Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/tobimichael96/885

This commit is contained in:
SergeantPanda 2026-01-30 12:37:07 -06:00
commit 68879ea933
101 changed files with 12658 additions and 1767 deletions

View file

@ -7,12 +7,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including:
- `useLocalStorage` hook tests with localStorage mocking and error handling
- `useSmartLogos` hook tests for logo loading and management
- `useTablePreferences` hook tests for table settings persistence
- `useAuthStore` tests for authentication flow and token management
- `useChannelsStore` tests for channel data management
- `useUserAgentsStore` tests for user agent CRUD operations
- `useUsersStore` tests for user management functionality
- `useVODLogosStore` tests for VOD logo operations
- `useVideoStore` tests for video player state management
- `useWarningsStore` tests for warning suppression functionality
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
### Changed
- 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.
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
- Modern OpenAPI 3.0 specification compliance
- Better auto-generation of request/response schemas
- Improved documentation accuracy with serializer introspection
### Fixed
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.18.1] - 2026-01-27
### Fixed
- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure
## [0.18.0] - 2026-01-27
### 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
- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability
- 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 +129,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.

View file

@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response
from rest_framework import viewsets, status
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework import viewsets, status, serializers
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
import json
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet):
return [IsAuthenticated()]
return []
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["username", "password"],
properties={
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(
type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
),
@extend_schema(
description="Authenticate and log in a user",
request=inline_serializer(
name="LoginRequest",
fields={
"username": serializers.CharField(),
"password": serializers.CharField(),
},
),
responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet):
)
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"},
@extend_schema(
description="Log out the current user",
)
def logout(self, request):
"""Logs out the authenticated user"""
@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet):
return [IsAdmin()]
@swagger_auto_schema(
operation_description="Retrieve a list of users",
@extend_schema(
description="Retrieve a list of users",
responses={200: UserSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific user by ID")
@extend_schema(description="Retrieve a specific user by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new user")
@extend_schema(description="Create a new user")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a user")
@extend_schema(description="Update a user")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a user")
@extend_schema(description="Delete a user")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@swagger_auto_schema(
method="get",
operation_description="Get active user information",
@extend_schema(
description="Get active user information",
)
@action(detail=False, methods=["get"], url_path="me")
def me(self, request):
@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet):
serializer_class = GroupSerializer
permission_classes = [Authenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of groups",
@extend_schema(
description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific group by ID")
@extend_schema(description="Retrieve a specific group by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new group")
@extend_schema(description="Create a new group")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a group")
@extend_schema(description="Update a group")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a group")
@extend_schema(description="Delete a group")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 4) Permissions List API
@swagger_auto_schema(
method="get",
operation_description="Retrieve a list of all permissions",
@extend_schema(
description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)},
)
@api_view(["GET"])

View file

@ -1,23 +1,8 @@
from django.urls import path, include, re_path
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework.permissions import AllowAny
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
app_name = 'api'
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="support@dispatcharr.local"),
license=openapi.License(name="Unlicense"),
),
public=True,
permission_classes=(AllowAny,),
)
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
@ -35,8 +20,9 @@ urlpatterns = [
# Swagger Documentation api_urls
re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
# OpenAPI Schema and Documentation (drf-spectacular)
path('schema/', SpectacularAPIView.as_view(), name='schema'),
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
]

View file

@ -4,10 +4,12 @@ from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
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 +99,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 +150,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,17 +203,82 @@ class StreamViewSet(viewsets.ModelViewSet):
# Return the response with the list of unique group names
return Response(list(group_names))
@swagger_auto_schema(
method="post",
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["ids"],
properties={
"ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to retrieve"
@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
]
})
@extend_schema(
methods=["POST"],
description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
request=inline_serializer(
name="StreamByIdsRequest",
fields={
"ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="List of stream IDs to retrieve"
),
},
),
@ -268,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
return super().partial_update(request, *args, **kwargs)
@swagger_auto_schema(
method="post",
operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)",
responses={200: "Cleanup completed"},
@extend_schema(
methods=["POST"],
description="Delete all channel groups that have no associations (no channels or M3U accounts)",
)
@action(detail=False, methods=["post"], url_path="cleanup")
def cleanup_unused_groups(self, request):
@ -519,6 +591,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)
@ -753,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Return the response with the list of IDs
return Response(list(channel_ids))
@swagger_auto_schema(
method="post",
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"starting_number": openapi.Schema(
type=openapi.TYPE_NUMBER,
description="Starting channel number to assign (can be decimal)",
@extend_schema(
methods=["POST"],
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request=inline_serializer(
name="AssignChannelsRequest",
fields={
"starting_number": serializers.FloatField(
help_text="Starting channel number to assign (can be decimal)",
required=False,
),
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to assign",
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Channel IDs to assign",
),
},
),
responses={200: "Channels have been auto-assigned!"},
)
@action(detail=False, methods=["post"], url_path="assign")
def assign(self, request):
@ -791,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK
)
@swagger_auto_schema(
method="post",
operation_description=(
@extend_schema(
methods=["POST"],
description=(
"Create a new channel from an existing stream. "
"If 'channel_number' is provided, it will be used (if available); "
"otherwise, the next available channel number is assigned. "
"If 'channel_profile_ids' is provided, the channel will only be added to those profiles. "
"Accepts either a single ID or an array of IDs."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
request=inline_serializer(
name="FromStreamRequest",
fields={
"stream_id": serializers.IntegerField(help_text="ID of the stream to link"),
"channel_number": serializers.FloatField(
help_text="(Optional) Desired channel number. Must not be in use.",
required=False,
),
"channel_number": openapi.Schema(
type=openapi.TYPE_NUMBER,
description="(Optional) Desired channel number. Must not be in use.",
),
"name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
"name": serializers.CharField(help_text="Desired channel name", required=False),
"channel_profile_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
required=False,
),
},
),
@ -881,6 +949,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
@ -976,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
@swagger_auto_schema(
method="post",
operation_description=(
@extend_schema(
methods=["POST"],
description=(
"Asynchronously bulk create channels from stream IDs. "
"Returns a task ID to track progress via WebSocket. "
"This is the recommended approach for large bulk operations."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to create channels from"
request=inline_serializer(
name="FromStreamBulkRequest",
fields={
"stream_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="List of stream IDs to create channels from"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
"channel_profile_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
required=False,
),
"starting_channel_number": openapi.Schema(
type=openapi.TYPE_INTEGER,
description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number"
"starting_channel_number": serializers.IntegerField(
help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number",
required=False,
),
},
),
responses={202: "Task started successfully"},
)
@action(detail=False, methods=["post"], url_path="from-stream/bulk")
def from_stream_bulk(self, request):
@ -1043,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 6) EPG Fuzzy Matching
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
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='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
@extend_schema(
methods=["POST"],
description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
request=inline_serializer(
name="MatchEpgRequest",
fields={
'channel_ids': serializers.ListField(
child=serializers.IntegerField(),
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
required=False,
)
}
),
responses={202: "EPG matching task initiated"},
)
@action(detail=False, methods=["post"], url_path="match-epg")
def match_epg(self, request):
@ -1077,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": message}, status=status.HTTP_202_ACCEPTED
)
@swagger_auto_schema(
method="post",
operation_description="Try to auto-match this specific channel with EPG data.",
responses={200: "EPG matching completed", 202: "EPG matching task initiated"},
@extend_schema(
methods=["POST"],
description="Try to auto-match this specific channel with EPG data.",
)
@action(detail=True, methods=["post"], url_path="match-epg")
def match_channel_epg(self, request, pk=None):
@ -1107,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
operation_description="Set EPG data for a channel and refresh program data",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["epg_data_id"],
properties={
"epg_data_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="EPG data ID to link"
)
@extend_schema(
methods=["POST"],
description="Set EPG data for a channel and refresh program data",
request=inline_serializer(
name="SetEpgRequest",
fields={
"epg_data_id": serializers.IntegerField(help_text="EPG data ID to link")
},
),
responses={200: "EPG data linked and refresh triggered"},
@ -1172,25 +1233,106 @@ class ChannelViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=400)
@swagger_auto_schema(
method="post",
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"associations": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"channel_id": openapi.Schema(type=openapi.TYPE_INTEGER),
"epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER),
@extend_schema(
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=inline_serializer(
name="ReorderChannelRequest",
fields={
"insert_after_id": serializers.IntegerField(
help_text="ID of the channel to insert after. Use null to move to the beginning.",
required=False,
allow_null=True,
),
},
),
)
@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,
)
@extend_schema(
methods=["POST"],
description="Associate multiple channels with EPG data without triggering a full refresh",
request=inline_serializer(
name="BatchSetEpgRequest",
fields={
"associations": serializers.ListField(
child=inline_serializer(
name="EpgAssociation",
fields={
"channel_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(),
},
),
)
},
),
responses={200: "EPG data linked for multiple channels"},
)
@action(detail=False, methods=["post"], url_path="batch-set-epg")
def batch_set_epg(self, request):
@ -1288,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete streams by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Stream IDs to delete",
@extend_schema(
description="Bulk delete streams by ID",
request=inline_serializer(
name="BulkDeleteStreamsRequest",
fields={
"stream_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Stream IDs to delete",
)
},
),
responses={204: "Streams deleted"},
)
def delete(self, request, *args, **kwargs):
stream_ids = request.data.get("stream_ids", [])
@ -1324,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete channels by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to delete",
@extend_schema(
description="Bulk delete channels by ID",
request=inline_serializer(
name="BulkDeleteChannelsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Channel IDs to delete",
)
},
),
responses={204: "Channels deleted"},
)
def delete(self, request):
channel_ids = request.data.get("channel_ids", [])
@ -1359,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete logos by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["logo_ids"],
properties={
"logo_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Logo IDs to delete",
@extend_schema(
description="Bulk delete logos by ID",
request=inline_serializer(
name="BulkDeleteLogosRequest",
fields={
"logo_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Logo IDs to delete",
)
},
),
responses={204: "Logos deleted"},
)
def delete(self, request):
logo_ids = request.data.get("logo_ids", [])
@ -1429,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Delete all channel logos that are not used by any channels",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"delete_files": openapi.Schema(
type=openapi.TYPE_BOOLEAN,
description="Whether to delete local logo files from disk",
default=False
@extend_schema(
description="Delete all channel logos that are not used by any channels",
request=inline_serializer(
name="CleanupUnusedLogosRequest",
fields={
"delete_files": serializers.BooleanField(
help_text="Whether to delete local logo files from disk",
default=False,
required=False,
)
},
),
responses={200: "Cleanup completed"},
)
def post(self, request):
"""Delete all channel logos with no channel associations"""
@ -1851,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
request_body=BulkChannelProfileMembershipSerializer,
responses={
200: openapi.Response(
description="Channels updated successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"status": openapi.Schema(type=openapi.TYPE_STRING, example="success"),
"updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"),
"created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"),
"invalid_channels": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description="List of channel IDs that don't exist"
),
},
),
),
400: "Invalid request data",
404: "Profile not found",
},
@extend_schema(
description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
request=BulkChannelProfileMembershipSerializer,
)
def patch(self, request, profile_id):
"""Bulk enable or disable channels for a specific profile"""
@ -2238,9 +2350,25 @@ class SeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@extend_schema(
summary="List all series rules",
description="Retrieve all configured DVR series recording rules.",
)
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
@extend_schema(
summary="Create or update a series rule",
description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
request=inline_serializer(
name="SeriesRuleRequest",
fields={
'tvg_id': serializers.CharField(help_text='Channel TVG ID'),
'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'),
'title': serializers.CharField(help_text='Series title', required=False),
},
),
)
def post(self, request):
data = request.data or {}
tvg_id = str(data.get("tvg_id") or "").strip()
@ -2273,6 +2401,13 @@ class DeleteSeriesRuleAPIView(APIView):
except KeyError:
return [Authenticated()]
@extend_schema(
summary="Delete a series rule",
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
parameters=[
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
],
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
@ -2287,6 +2422,16 @@ class EvaluateSeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@extend_schema(
summary="Evaluate series rules",
description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
request=inline_serializer(
name="EvaluateSeriesRulesRequest",
fields={
"tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."),
},
),
)
def post(self, request):
tvg_id = request.data.get("tvg_id")
# Run synchronously so UI sees results immediately
@ -2308,6 +2453,18 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
except KeyError:
return [Authenticated()]
@extend_schema(
summary="Bulk remove scheduled recordings for a series",
description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
request=inline_serializer(
name="BulkRemoveSeriesRecordingsRequest",
fields={
"tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"),
"title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"),
"scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"),
},
),
)
def post(self, request):
from django.utils import timezone
tvg_id = str(request.data.get("tvg_id") or "").strip()

View file

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

View file

@ -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"

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import logging, os
from rest_framework import viewsets, status
from rest_framework import viewsets, status, serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
@ -122,8 +122,8 @@ class EPGGridAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
@extend_schema(
description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
responses={200: ProgramDataSerializer(many=True)},
)
def get(self, request, format=None):
@ -371,9 +371,8 @@ class EPGImportAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers an EPG data import",
responses={202: "EPG data import initiated"},
@extend_schema(
description="Triggers an EPG data import",
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
@ -417,3 +416,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()]
@extend_schema(
description="Get currently playing programs for specified channels or all channels",
request=inline_serializer(
name="CurrentProgramsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
),
},
),
responses={200: 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)

View file

@ -4,8 +4,8 @@ from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.db import models
from apps.channels.models import Channel, ChannelProfile, Stream
@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")},
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request, profile=None):
uri_parts = ["hdhr"]
@ -100,9 +99,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")},
@extend_schema(
description="Retrieve the available channel lineup",
)
def get(self, request, profile=None):
if profile is not None:
@ -141,9 +139,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")},
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request, profile=None):
data = {
@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")},
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")

View file

@ -3,8 +3,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from .models import HDHRDevice
@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")},
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
@ -81,9 +80,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")},
@extend_schema(
description="Retrieve the available channel lineup",
)
def get(self, request):
channels = Channel.objects.all().order_by("channel_number")
@ -102,9 +100,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")},
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request):
data = {
@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")},
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")

View file

@ -6,8 +6,8 @@ from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of all active M3U accounts",
responses={202: "M3U refresh initiated"},
@extend_schema(
description="Triggers a refresh of all active M3U accounts",
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of a single M3U account",
responses={202: "M3U account refresh initiated"},
@extend_schema(
description="Triggers a refresh of a single M3U account",
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of account information for a specific M3U profile",
responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"},
@extend_schema(
description="Triggers a refresh of account information for a specific M3U profile",
)
def post(self, request, profile_id, format=None):
try:

View file

@ -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:

View file

@ -1,5 +1,8 @@
from django.test import TestCase, Client
from django.urls import reverse
from apps.channels.models import Channel, ChannelGroup
from apps.epg.models import EPGData, EPGSource
import xml.etree.ElementTree as ET
class OutputM3UTest(TestCase):
def setUp(self):
@ -37,3 +40,106 @@ class OutputM3UTest(TestCase):
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
class OutputEPGXMLEscapingTest(TestCase):
"""Test XML escaping of channel_id attributes in EPG generation"""
def setUp(self):
self.client = Client()
self.group = ChannelGroup.objects.create(name="Test Group")
def test_channel_id_with_ampersand(self):
"""Test channel ID with ampersand is properly escaped"""
channel = Channel.objects.create(
channel_number=1.0,
name="Test Channel",
tvg_id="News & Sports",
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
# Should contain escaped ampersand
self.assertIn('id="News &amp; Sports"', content)
self.assertNotIn('id="News & Sports"', content)
# Verify XML is parseable
try:
ET.fromstring(content)
except ET.ParseError as e:
self.fail(f"Generated EPG is not valid XML: {e}")
def test_channel_id_with_angle_brackets(self):
"""Test channel ID with < and > characters"""
channel = Channel.objects.create(
channel_number=2.0,
name="HD Channel",
tvg_id="Channel <HD>",
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
self.assertIn('id="Channel &lt;HD&gt;"', content)
try:
ET.fromstring(content)
except ET.ParseError as e:
self.fail(f"Generated EPG with < > is not valid XML: {e}")
def test_channel_id_with_all_special_chars(self):
"""Test channel ID with all XML special characters"""
channel = Channel.objects.create(
channel_number=3.0,
name="Complex Channel",
tvg_id='Test & "Special" <Chars>',
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
self.assertIn('id="Test &amp; &quot;Special&quot; &lt;Chars&gt;"', content)
try:
tree = ET.fromstring(content)
# Verify we can find the channel with correct ID in parsed tree
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
self.assertIsNotNone(channel_elem)
except ET.ParseError as e:
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
def test_program_channel_attribute_escaping(self):
"""Test that programme elements also have escaped channel attributes"""
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
channel = Channel.objects.create(
channel_number=4.0,
name="Program Test",
tvg_id="News & Sports",
epg_data=epg_data,
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
# Check programme elements have escaped channel attributes
self.assertIn('channel="News &amp; Sports"', content)
try:
tree = ET.fromstring(content)
programmes = tree.findall('.//programme[@channel="News & Sports"]')
self.assertGreater(len(programmes), 0)
except ET.ParseError as e:
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")

View file

@ -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(
@ -1196,7 +1203,7 @@ def generate_dummy_epg(
# Create program entry with escaped channel name
xml_lines.append(
f' <programme start="{start_str}" stop="{stop_str}" channel="{program["channel_id"]}">'
f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
)
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
@ -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(
@ -1434,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None):
else:
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id]))
display_name = channel.name
xml_lines.append(f' <channel id="{channel_id}">')
xml_lines.append(f' <channel id="{html.escape(channel_id)}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
xml_lines.append(f' <icon src="{html.escape(tvg_logo)}" />')
xml_lines.append(" </channel>")
@ -1509,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None):
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
# Create program entry with escaped channel name
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">\n'
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
@ -1558,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None):
start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z")
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">\n'
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
yield f" <title>{html.escape(program['title'])}</title>\n"
yield f" <desc>{html.escape(program['description'])}</desc>\n"
@ -1620,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None):
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
program_xml = [f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">']
program_xml = [f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">']
program_xml.append(f' <title>{html.escape(prog.title)}</title>')
# Add subtitle if available
@ -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:

View file

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

View file

@ -9,8 +9,8 @@ from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes, action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from .models import (
UserAgent,
StreamProfile,
@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
@swagger_auto_schema(
method="get",
operation_description="Endpoint for environment details",
responses={200: "Environment variables"},
@extend_schema(
description="Endpoint for environment details",
)
@api_view(["GET"])
@permission_classes([Authenticated])
@ -314,10 +312,8 @@ def environment(request):
)
@swagger_auto_schema(
method="get",
operation_description="Get application version information",
responses={200: "Version information"},
@extend_schema(
description="Get application version information",
)
@api_view(["GET"])
@ -333,10 +329,8 @@ def version(request):
)
@swagger_auto_schema(
method="post",
operation_description="Trigger rehashing of all streams",
responses={200: "Rehash task started"},
@extend_schema(
description="Trigger rehashing of all streams",
)
@api_view(["POST"])
@permission_classes([Authenticated])
@ -383,9 +377,8 @@ class TimezoneListView(APIView):
def get_permissions(self):
return [Authenticated()]
@swagger_auto_schema(
operation_description="Get list of all supported timezones",
responses={200: openapi.Response('List of timezones with grouping by region')}
@extend_schema(
description="Get list of all supported timezones",
)
def get(self, request):
import pytz

View file

@ -32,7 +32,7 @@ INSTALLED_APPS = [
"apps.vod.apps.VODConfig",
"core",
"daphne",
"drf_yasg",
"drf_spectacular",
"channels",
"django.contrib.admin",
"django.contrib.auth",
@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [
]
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
@ -162,10 +162,22 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
SWAGGER_SETTINGS = {
"SECURITY_DEFINITIONS": {
"Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}
}
SPECTACULAR_SETTINGS = {
"TITLE": "Dispatcharr API",
"DESCRIPTION": "API documentation for Dispatcharr",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SECURITY": [{"BearerAuth": []}],
"COMPONENTS": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.",
}
}
},
}
LANGUAGE_CODE = "en-us"

View file

@ -3,35 +3,20 @@ from django.urls import path, include, re_path
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView, RedirectView
from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
from apps.output.views import xc_movie_stream, xc_series_stream
# Define schema_view for Swagger
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version="v1",
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="contact@dispatcharr.local"),
license=openapi.License(name="Creative Commons by-nc-sa"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
# API Routes
path("api/", include(("apps.api.urls", "api"), namespace="api")),
path("api", RedirectView.as_view(url="/api/", permanent=True)),
# Admin
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
path("admin/", admin.site.urls),
# Swagger redirects (Swagger UI is served at /api/swagger/)
path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)),
path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
# Outputs
path("output", RedirectView.as_view(url="/output/", permanent=True)),
path("output/", include(("apps.output.urls", "output"), namespace="output")),
@ -67,12 +52,9 @@ urlpatterns = [
xc_series_stream,
name="xc_series_stream",
),
re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
# ReDoc UI
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
# Optionally, serve the raw Swagger JSON
path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
# Admin
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
path("admin/", admin.site.urls),
# VOD proxy is now handled by the main proxy URLs above
# Catch-all routes should always be last

View file

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

View file

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

View file

@ -59,7 +59,7 @@ server {
}
# admin disabled when not in dev mode
location /admin {
location ~ ^/admin/?$ {
return 301 /login;
}

View file

@ -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"

View file

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

View file

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

View file

@ -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.

View file

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

View file

@ -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) => {

View file

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

View file

@ -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">

View file

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

View file

@ -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" />

View file

@ -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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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={

View file

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

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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,6 @@ const StreamsTable = ({ onReady }) => {
channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
const fetchLogos = useChannelsStore.getState().fetchLogos;
fetchLogos();
};
// Handle confirming the single channel numbering modal
@ -844,38 +913,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 +949,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 +966,7 @@ const StreamsTable = ({ onReady }) => {
/>
</Flex>
);
}
}
};
@ -946,6 +1011,10 @@ const StreamsTable = ({ onReady }) => {
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
state: {
pagination,
sorting,
},
headerCellRenderFns: {
name: renderHeaderCell,
group: renderHeaderCell,
@ -972,6 +1041,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 +1161,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>

View file

@ -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 = () => {

View file

@ -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',

View file

@ -0,0 +1,110 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useLocalStorage from '../useLocalStorage';
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key) => {
delete store[key];
})
};
})();
global.localStorage = localStorageMock;
// Mock console.error to avoid cluttering test output
global.console.error = vi.fn();
describe('useLocalStorage', () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
it('should initialize with default value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
expect(result.current[0]).toBe('defaultValue');
});
it('should initialize with value from localStorage if available', () => {
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
expect(result.current[0]).toBe('storedValue');
});
it('should update localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
expect(result.current[0]).toBe('updated');
});
it('should handle complex objects', () => {
const complexObject = { name: 'test', count: 42, nested: { value: true } };
const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
act(() => {
result.current[1]({ name: 'updated', count: 100 });
});
expect(result.current[0]).toEqual({ name: 'updated', count: 100 });
});
it('should handle errors when reading from localStorage', () => {
localStorageMock.getItem.mockImplementationOnce(() => {
throw new Error('Read error');
});
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalledWith(
'Error reading key "testKey":',
expect.any(Error)
);
});
it('should handle errors when writing to localStorage', () => {
localStorageMock.setItem.mockImplementationOnce(() => {
throw new Error('Write error');
});
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(console.error).toHaveBeenCalledWith(
'Error saving setting: testKey:',
expect.any(Error)
);
});
it('should handle invalid JSON in localStorage', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,297 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
import useLogosStore from '../../store/logos';
// Mock the logos store
vi.mock('../../store/logos');
describe('useSmartLogos', () => {
describe('useLogoSelection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with empty state', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: vi.fn(),
}));
const { result } = renderHook(() => useLogoSelection());
expect(result.current.logos).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.hasLogos).toBe(false);
});
it('should load logos when ensureLogosLoaded is called', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
const { result } = renderHook(() => useLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
});
it('should not reload logos if already loaded', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogos: mockFetchLogos,
}));
const { result } = renderHook(() => useLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
});
it('should handle errors when fetching logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogos: mockFetchLogos,
}));
const { result } = renderHook(() => useLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load logos for selection:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
it('should indicate hasLogos when logos are present', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
fetchLogos: vi.fn(),
}));
const { result } = renderHook(() => useLogoSelection());
expect(result.current.hasLogos).toBe(true);
});
});
describe('useChannelLogoSelection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with channel logos state', () => {
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: vi.fn(),
}));
const { result } = renderHook(() => useChannelLogoSelection());
expect(result.current.logos).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.hasLogos).toBe(false);
});
it('should load channel logos when ensureLogosLoaded is called', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const { result } = renderHook(() => useChannelLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1);
});
it('should not reload if already loaded', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: { logo1: { id: 'logo1' } },
hasLoadedChannelLogos: true,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const { result } = renderHook(() => useChannelLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
});
it('should not load if backgroundLoading is true', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: true,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const { result } = renderHook(() => useChannelLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
});
it('should handle errors when fetching channel logos', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
channelLogos: {},
hasLoadedChannelLogos: false,
backgroundLoading: false,
fetchChannelAssignableLogos: mockFetchChannelLogos,
}));
const { result } = renderHook(() => useChannelLogoSelection());
await act(async () => {
await result.current.ensureLogosLoaded();
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load channel logos:',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe('useLogosById', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with empty logos', () => {
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: vi.fn(),
}));
const { result } = renderHook(() => useLogosById([]));
expect(result.current.logos).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.missingLogos).toBe(0);
});
it('should fetch missing logos by IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
renderHook(() => useLogosById(['logo1', 'logo2']));
await waitFor(() => {
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
});
});
it('should not fetch logos that are already loaded', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: { logo1: { id: 'logo1' } },
fetchLogosByIds: mockFetchLogosByIds,
}));
renderHook(() => useLogosById(['logo1', 'logo2']));
await waitFor(() => {
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']);
});
});
it('should handle errors when fetching logos by IDs', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
renderHook(() => useLogosById(['logo1']));
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load logos by IDs:',
expect.any(Error)
);
});
consoleErrorSpy.mockRestore();
});
it('should filter out null/undefined IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
await waitFor(() => {
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
});
});
it('should not refetch the same IDs multiple times', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
useLogosStore.mockImplementation((selector) => selector({
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
}));
const { rerender } = renderHook(() => useLogosById(['logo1']));
await waitFor(() => {
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
});
rerender();
await waitFor(() => {
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
});
});
});
});

View file

@ -0,0 +1,390 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import useTablePreferences from '../useTablePreferences';
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key) => {
delete store[key];
})
};
})();
global.localStorage = localStorageMock;
describe('useTablePreferences', () => {
let consoleErrorSpy;
beforeEach(() => {
// Spy on console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
localStorageMock.clear();
// Mock window.addEventListener and removeEventListener
vi.spyOn(window, 'addEventListener');
vi.spyOn(window, 'removeEventListener');
});
afterEach(() => {
vi.clearAllMocks();
consoleErrorSpy.mockRestore();
});
describe('Initial State', () => {
it('should initialize with default values when localStorage is empty', () => {
const { result } = renderHook(() => useTablePreferences());
expect(result.current.headerPinned).toBe(false);
expect(result.current.tableSize).toBe('default');
});
it('should initialize headerPinned from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
const { result } = renderHook(() => useTablePreferences());
expect(result.current.headerPinned).toBe(true);
});
it('should initialize tableSize from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
const { result } = renderHook(() => useTablePreferences());
expect(result.current.tableSize).toBe('compact');
});
it('should initialize both preferences from localStorage', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
const { result } = renderHook(() => useTablePreferences());
expect(result.current.headerPinned).toBe(true);
expect(result.current.tableSize).toBe('comfortable');
});
it('should migrate tableSize from old localStorage location', () => {
localStorageMock.setItem('table-size', JSON.stringify('compact'));
const { result } = renderHook(() => useTablePreferences());
expect(result.current.tableSize).toBe('compact');
});
it('should prefer new localStorage location over old location', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
localStorageMock.setItem('table-size', JSON.stringify('compact'));
const { result } = renderHook(() => useTablePreferences());
expect(result.current.tableSize).toBe('comfortable');
});
it('should handle malformed JSON in localStorage gracefully', () => {
localStorageMock.setItem('table-preferences', 'invalid json');
const { result } = renderHook(() => useTablePreferences());
expect(result.current.headerPinned).toBe(false);
expect(result.current.tableSize).toBe('default');
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
});
});
describe('setHeaderPinned', () => {
it('should update headerPinned state', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setHeaderPinned(true);
});
expect(result.current.headerPinned).toBe(true);
});
it('should persist headerPinned to localStorage', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setHeaderPinned(true);
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
});
it('should preserve existing preferences when updating headerPinned', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setHeaderPinned(true);
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ tableSize: 'compact', headerPinned: true })
);
});
it('should dispatch custom event when updating headerPinned', () => {
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setHeaderPinned(true);
});
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'table-preferences-changed',
detail: { headerPinned: true },
})
);
dispatchEventSpy.mockRestore();
});
it('should handle localStorage errors gracefully', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
localStorageMock.setItem.mockImplementationOnce(() => {
throw new Error('Storage error');
});
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setHeaderPinned(true);
});
expect(result.current.headerPinned).toBe(true);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error saving headerPinned to localStorage:',
expect.any(Error)
);
});
});
describe('setTableSize', () => {
it('should update tableSize state', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setTableSize('compact');
});
expect(result.current.tableSize).toBe('compact');
});
it('should persist tableSize to localStorage', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setTableSize('comfortable');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ tableSize: 'comfortable' })
);
});
it('should preserve existing preferences when updating tableSize', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setTableSize('compact');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
);
});
it('should dispatch custom event when updating tableSize', () => {
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setTableSize('compact');
});
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'table-preferences-changed',
detail: { tableSize: 'compact' },
})
);
dispatchEventSpy.mockRestore();
});
it('should handle localStorage errors gracefully', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
localStorageMock.setItem.mockImplementationOnce(() => {
throw new Error('Storage error');
});
const { result } = renderHook(() => useTablePreferences());
act(() => {
result.current.setTableSize('compact');
});
expect(result.current.tableSize).toBe('compact');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error saving tableSize to localStorage:',
expect.any(Error)
);
});
});
describe('Event Listeners', () => {
it('should register event listener on mount', () => {
renderHook(() => useTablePreferences());
expect(window.addEventListener).toHaveBeenCalledWith(
'table-preferences-changed',
expect.any(Function)
);
});
it('should remove event listener on unmount', () => {
const { unmount } = renderHook(() => useTablePreferences());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith(
'table-preferences-changed',
expect.any(Function)
);
});
it('should update headerPinned from custom event', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
const event = new CustomEvent('table-preferences-changed', {
detail: { headerPinned: true },
});
window.dispatchEvent(event);
});
expect(result.current.headerPinned).toBe(true);
});
it('should update tableSize from custom event', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
const event = new CustomEvent('table-preferences-changed', {
detail: { tableSize: 'compact' },
});
window.dispatchEvent(event);
});
expect(result.current.tableSize).toBe('compact');
});
it('should update both preferences from custom event', () => {
const { result } = renderHook(() => useTablePreferences());
act(() => {
const event = new CustomEvent('table-preferences-changed', {
detail: { headerPinned: true, tableSize: 'comfortable' },
});
window.dispatchEvent(event);
});
expect(result.current.headerPinned).toBe(true);
expect(result.current.tableSize).toBe('comfortable');
});
it('should not update if value is the same', () => {
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
const { result } = renderHook(() => useTablePreferences());
const initialHeaderPinned = result.current.headerPinned;
act(() => {
const event = new CustomEvent('table-preferences-changed', {
detail: { headerPinned: true },
});
window.dispatchEvent(event);
});
expect(result.current.headerPinned).toBe(initialHeaderPinned);
});
});
describe('Integration Tests', () => {
it('should handle complete workflow', () => {
const { result } = renderHook(() => useTablePreferences());
// Update headerPinned
act(() => {
result.current.setHeaderPinned(true);
});
expect(result.current.headerPinned).toBe(true);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ headerPinned: true })
);
// Update tableSize
act(() => {
result.current.setTableSize('compact');
});
expect(result.current.tableSize).toBe('compact');
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'table-preferences',
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
);
// Verify both preferences are maintained
expect(result.current.headerPinned).toBe(true);
expect(result.current.tableSize).toBe('compact');
});
it('should sync changes across multiple hook instances via events', () => {
const { result: result1 } = renderHook(() => useTablePreferences());
const { result: result2 } = renderHook(() => useTablePreferences());
// Update from first instance
act(() => {
result1.current.setHeaderPinned(true);
});
// Second instance should receive the event
act(() => {
const event = new CustomEvent('table-preferences-changed', {
detail: { headerPinned: true },
});
window.dispatchEvent(event);
});
expect(result2.current.headerPinned).toBe(true);
});
});
});

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
const useLocalStorage = (key, defaultValue) => {
const localKey = key;

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,631 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import useAuthStore from '../auth';
import useSettingsStore from '../settings';
import useChannelsStore from '../channels';
import usePlaylistsStore from '../playlists';
import useEPGsStore from '../epgs';
import useStreamProfilesStore from '../streamProfiles';
import useUserAgentsStore from '../userAgents';
import useUsersStore from '../users';
import API from '../../api';
import { USER_LEVELS } from '../../constants';
// Mock all store dependencies
vi.mock('../settings');
vi.mock('../channels');
vi.mock('../playlists');
vi.mock('../epgs');
vi.mock('../streamProfiles');
vi.mock('../userAgents');
vi.mock('../users');
vi.mock('../../api');
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
global.localStorage = localStorageMock;
// Helper to create a mock JWT token
const createMockToken = (expiresInSeconds = 3600) => {
const now = Math.floor(Date.now() / 1000);
const exp = now + expiresInSeconds;
const payload = btoa(JSON.stringify({ exp }));
return `header.${payload}.signature`;
};
describe('useAuthStore', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
// Setup default store mocks
useSettingsStore.mockImplementation((selector) => selector({
fetchSettings: vi.fn().mockResolvedValue(),
}));
useChannelsStore.mockImplementation((selector) => selector({
fetchChannels: vi.fn().mockResolvedValue(),
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
}));
usePlaylistsStore.mockImplementation((selector) => selector({
fetchPlaylists: vi.fn().mockResolvedValue(),
}));
useEPGsStore.mockImplementation((selector) => selector({
fetchEPGs: vi.fn().mockResolvedValue(),
fetchEPGData: vi.fn().mockResolvedValue(),
}));
useStreamProfilesStore.mockImplementation((selector) => selector({
fetchProfiles: vi.fn().mockResolvedValue(),
}));
useUserAgentsStore.mockImplementation((selector) => selector({
fetchUserAgents: vi.fn().mockResolvedValue(),
}));
useUsersStore.mockImplementation((selector) => selector({
fetchUsers: vi.fn().mockResolvedValue(),
}));
});
afterEach(() => {
// Reset the store state
const { setState } = useAuthStore;
if (setState) {
setState({
isAuthenticated: false,
isInitialized: false,
needsSuperuser: false,
user: {
username: '',
email: '',
user_level: '',
},
isLoading: false,
error: null,
accessToken: null,
refreshToken: null,
tokenExpiration: null,
superuserExists: true,
});
}
});
describe('Initial State', () => {
it('should initialize with default state', () => {
const { result } = renderHook(() => useAuthStore());
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isInitialized).toBe(false);
expect(result.current.needsSuperuser).toBe(false);
expect(result.current.user).toEqual({
username: '',
email: '',
user_level: '',
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.superuserExists).toBe(true);
});
});
describe('login', () => {
it('should successfully login and store tokens', async () => {
const mockAccessToken = createMockToken();
const mockRefreshToken = createMockToken(86400);
API.login.mockResolvedValue({
access: mockAccessToken,
refresh: mockRefreshToken,
});
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
});
expect(API.login).toHaveBeenCalledWith('testuser', 'password');
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
});
it('should handle login failure', async () => {
API.login.mockRejectedValue(new Error('Invalid credentials'));
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'wrong' });
});
expect(API.login).toHaveBeenCalledWith('testuser', 'wrong');
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.setItem).not.toHaveBeenCalled();
});
});
describe('getRefreshToken', () => {
it('should refresh token successfully', async () => {
const mockNewAccessToken = createMockToken();
localStorageMock.getItem.mockReturnValue('old-refresh-token');
API.refreshToken.mockResolvedValue({
access: mockNewAccessToken,
});
const { result } = renderHook(() => useAuthStore());
let newToken;
await act(async () => {
newToken = await result.current.getRefreshToken();
});
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
expect(newToken).toBe(mockNewAccessToken);
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
});
it('should return false if no refresh token exists', async () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() => useAuthStore());
let response;
await act(async () => {
response = await result.current.getRefreshToken();
});
expect(response).toBe(false);
expect(API.refreshToken).not.toHaveBeenCalled();
});
it('should logout on refresh token failure', async () => {
localStorageMock.getItem.mockReturnValue('invalid-refresh-token');
API.refreshToken.mockRejectedValue(new Error('Invalid token'));
API.logout.mockResolvedValue();
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.getRefreshToken();
});
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
});
});
describe('getToken', () => {
it('should return valid access token if not expired', async () => {
const mockToken = createMockToken(3600);
const now = Math.floor(Date.now() / 1000);
localStorageMock.getItem.mockImplementation((key) => {
if (key === 'tokenExpiration') return (now + 1800).toString();
if (key === 'accessToken') return mockToken;
return null;
});
const { result } = renderHook(() => useAuthStore());
let token;
await act(async () => {
token = await result.current.getToken();
});
expect(token).toBe(mockToken);
});
it('should refresh token if expired', async () => {
const now = Math.floor(Date.now() / 1000);
const mockNewToken = createMockToken();
localStorageMock.getItem.mockImplementation((key) => {
if (key === 'tokenExpiration') return (now - 100).toString();
if (key === 'refreshToken') return 'refresh-token';
return null;
});
API.refreshToken.mockResolvedValue({
access: mockNewToken,
});
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.getToken();
});
expect(API.refreshToken).toHaveBeenCalled();
});
});
describe('logout', () => {
it('should clear tokens and call logout API', async () => {
API.logout.mockResolvedValue();
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.logout();
});
expect(API.logout).toHaveBeenCalled();
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
});
it('should continue logout even if API call fails', async () => {
API.logout.mockRejectedValue(new Error('API error'));
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.logout();
});
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalled();
});
});
describe('initializeAuth', () => {
it('should initialize auth with valid refresh token', async () => {
const mockToken = createMockToken();
localStorageMock.getItem.mockReturnValue('valid-refresh-token');
API.refreshToken.mockResolvedValue({ access: mockToken });
const { result } = renderHook(() => useAuthStore());
let initialized;
await act(async () => {
initialized = await result.current.initializeAuth();
});
expect(initialized).toBe(true);
});
it('should return false if no refresh token exists', async () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() => useAuthStore());
let initialized;
await act(async () => {
initialized = await result.current.initializeAuth();
});
expect(initialized).toBe(false);
});
});
describe('initData', () => {
const fetchSettings = vi.fn().mockResolvedValue();
const fetchChannels = vi.fn().mockResolvedValue();
const fetchChannelGroups = vi.fn().mockResolvedValue();
const fetchChannelProfiles = vi.fn().mockResolvedValue();
const fetchPlaylists = vi.fn().mockResolvedValue();
const fetchEPGs = vi.fn().mockResolvedValue();
const fetchEPGData = vi.fn().mockResolvedValue();
const fetchProfiles = vi.fn().mockResolvedValue();
const fetchUserAgents = vi.fn().mockResolvedValue();
const fetchUsers = vi.fn().mockResolvedValue();
// Mock getState for each store
useSettingsStore.getState = () => ({ fetchSettings });
useChannelsStore.getState = () => ({
fetchChannels,
fetchChannelGroups,
fetchChannelProfiles,
});
usePlaylistsStore.getState = () => ({ fetchPlaylists });
useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData });
useStreamProfilesStore.getState = () => ({ fetchProfiles });
useUserAgentsStore.getState = () => ({ fetchUserAgents });
useUsersStore.getState = () => ({ fetchUsers });
it('should initialize data for admin user', async () => {
const mockUser = {
username: 'admin',
email: 'admin@test.com',
user_level: USER_LEVELS.ADMIN,
};
API.me.mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
expect(API.me).toHaveBeenCalled();
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
expect(fetchSettings).toHaveBeenCalled();
expect(fetchChannels).toHaveBeenCalled();
expect(fetchUsers).toHaveBeenCalled();
});
it('should not fetch users for non-admin user', async () => {
const mockUser = {
username: 'reseller',
email: 'reseller@test.com',
user_level: USER_LEVELS.RESELLER,
};
API.me.mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
expect(fetchUsers).not.toHaveBeenCalled();
});
it('should throw error for unauthorized user level', async () => {
const mockUser = {
username: 'streamer',
email: 'streamer@test.com',
user_level: USER_LEVELS.STREAMER,
};
API.me.mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthStore());
await expect(
act(async () => {
await result.current.initData();
})
).rejects.toThrow('Unauthorized');
});
it('should handle errors during data initialization', async () => {
const mockUser = {
username: 'admin',
email: 'admin@test.com',
user_level: USER_LEVELS.ADMIN,
};
API.me.mockResolvedValue(mockUser);
const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
useChannelsStore.getState = vi.fn(() => ({
fetchChannels,
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
}));
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
});
});
describe('setUser', () => {
it('should update user state', () => {
const { result } = renderHook(() => useAuthStore());
const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
act(() => {
result.current.setUser(newUser);
});
expect(result.current.user).toEqual(newUser);
});
});
describe('setIsAuthenticated', () => {
it('should update authentication state', () => {
const { result } = renderHook(() => useAuthStore());
act(() => {
result.current.setIsAuthenticated(true);
});
expect(result.current.isAuthenticated).toBe(true);
});
});
describe('setSuperuserExists', () => {
it('should update superuser exists state', () => {
const { result } = renderHook(() => useAuthStore());
act(() => {
result.current.setSuperuserExists(false);
});
expect(result.current.superuserExists).toBe(false);
});
});
describe('getRefreshToken edge cases', () => {
it('should return false if API response has no access token', async () => {
localStorageMock.getItem.mockReturnValue('refresh-token');
API.refreshToken.mockResolvedValue({});
const { result } = renderHook(() => useAuthStore());
let response;
await act(async () => {
response = await result.current.getRefreshToken();
});
expect(response).toBe(false);
});
});
describe('login edge cases', () => {
it('should not update state if response has no access token', async () => {
API.login.mockResolvedValue({});
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.login({ username: 'testuser', password: 'password' });
});
expect(result.current.accessToken).toBeNull();
expect(localStorageMock.setItem).not.toHaveBeenCalled();
});
});
describe('logout edge cases', () => {
it('should reset isInitializing flag on logout', async () => {
useAuthStore.setState({ isInitializing: true });
API.logout.mockResolvedValue();
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.logout();
});
expect(result.current.isInitializing).toBe(false);
});
});
describe('initializeAuth edge cases', () => {
it('should return false if refresh token API call fails', async () => {
localStorageMock.getItem.mockReturnValue('refresh-token');
API.refreshToken.mockRejectedValue(new Error('Token expired'));
API.logout.mockResolvedValue();
const { result } = renderHook(() => useAuthStore());
let initialized;
await act(async () => {
initialized = await result.current.initializeAuth();
});
expect(initialized).toBe(false);
});
it('should return false if refresh returns no access token', async () => {
localStorageMock.getItem.mockReturnValue('refresh-token');
API.refreshToken.mockResolvedValue({});
const { result } = renderHook(() => useAuthStore());
let initialized;
await act(async () => {
initialized = await result.current.initializeAuth();
});
expect(initialized).toBe(false);
});
});
describe('initData edge cases', () => {
it('should skip initialization if already initialized', async () => {
useAuthStore.setState({ isInitialized: true });
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
expect(API.me).not.toHaveBeenCalled();
});
it('should skip initialization if already initializing', async () => {
useAuthStore.setState({ isInitializing: true });
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
expect(API.me).not.toHaveBeenCalled();
});
it('should set isInitializing to false on error', async () => {
// Reset state before the test
useAuthStore.setState({
isInitializing: false,
isInitialized: false
});
API.me.mockRejectedValue(new Error('API error'));
const { result } = renderHook(() => useAuthStore());
await act(async () => {
try {
await result.current.initData();
} catch {
// Expected error
}
});
expect(result.current.isInitializing).toBe(false);
expect(result.current.isInitialized).toBe(false);
});
it('should call fetchChannels in background after initialization', async () => {
const mockUser = {
username: 'admin',
email: 'admin@test.com',
user_level: USER_LEVELS.ADMIN,
};
const fetchChannels = vi.fn().mockResolvedValue();
useChannelsStore.getState = () => ({
fetchChannels,
fetchChannelGroups: vi.fn().mockResolvedValue(),
fetchChannelProfiles: vi.fn().mockResolvedValue(),
});
API.me.mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthStore());
await act(async () => {
await result.current.initData();
});
// Wait for the background call to complete
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
// The background fetchChannels is called synchronously without await
// so we just need to verify it was called
expect(fetchChannels).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,442 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useChannelsStore from '../channels';
import api from '../../api';
import { showNotification } from '../../utils/notificationUtils';
// Mock dependencies
vi.mock('../../api');
vi.mock('../../utils/notificationUtils');
describe('useChannelsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset store state between tests
useChannelsStore.setState({
channels: {},
channelsByUUID: {},
channelGroups: {},
profiles: { 0: { id: '0', name: 'All', channels: new Set() } },
selectedProfileId: '0',
channelsPageSelection: [],
stats: {},
activeChannels: {},
activeClients: {},
recordings: [],
recurringRules: [],
isLoading: false,
error: null,
forceUpdate: 0,
});
});
describe('fetchChannels', () => {
it('should fetch and store channels successfully', async () => {
const mockChannels = [
{ id: 1, uuid: 'uuid-1', name: 'Channel 1' },
{ id: 2, uuid: 'uuid-2', name: 'Channel 2' },
];
api.getChannels.mockResolvedValue(mockChannels);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannels();
});
expect(api.getChannels).toHaveBeenCalledOnce();
expect(result.current.channels).toEqual({
1: mockChannels[0],
2: mockChannels[1],
});
expect(result.current.channelsByUUID).toEqual({
'uuid-1': 1,
'uuid-2': 2,
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle fetch error', async () => {
const errorMessage = 'Network error';
api.getChannels.mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannels();
});
expect(result.current.error).toBe(errorMessage);
expect(result.current.isLoading).toBe(false);
});
});
describe('fetchChannelGroups', () => {
it('should fetch and process channel groups', async () => {
const mockGroups = [
{ id: 1, name: 'Group 1', channel_count: 5, m3u_account_count: 0 },
{ id: 2, name: 'Group 2', channel_count: 0, m3u_account_count: 2 },
];
api.getChannelGroups.mockResolvedValue(mockGroups);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannelGroups();
});
expect(api.getChannelGroups).toHaveBeenCalledOnce();
expect(result.current.channelGroups[1]).toMatchObject({
hasChannels: true,
hasM3UAccounts: false,
canEdit: true,
canDelete: false,
});
expect(result.current.channelGroups[2]).toMatchObject({
hasChannels: false,
hasM3UAccounts: true,
canEdit: false,
canDelete: false,
});
});
});
describe('fetchChannelProfiles', () => {
it('should fetch and process channel profiles', async () => {
const mockProfiles = [
{ id: '1', name: 'Profile 1', channels: [1, 2, 3] },
];
api.getChannelProfiles.mockResolvedValue(mockProfiles);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchChannelProfiles();
});
expect(api.getChannelProfiles).toHaveBeenCalledOnce();
expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
expect(result.current.profiles['1'].channels.has(1)).toBe(true);
});
});
describe('addChannel', () => {
it('should add a new channel', async () => {
const newChannel = { id: 3, uuid: 'uuid-3', name: 'Channel 3' };
api.getChannelProfiles.mockResolvedValue([]);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
result.current.addChannel(newChannel);
});
expect(result.current.channels[3]).toEqual(newChannel);
expect(result.current.channelsByUUID['uuid-3']).toBe(3);
});
});
describe('updateChannel', () => {
it('should update an existing channel', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channels: { 1: { id: 1, uuid: 'uuid-1', name: 'Old Name' } },
channelsByUUID: { 'uuid-1': 1 },
});
});
const updatedChannel = { id: 1, uuid: 'uuid-1', name: 'New Name' };
act(() => {
result.current.updateChannel(updatedChannel);
});
expect(result.current.channels[1].name).toBe('New Name');
});
});
describe('removeChannels', () => {
it('should remove channels by IDs', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channels: {
1: { id: 1, uuid: 'uuid-1' },
2: { id: 2, uuid: 'uuid-2' },
},
channelsByUUID: { 'uuid-1': 1, 'uuid-2': 2 },
});
});
act(() => {
result.current.removeChannels([1]);
});
expect(result.current.channels[1]).toBeUndefined();
expect(result.current.channelsByUUID['uuid-1']).toBeUndefined();
expect(result.current.channels[2]).toBeDefined();
});
});
describe('channel groups operations', () => {
it('should add a channel group', () => {
const { result } = renderHook(() => useChannelsStore());
const newGroup = { id: 1, name: 'New Group' };
act(() => {
result.current.addChannelGroup(newGroup);
});
expect(result.current.channelGroups[1]).toEqual(newGroup);
});
it('should update a channel group', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channelGroups: { 1: { id: 1, name: 'Old Name' } },
});
});
act(() => {
result.current.updateChannelGroup({ id: 1, name: 'Updated Name' });
});
expect(result.current.channelGroups[1].name).toBe('Updated Name');
});
it('should remove a channel group', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channelGroups: { 1: { id: 1, name: 'Group' } },
});
});
act(() => {
result.current.removeChannelGroup(1);
});
expect(result.current.channelGroups[1]).toBeUndefined();
});
});
describe('profile operations', () => {
it('should add a profile', () => {
const { result } = renderHook(() => useChannelsStore());
const newProfile = { id: '1', name: 'Profile', channels: [1, 2] };
act(() => {
result.current.addProfile(newProfile);
});
expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
});
it('should update a profile', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
});
expect(result.current.profiles['1'].name).toBe('Updated');
});
it('should remove profiles and reset selected if needed', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1' }, '2': { id: '2' } },
selectedProfileId: '1',
});
});
act(() => {
result.current.removeProfiles(['1']);
});
expect(result.current.profiles['1']).toBeUndefined();
expect(result.current.selectedProfileId).toBe('0');
});
});
describe('updateProfileChannels', () => {
it('should add channels to profile', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1]) } },
});
});
act(() => {
result.current.updateProfileChannels([2, 3], '1', true);
});
expect(result.current.profiles['1'].channels.has(2)).toBe(true);
expect(result.current.profiles['1'].channels.has(3)).toBe(true);
});
it('should remove channels from profile', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
});
});
act(() => {
result.current.updateProfileChannels([2], '1', false);
});
expect(result.current.profiles['1'].channels.has(2)).toBe(false);
});
});
describe('setChannelStats', () => {
it('should update stats and show notifications for new channels', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channels: { 1: { id: 1, name: 'Channel 1' } },
channelsByUUID: { 'uuid-1': 1 },
stats: { channels: [] },
});
});
const newStats = {
channels: [
{ channel_id: 'uuid-1', clients: [] },
],
};
act(() => {
result.current.setChannelStats(newStats);
});
expect(result.current.stats).toEqual(newStats);
expect(showNotification).toHaveBeenCalled();
});
});
describe('recordings operations', () => {
it('should fetch recordings', async () => {
const mockRecordings = [{ id: 1, title: 'Recording 1' }];
api.getRecordings.mockResolvedValue(mockRecordings);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchRecordings();
});
expect(result.current.recordings).toEqual(mockRecordings);
});
it('should remove a recording', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
recordings: [{ id: 1 }, { id: 2 }],
});
});
act(() => {
result.current.removeRecording(1);
});
expect(result.current.recordings).toHaveLength(1);
expect(result.current.recordings[0].id).toBe(2);
});
});
describe('recurring rules operations', () => {
it('should fetch recurring rules', async () => {
const mockRules = [{ id: 1, name: 'Rule 1' }];
api.listRecurringRules.mockResolvedValue(mockRules);
const { result } = renderHook(() => useChannelsStore());
await act(async () => {
await result.current.fetchRecurringRules();
});
expect(result.current.recurringRules).toEqual(mockRules);
});
it('should remove a recurring rule', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
recurringRules: [{ id: 1 }, { id: 2 }],
});
});
act(() => {
result.current.removeRecurringRule(1);
});
expect(result.current.recurringRules).toHaveLength(1);
});
});
describe('helper methods', () => {
it('should validate if channel group can be edited', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channelGroups: {
1: { id: 1, canEdit: true },
2: { id: 2, canEdit: false },
},
});
});
expect(result.current.canEditChannelGroup(1)).toBe(true);
expect(result.current.canEditChannelGroup(2)).toBe(false);
});
it('should validate if channel group can be deleted', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
useChannelsStore.setState({
channelGroups: {
1: { id: 1, canDelete: true },
2: { id: 2, canDelete: false },
},
});
});
expect(result.current.canDeleteChannelGroup(1)).toBe(true);
expect(result.current.canDeleteChannelGroup(2)).toBe(false);
});
});
describe('triggerUpdate', () => {
it('should update forceUpdate timestamp', () => {
const { result } = renderHook(() => useChannelsStore());
const initialUpdate = result.current.forceUpdate;
act(() => {
result.current.triggerUpdate();
});
expect(result.current.forceUpdate).not.toBe(initialUpdate);
});
});
});

View file

@ -0,0 +1,402 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useChannelsTableStore from '../channelsTable';
describe('useChannelsTableStore', () => {
beforeEach(() => {
// Mock localStorage
const mockLocalStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
clear: vi.fn(),
};
global.localStorage = mockLocalStorage;
vi.clearAllMocks();
// Reset store state between tests
useChannelsTableStore.setState({
channels: [],
pageCount: 0,
totalCount: 0,
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
pageSize: 50,
},
selectedChannelIds: [],
allQueryIds: [],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial state', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useChannelsTableStore());
expect(result.current.channels).toEqual([]);
expect(result.current.pageCount).toBe(0);
expect(result.current.totalCount).toBe(0);
expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
expect(result.current.pagination.pageIndex).toBe(0);
expect(result.current.pagination.pageSize).toBe(50);
expect(result.current.selectedChannelIds).toEqual([]);
expect(result.current.allQueryIds).toEqual([]);
});
});
describe('queryChannels', () => {
it('should update channels, totalCount, and pageCount', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockResults = [
{ id: 1, name: 'Channel 1' },
{ id: 2, name: 'Channel 2' },
];
const mockData = {
results: mockResults,
count: 150,
};
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryChannels(mockData, mockParams);
});
expect(result.current.channels).toEqual(mockResults);
expect(result.current.totalCount).toBe(150);
expect(result.current.pageCount).toBe(3); // Math.ceil(150 / 50)
});
it('should calculate pageCount correctly with different page sizes', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockData = {
results: [],
count: 75,
};
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
result.current.queryChannels(mockData, mockParams);
});
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
});
it('should handle zero results', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockData = {
results: [],
count: 0,
};
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryChannels(mockData, mockParams);
});
expect(result.current.channels).toEqual([]);
expect(result.current.totalCount).toBe(0);
expect(result.current.pageCount).toBe(0);
});
});
describe('setAllQueryIds', () => {
it('should update allQueryIds', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockIds = [1, 2, 3, 4, 5];
act(() => {
result.current.setAllQueryIds(mockIds);
});
expect(result.current.allQueryIds).toEqual(mockIds);
});
it('should replace existing allQueryIds', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setAllQueryIds([1, 2, 3]);
});
expect(result.current.allQueryIds).toEqual([1, 2, 3]);
act(() => {
result.current.setAllQueryIds([4, 5, 6]);
});
expect(result.current.allQueryIds).toEqual([4, 5, 6]);
});
});
describe('setSelectedChannelIds', () => {
it('should update selectedChannelIds', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockIds = [1, 3, 5];
act(() => {
result.current.setSelectedChannelIds(mockIds);
});
expect(result.current.selectedChannelIds).toEqual(mockIds);
});
it('should clear selectedChannelIds when empty array is passed', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setSelectedChannelIds([1, 2, 3]);
});
expect(result.current.selectedChannelIds).toEqual([1, 2, 3]);
act(() => {
result.current.setSelectedChannelIds([]);
});
expect(result.current.selectedChannelIds).toEqual([]);
});
});
describe('getChannelStreams', () => {
it('should return streams for existing channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', streams: ['stream1', 'stream2'] },
{ id: 2, name: 'Channel 2', streams: ['stream3'] },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const streams = result.current.getChannelStreams(1);
expect(streams).toEqual(['stream1', 'stream2']);
});
it('should return empty array for channel without streams', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1' },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const streams = result.current.getChannelStreams(1);
expect(streams).toEqual([]);
});
it('should return empty array for non-existent channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', streams: ['stream1'] },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const streams = result.current.getChannelStreams(999);
expect(streams).toEqual([]);
});
});
describe('setPagination', () => {
it('should update pagination', () => {
const { result } = renderHook(() => useChannelsTableStore());
const newPagination = {
pageIndex: 2,
pageSize: 100,
};
act(() => {
result.current.setPagination(newPagination);
});
expect(result.current.pagination).toEqual(newPagination);
});
it('should update only changed pagination properties', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setPagination({
pageIndex: 5,
pageSize: 50,
});
});
expect(result.current.pagination.pageIndex).toBe(5);
expect(result.current.pagination.pageSize).toBe(50);
});
});
describe('setSorting', () => {
it('should update sorting', () => {
const { result } = renderHook(() => useChannelsTableStore());
const newSorting = [{ id: 'name', desc: true }];
act(() => {
result.current.setSorting(newSorting);
});
expect(result.current.sorting).toEqual(newSorting);
});
it('should handle multiple sorting columns', () => {
const { result } = renderHook(() => useChannelsTableStore());
const newSorting = [
{ id: 'name', desc: false },
{ id: 'channel_number', desc: true },
];
act(() => {
result.current.setSorting(newSorting);
});
expect(result.current.sorting).toEqual(newSorting);
});
it('should clear sorting when empty array is passed', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setSorting([{ id: 'name', desc: true }]);
});
expect(result.current.sorting).toHaveLength(1);
act(() => {
result.current.setSorting([]);
});
expect(result.current.sorting).toEqual([]);
});
});
describe('isUnlocked', () => {
it('should initialize with default false value', () => {
const { result } = renderHook(() => useChannelsTableStore());
expect(result.current.isUnlocked).toBe(false);
});
});
describe('setIsUnlocked', () => {
it('should update isUnlocked to true', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setIsUnlocked(true);
});
expect(result.current.isUnlocked).toBe(true);
});
it('should update isUnlocked to false', () => {
const { result } = renderHook(() => useChannelsTableStore());
act(() => {
result.current.setIsUnlocked(true);
});
expect(result.current.isUnlocked).toBe(true);
act(() => {
result.current.setIsUnlocked(false);
});
expect(result.current.isUnlocked).toBe(false);
});
});
describe('updateChannel', () => {
it('should update an existing channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', channel_number: 1 },
{ id: 2, name: 'Channel 2', channel_number: 2 },
{ id: 3, name: 'Channel 3', channel_number: 3 },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
act(() => {
result.current.updateChannel(updatedChannel);
});
expect(result.current.channels).toEqual([
{ id: 1, name: 'Channel 1', channel_number: 1 },
{ id: 2, name: 'Updated Channel 2', channel_number: 22 },
{ id: 3, name: 'Channel 3', channel_number: 3 },
]);
});
it('should not modify channels when updating non-existent channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', channel_number: 1 },
{ id: 2, name: 'Channel 2', channel_number: 2 },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
act(() => {
result.current.updateChannel(updatedChannel);
});
expect(result.current.channels).toEqual(mockChannels);
});
it('should preserve other channels when updating one channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
const mockChannels = [
{ id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] },
{ id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] },
];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
});
const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
act(() => {
result.current.updateChannel(updatedChannel);
});
expect(result.current.channels[0]).toEqual(updatedChannel);
expect(result.current.channels[1]).toEqual(mockChannels[1]);
});
});
});

View file

@ -0,0 +1,687 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import useEPGsStore from '../epgs';
import api from '../../api';
// Mock the api module
vi.mock('../../api');
describe('useEPGsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset store state between tests
useEPGsStore.setState({
epgs: {},
tvgs: [],
tvgsById: {},
tvgsLoaded: false,
isLoading: false,
error: null,
refreshProgress: {},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial state', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useEPGsStore());
expect(result.current.epgs).toEqual({});
expect(result.current.tvgs).toEqual([]);
expect(result.current.tvgsById).toEqual({});
expect(result.current.tvgsLoaded).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.refreshProgress).toEqual({});
});
});
describe('fetchEPGs', () => {
it('should fetch and store EPGs successfully', async () => {
const mockEPGs = [
{ id: 'epg1', name: 'EPG 1', status: 'idle' },
{ id: 'epg2', name: 'EPG 2', status: 'success' },
];
api.getEPGs.mockResolvedValue(mockEPGs);
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGs();
});
expect(result.current.epgs).toEqual({
epg1: { id: 'epg1', name: 'EPG 1', status: 'idle' },
epg2: { id: 'epg2', name: 'EPG 2', status: 'success' },
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(api.getEPGs).toHaveBeenCalledTimes(1);
});
it('should set loading state while fetching', async () => {
api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
const { result } = renderHook(() => useEPGsStore());
const fetchPromise = act(async () => {
await result.current.fetchEPGs();
});
// Check loading state immediately after calling
await waitFor(() => {
expect(result.current.isLoading).toBe(true);
});
await fetchPromise;
expect(result.current.isLoading).toBe(false);
});
it('should handle fetch error', async () => {
const mockError = new Error('Network error');
api.getEPGs.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGs();
});
expect(result.current.error).toBe('Failed to load epgs.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
consoleSpy.mockRestore();
});
it('should handle empty EPGs array', async () => {
api.getEPGs.mockResolvedValue([]);
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGs();
});
expect(result.current.epgs).toEqual({});
expect(result.current.isLoading).toBe(false);
});
});
describe('fetchEPGData', () => {
it('should fetch and store TVG data successfully', async () => {
const mockTVGs = [
{ id: 'tvg1', name: 'TVG 1' },
{ id: 'tvg2', name: 'TVG 2' },
];
api.getEPGData.mockResolvedValue(mockTVGs);
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGData();
});
expect(result.current.tvgs).toEqual(mockTVGs);
expect(result.current.tvgsById).toEqual({
tvg1: { id: 'tvg1', name: 'TVG 1' },
tvg2: { id: 'tvg2', name: 'TVG 2' },
});
expect(result.current.tvgsLoaded).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(api.getEPGData).toHaveBeenCalledTimes(1);
});
it('should set loading state while fetching', async () => {
api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
const { result } = renderHook(() => useEPGsStore());
const fetchPromise = act(async () => {
await result.current.fetchEPGData();
});
await waitFor(() => {
expect(result.current.isLoading).toBe(true);
});
await fetchPromise;
expect(result.current.isLoading).toBe(false);
});
it('should handle fetch error', async () => {
const mockError = new Error('API error');
api.getEPGData.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGData();
});
expect(result.current.error).toBe('Failed to load tvgs.');
expect(result.current.tvgsLoaded).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
consoleSpy.mockRestore();
});
it('should handle empty TVG array', async () => {
api.getEPGData.mockResolvedValue([]);
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
await result.current.fetchEPGData();
});
expect(result.current.tvgs).toEqual([]);
expect(result.current.tvgsById).toEqual({});
expect(result.current.tvgsLoaded).toBe(true);
});
});
describe('addEPG', () => {
it('should add new EPG to store', () => {
const { result } = renderHook(() => useEPGsStore());
const newEPG = { id: 'epg1', name: 'New EPG', status: 'idle' };
act(() => {
result.current.addEPG(newEPG);
});
expect(result.current.epgs).toEqual({
epg1: newEPG,
});
});
it('should add multiple EPGs', () => {
const { result } = renderHook(() => useEPGsStore());
const epg1 = { id: 'epg1', name: 'EPG 1', status: 'idle' };
const epg2 = { id: 'epg2', name: 'EPG 2', status: 'success' };
act(() => {
result.current.addEPG(epg1);
result.current.addEPG(epg2);
});
expect(result.current.epgs).toEqual({
epg1,
epg2,
});
});
it('should not overwrite existing EPGs', () => {
const { result } = renderHook(() => useEPGsStore());
const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
const newEPG = { id: 'epg2', name: 'New', status: 'success' };
act(() => {
result.current.addEPG(originalEPG);
result.current.addEPG(newEPG);
});
expect(result.current.epgs).toEqual({
epg1: originalEPG,
epg2: newEPG,
});
});
});
describe('updateEPG', () => {
it('should update existing EPG', () => {
const { result } = renderHook(() => useEPGsStore());
const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
const updatedEPG = { id: 'epg1', name: 'Updated', status: 'success' };
act(() => {
result.current.addEPG(originalEPG);
});
act(() => {
result.current.updateEPG(updatedEPG);
});
expect(result.current.epgs.epg1).toEqual(updatedEPG);
});
it('should add EPG if it does not exist', () => {
const { result } = renderHook(() => useEPGsStore());
const newEPG = { id: 'epg1', name: 'New', status: 'idle' };
act(() => {
result.current.updateEPG(newEPG);
});
expect(result.current.epgs.epg1).toEqual(newEPG);
});
it('should not update state when called with invalid epg (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
act(() => {
useEPGsStore.setState({ epgs: initialEPGs });
});
act(() => {
result.current.updateEPG(null);
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (missing id)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
act(() => {
useEPGsStore.setState({ epgs: initialEPGs });
});
const invalidEPG = { name: 'No ID' };
act(() => {
result.current.updateEPG(invalidEPG);
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (non-object)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
act(() => {
useEPGsStore.setState({ epgs: initialEPGs });
});
act(() => {
result.current.updateEPG('invalid');
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
consoleSpy.mockRestore();
});
});
describe('removeEPGs', () => {
it('should remove single EPG', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
useEPGsStore.setState({
epgs: {
epg1: { id: 'epg1', name: 'EPG 1' },
epg2: { id: 'epg2', name: 'EPG 2' },
},
});
});
act(() => {
result.current.removeEPGs(['epg1']);
});
expect(result.current.epgs).toEqual({
epg2: { id: 'epg2', name: 'EPG 2' },
});
});
it('should remove multiple EPGs', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
useEPGsStore.setState({
epgs: {
epg1: { id: 'epg1', name: 'EPG 1' },
epg2: { id: 'epg2', name: 'EPG 2' },
epg3: { id: 'epg3', name: 'EPG 3' },
},
});
});
act(() => {
result.current.removeEPGs(['epg1', 'epg3']);
});
expect(result.current.epgs).toEqual({
epg2: { id: 'epg2', name: 'EPG 2' },
});
});
it('should handle removing non-existent EPG', () => {
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = {
epg1: { id: 'epg1', name: 'EPG 1' },
};
act(() => {
useEPGsStore.setState({ epgs: initialEPGs });
});
act(() => {
result.current.removeEPGs(['nonexistent']);
});
expect(result.current.epgs).toEqual(initialEPGs);
});
it('should handle empty array', () => {
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = {
epg1: { id: 'epg1', name: 'EPG 1' },
};
act(() => {
useEPGsStore.setState({ epgs: initialEPGs });
});
act(() => {
result.current.removeEPGs([]);
});
expect(result.current.epgs).toEqual(initialEPGs);
});
});
describe('updateEPGProgress', () => {
beforeEach(() => {
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'idle', last_message: '' },
},
});
});
});
it('should update progress for downloading action', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'downloading',
progress: 50,
speed: '1.5 MB/s',
elapsed_time: '00:00:30',
time_remaining: '00:00:30',
});
});
expect(result.current.refreshProgress.source1).toEqual({
action: 'downloading',
progress: 50,
speed: '1.5 MB/s',
elapsed_time: '00:00:30',
time_remaining: '00:00:30',
status: 'in_progress',
});
expect(result.current.epgs.source1.status).toBe('fetching');
});
it('should update progress for parsing_channels action', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'parsing_channels',
progress: 75,
});
});
expect(result.current.epgs.source1.status).toBe('parsing');
});
it('should update progress for parsing_programs action', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'parsing_programs',
progress: 90,
});
});
expect(result.current.epgs.source1.status).toBe('parsing');
});
it('should set status to success when progress is 100', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'success',
progress: 100,
});
});
expect(result.current.epgs.source1.status).toBe('success');
});
it('should use explicit status from data', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
status: 'error',
progress: 50,
});
});
expect(result.current.epgs.source1.status).toBe('error');
expect(result.current.refreshProgress.source1.status).toBe('error');
});
it('should set last_message on error status', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
status: 'error',
error: 'Connection failed',
});
});
expect(result.current.epgs.source1.last_message).toBe('Connection failed');
});
it('should use default error message if error is not provided', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'source1',
status: 'error',
});
});
expect(result.current.epgs.source1.last_message).toBe('Unknown error');
});
it('should not update state when called with invalid data (null)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
const initialProgress = { ...result.current.refreshProgress };
act(() => {
result.current.updateEPGProgress(null);
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
consoleSpy.mockRestore();
});
it('should not update state when called with invalid data (missing source)', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
const initialProgress = { ...result.current.refreshProgress };
act(() => {
result.current.updateEPGProgress({ progress: 50 });
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
consoleSpy.mockRestore();
});
it('should not update state when source does not exist and no status', () => {
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
const initialProgress = { ...result.current.refreshProgress };
act(() => {
result.current.updateEPGProgress({
source: 'nonexistent',
progress: 50,
});
});
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
});
it('should update refreshProgress even when source does not exist but status is provided', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
result.current.updateEPGProgress({
source: 'newSource',
status: 'success',
progress: 100,
});
});
expect(result.current.refreshProgress.newSource).toEqual({
action: undefined,
progress: 100,
speed: undefined,
elapsed_time: undefined,
time_remaining: undefined,
status: 'success',
});
});
it('should not update EPG if status and last_message have not changed', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'fetching', last_message: '' },
},
});
});
const epgsBeforeUpdate = result.current.epgs;
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'downloading',
progress: 25,
});
});
// EPGs object should be the same reference (not updated)
expect(result.current.epgs).toBe(epgsBeforeUpdate);
// But refreshProgress should be updated
expect(result.current.refreshProgress.source1.progress).toBe(25);
});
it('should update EPG if status changed', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'idle', last_message: '' },
},
});
});
const epgsBeforeUpdate = result.current.epgs;
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'downloading',
progress: 25,
});
});
// EPGs object should be different (updated) because status changed from 'idle' to 'fetching'
expect(result.current.epgs).not.toBe(epgsBeforeUpdate);
expect(result.current.epgs.source1.status).toBe('fetching');
});
it('should preserve current EPG status when no status change is detected', () => {
const { result } = renderHook(() => useEPGsStore());
act(() => {
useEPGsStore.setState({
epgs: {
source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
},
});
});
act(() => {
result.current.updateEPGProgress({
source: 'source1',
action: 'parsing_programs',
progress: 85,
});
});
expect(result.current.epgs.source1.status).toBe('parsing');
expect(result.current.epgs.source1.last_message).toBe('Processing');
});
});
});

View file

@ -0,0 +1,916 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import useLogosStore from '../logos';
import api from '../../api';
// Mock the api module
vi.mock('../../api');
describe('useLogosStore', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset store state between tests
useLogosStore.setState({
logos: {},
channelLogos: {},
isLoading: false,
backgroundLoading: false,
hasLoadedAll: false,
hasLoadedChannelLogos: false,
error: null,
allowLogoRendering: false,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial state', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useLogosStore());
expect(result.current.logos).toEqual({});
expect(result.current.channelLogos).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.backgroundLoading).toBe(false);
expect(result.current.hasLoadedAll).toBe(false);
expect(result.current.hasLoadedChannelLogos).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.allowLogoRendering).toBe(false);
});
});
describe('enableLogoRendering', () => {
it('should enable logo rendering', () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
result.current.enableLogoRendering();
});
expect(result.current.allowLogoRendering).toBe(true);
});
});
describe('addLogo', () => {
it('should add logo to main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
act(() => {
result.current.addLogo(newLogo);
});
expect(result.current.logos).toEqual({
logo1: newLogo,
});
});
it('should add logo to channelLogos if hasLoadedChannelLogos is true', () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ hasLoadedChannelLogos: true });
});
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
act(() => {
result.current.addLogo(newLogo);
});
expect(result.current.logos).toEqual({ logo1: newLogo });
expect(result.current.channelLogos).toEqual({ logo1: newLogo });
});
it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
const { result } = renderHook(() => useLogosStore());
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
act(() => {
result.current.addLogo(newLogo);
});
expect(result.current.logos).toEqual({ logo1: newLogo });
expect(result.current.channelLogos).toEqual({});
});
});
describe('updateLogo', () => {
it('should update logo in main logos store', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
});
act(() => {
result.current.updateLogo(updatedLogo);
});
expect(result.current.logos.logo1).toEqual(updatedLogo);
});
it('should update logo in channelLogos if it exists there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
act(() => {
useLogosStore.setState({
logos: { logo1: originalLogo },
channelLogos: { logo1: originalLogo },
});
});
act(() => {
result.current.updateLogo(updatedLogo);
});
expect(result.current.logos.logo1).toEqual(updatedLogo);
expect(result.current.channelLogos.logo1).toEqual(updatedLogo);
});
it('should not update channelLogos if logo does not exist there', () => {
const { result } = renderHook(() => useLogosStore());
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
});
act(() => {
result.current.updateLogo(updatedLogo);
});
expect(result.current.channelLogos).toEqual({});
});
});
describe('removeLogo', () => {
it('should remove logo from both stores', () => {
const { result } = renderHook(() => useLogosStore());
const logo1 = { id: 'logo1', name: 'Logo 1' };
const logo2 = { id: 'logo2', name: 'Logo 2' };
act(() => {
useLogosStore.setState({
logos: { logo1, logo2 },
channelLogos: { logo1, logo2 },
});
});
act(() => {
result.current.removeLogo('logo1');
});
expect(result.current.logos).toEqual({ logo2 });
expect(result.current.channelLogos).toEqual({ logo2 });
});
it('should handle removing non-existent logo', () => {
const { result } = renderHook(() => useLogosStore());
const logo1 = { id: 'logo1', name: 'Logo 1' };
act(() => {
useLogosStore.setState({ logos: { logo1 } });
});
act(() => {
result.current.removeLogo('nonexistent');
});
expect(result.current.logos).toEqual({ logo1 });
});
});
describe('fetchLogos', () => {
it('should fetch logos successfully with array response', async () => {
const mockLogos = [
{ id: 'logo1', name: 'Logo 1' },
{ id: 'logo2', name: 'Logo 2' },
];
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
let response;
await act(async () => {
response = await result.current.fetchLogos(100);
});
expect(result.current.logos).toEqual({
logo1: { id: 'logo1', name: 'Logo 1' },
logo2: { id: 'logo2', name: 'Logo 2' },
});
expect(result.current.isLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledWith({ page_size: 100 });
expect(response).toEqual(mockLogos);
});
it('should fetch logos successfully with paginated response', async () => {
const mockResponse = {
results: [
{ id: 'logo1', name: 'Logo 1' },
{ id: 'logo2', name: 'Logo 2' },
],
count: 2,
};
api.getLogos.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.fetchLogos(50);
});
expect(result.current.logos).toEqual({
logo1: { id: 'logo1', name: 'Logo 1' },
logo2: { id: 'logo2', name: 'Logo 2' },
});
expect(api.getLogos).toHaveBeenCalledWith({ page_size: 50 });
});
it('should handle fetch error', async () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
act(async () => {
await result.current.fetchLogos();
})
).rejects.toThrow('Network error');
await waitFor(() => {
expect(result.current.error).toBe('Failed to load logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
});
consoleSpy.mockRestore();
});
});
describe('fetchAllLogos', () => {
it('should fetch all logos successfully', async () => {
const mockLogos = [
{ id: 'logo1', name: 'Logo 1' },
{ id: 'logo2', name: 'Logo 2' },
];
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
let response;
await act(async () => {
response = await result.current.fetchAllLogos();
});
expect(result.current.logos).toEqual({
logo1: { id: 'logo1', name: 'Logo 1' },
logo2: { id: 'logo2', name: 'Logo 2' },
});
expect(result.current.hasLoadedAll).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
expect(response).toEqual(mockLogos);
});
it('should not refetch if already loaded and not forced', async () => {
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({
logos: { logo1: mockLogos[0] },
hasLoadedAll: true,
});
});
const response = await act(async () => {
return await result.current.fetchAllLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
expect(response).toEqual([mockLogos[0]]);
});
it('should refetch if forced', async () => {
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({
logos: { logo1: mockLogos[0] },
hasLoadedAll: true,
});
});
await act(async () => {
await result.current.fetchAllLogos(true);
});
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
});
it('should not refetch if already loading', async () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ isLoading: true });
});
const response = await act(async () => {
return await result.current.fetchAllLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
expect(response).toEqual([]);
});
it('should handle fetch error', async () => {
const mockError = new Error('API error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
act(async () => {
await result.current.fetchAllLogos();
})
).rejects.toThrow('API error');
await waitFor(() => {
expect(result.current.error).toBe('Failed to load all logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
});
consoleSpy.mockRestore();
});
});
describe('fetchUsedLogos', () => {
it('should fetch used logos successfully', async () => {
const mockResponse = {
results: [
{ id: 'logo1', name: 'Used Logo 1' },
{ id: 'logo2', name: 'Used Logo 2' },
],
};
api.getLogos.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useLogosStore());
let response;
await act(async () => {
response = await result.current.fetchUsedLogos(100);
});
expect(result.current.logos).toEqual({
logo1: { id: 'logo1', name: 'Used Logo 1' },
logo2: { id: 'logo2', name: 'Used Logo 2' },
});
expect(result.current.isLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
expect(response).toEqual(mockResponse);
});
it('should merge with existing logos', async () => {
const existingLogo = { id: 'logo1', name: 'Existing Logo' };
const newLogo = { id: 'logo2', name: 'New Logo' };
api.getLogos.mockResolvedValue({ results: [newLogo] });
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ logos: { logo1: existingLogo } });
});
await act(async () => {
await result.current.fetchUsedLogos();
});
expect(result.current.logos).toEqual({
logo1: existingLogo,
logo2: newLogo,
});
});
it('should handle fetch error', async () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
act(async () => {
await result.current.fetchUsedLogos();
})
).rejects.toThrow('Fetch error');
await waitFor(() => {
expect(result.current.error).toBe('Failed to load used logos.');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
});
consoleSpy.mockRestore();
});
});
describe('fetchChannelAssignableLogos', () => {
it('should return cached logos if already loaded', async () => {
const cachedLogos = {
logo1: { id: 'logo1', name: 'Cached Logo' },
};
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({
channelLogos: cachedLogos,
hasLoadedChannelLogos: true,
});
});
const response = await act(async () => {
return await result.current.fetchChannelAssignableLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
expect(response).toEqual([cachedLogos.logo1]);
});
it('should fetch and cache logos if not loaded', async () => {
const mockLogos = [
{ id: 'logo1', name: 'Logo 1' },
{ id: 'logo2', name: 'Logo 2' },
];
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.fetchChannelAssignableLogos();
});
expect(result.current.channelLogos).toEqual({
logo1: { id: 'logo1', name: 'Logo 1' },
logo2: { id: 'logo2', name: 'Logo 2' },
});
expect(result.current.hasLoadedChannelLogos).toBe(true);
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
});
});
describe('fetchLogosByIds', () => {
it('should fetch missing logos by IDs', async () => {
const existingLogo = { id: 'logo1', name: 'Existing' };
const newLogo = { id: 'logo2', name: 'New' };
api.getLogosByIds.mockResolvedValue([newLogo]);
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ logos: { logo1: existingLogo } });
});
let response;
await act(async () => {
response = await result.current.fetchLogosByIds(['logo1', 'logo2']);
});
expect(api.getLogosByIds).toHaveBeenCalledWith(['logo2']);
expect(result.current.logos).toEqual({
logo1: existingLogo,
logo2: newLogo,
});
expect(response).toEqual([newLogo]);
});
it('should return empty array if all logos exist', async () => {
const logo1 = { id: 'logo1', name: 'Logo 1' };
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ logos: { logo1 } });
});
const response = await act(async () => {
return await result.current.fetchLogosByIds(['logo1']);
});
expect(api.getLogosByIds).not.toHaveBeenCalled();
expect(response).toEqual([]);
});
it('should handle fetch error', async () => {
const mockError = new Error('Fetch error');
api.getLogosByIds.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
act(async () => {
await result.current.fetchLogosByIds(['logo1']);
})
).rejects.toThrow('Fetch error');
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
consoleSpy.mockRestore();
});
});
describe('fetchLogosInBackground', () => {
it('should fetch logos in background with pagination', async () => {
// vi.useRealTimers();
const page1 = {
results: [{ id: 'logo1', name: 'Logo 1' }],
next: 'http://example.com/page2',
};
const page2 = {
results: [{ id: 'logo2', name: 'Logo 2' }],
next: null,
};
api.getLogos
.mockResolvedValueOnce(page1)
.mockResolvedValueOnce(page2);
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.fetchLogosInBackground();
});
expect(result.current.logos).toEqual({
logo1: { id: 'logo1', name: 'Logo 1' },
logo2: { id: 'logo2', name: 'Logo 2' },
});
expect(result.current.backgroundLoading).toBe(false);
expect(api.getLogos).toHaveBeenCalledTimes(2);
expect(api.getLogos).toHaveBeenCalledWith({ page: 1, page_size: 200 });
expect(api.getLogos).toHaveBeenCalledWith({ page: 2, page_size: 200 });
});
it('should handle errors gracefully without throwing', async () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.fetchLogosInBackground();
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
consoleSpy.mockRestore();
});
});
describe('backgroundLoadAllLogos', () => {
it('should not start if already loading', async () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ backgroundLoading: true });
});
await act(async () => {
await result.current.backgroundLoadAllLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
});
it('should not start if already loaded', async () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ hasLoadedAll: true });
});
await act(async () => {
await result.current.backgroundLoadAllLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
});
it('should load logos in background asynchronously', async () => {
vi.useFakeTimers();
const mockLogos = Array.from({ length: 2500 }, (_, i) => ({
id: `logo${i}`,
name: `Logo ${i}`,
}));
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
// Start background loading
result.current.backgroundLoadAllLogos();
// Advance timers to execute setTimeout
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.hasLoadedAll).toBe(true);
expect(result.current.backgroundLoading).toBe(false);
expect(Object.keys(result.current.logos).length).toBe(2500);
vi.useRealTimers();
});
it('should handle errors gracefully', async () => {
vi.useFakeTimers();
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.backgroundLoadAllLogos();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
consoleSpy.mockRestore();
vi.useRealTimers();
});
});
describe('backgroundLoadChannelLogos', () => {
it('should not start if already loading', async () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ backgroundLoading: true });
});
await act(async () => {
await result.current.backgroundLoadChannelLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
});
it('should not start if already loaded', async () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ hasLoadedChannelLogos: true });
});
await act(async () => {
await result.current.backgroundLoadChannelLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
});
it('should not start if channelLogos already has many items', async () => {
const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
const channelLogosObj = Object.fromEntries(channelLogos);
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ channelLogos: channelLogosObj });
});
await act(async () => {
await result.current.backgroundLoadChannelLogos();
});
expect(api.getLogos).not.toHaveBeenCalled();
});
it('should load channel logos in background', async () => {
const mockLogos = [
{ id: 'logo1', name: 'Logo 1' },
{ id: 'logo2', name: 'Logo 2' },
];
api.getLogos.mockResolvedValue(mockLogos);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.backgroundLoadChannelLogos();
});
expect(result.current.hasLoadedChannelLogos).toBe(true);
expect(result.current.backgroundLoading).toBe(false);
expect(Object.keys(result.current.channelLogos).length).toBe(2);
expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
consoleSpy.mockRestore();
});
it('should handle errors gracefully', async () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
await result.current.backgroundLoadChannelLogos();
});
expect(result.current.backgroundLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
});
});
describe('startBackgroundLoading', () => {
it('should start background loading after delay', async () => {
vi.useFakeTimers();
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
api.getLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useLogosStore());
result.current.startBackgroundLoading();
// Advance timer by 3 seconds
await act(async () => {
vi.advanceTimersByTime(3000);
await vi.runAllTimersAsync();
});
expect(result.current.hasLoadedAll).toBe(true);
vi.useRealTimers();
});
it('should handle errors in background loading', async () => {
vi.useFakeTimers();
const mockError = new Error('Background error');
api.getLogos.mockRejectedValue(mockError);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.startBackgroundLoading();
await act(async () => {
vi.advanceTimersByTime(3000);
await vi.runAllTimersAsync();
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
vi.useRealTimers();
});
});
describe('helper methods', () => {
describe('getLogoById', () => {
it('should return logo if it exists', () => {
const logo = { id: 'logo1', name: 'Logo 1' };
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ logos: { logo1: logo } });
});
expect(result.current.getLogoById('logo1')).toEqual(logo);
});
it('should return null if logo does not exist', () => {
const { result } = renderHook(() => useLogosStore());
expect(result.current.getLogoById('nonexistent')).toBeNull();
});
});
describe('hasLogo', () => {
it('should return true if logo exists', () => {
const logo = { id: 'logo1', name: 'Logo 1' };
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ logos: { logo1: logo } });
});
expect(result.current.hasLogo('logo1')).toBe(true);
});
it('should return false if logo does not exist', () => {
const { result } = renderHook(() => useLogosStore());
expect(result.current.hasLogo('nonexistent')).toBe(false);
});
});
describe('getLogosCount', () => {
it('should return correct count of logos', () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({
logos: {
logo1: { id: 'logo1' },
logo2: { id: 'logo2' },
logo3: { id: 'logo3' },
},
});
});
expect(result.current.getLogosCount()).toBe(3);
});
it('should return 0 for empty logos', () => {
const { result } = renderHook(() => useLogosStore());
expect(result.current.getLogosCount()).toBe(0);
});
});
describe('needsAllLogos', () => {
it('should return true if hasLoadedAll is false', () => {
const { result } = renderHook(() => useLogosStore());
expect(result.current.needsAllLogos()).toBe(true);
});
it('should return true if logos is empty', () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({ hasLoadedAll: true, logos: {} });
});
expect(result.current.needsAllLogos()).toBe(true);
});
it('should return false if hasLoadedAll is true and logos exist', () => {
const { result } = renderHook(() => useLogosStore());
act(() => {
useLogosStore.setState({
hasLoadedAll: true,
logos: { logo1: { id: 'logo1' } },
});
});
expect(result.current.needsAllLogos()).toBe(false);
});
});
});
});

View file

@ -0,0 +1,266 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import usePlaylistsStore from '../playlists';
import api from '../../api';
vi.mock('../../api');
describe('usePlaylistsStore', () => {
beforeEach(() => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.playlists = [];
result.current.profiles = {};
result.current.refreshProgress = {};
result.current.isLoading = false;
result.current.error = null;
result.current.profileSearchPreview = '';
result.current.profileResult = '';
result.current.editPlaylistId = null;
});
vi.clearAllMocks();
});
it('should initialize with default state', () => {
const { result } = renderHook(() => usePlaylistsStore());
expect(result.current.playlists).toEqual([]);
expect(result.current.profiles).toEqual({});
expect(result.current.refreshProgress).toEqual({});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.profileSearchPreview).toBe('');
expect(result.current.profileResult).toBe('');
expect(result.current.editPlaylistId).toBe(null);
});
it('should set edit playlist id', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.setEditPlaylistId('playlist1');
});
expect(result.current.editPlaylistId).toBe('playlist1');
});
it('should fetch playlist successfully', async () => {
const mockPlaylist = {
id: 'playlist1',
name: 'Test Playlist',
profiles: ['profile1', 'profile2'],
};
api.getPlaylist.mockResolvedValue(mockPlaylist);
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.playlists = [{ id: 'playlist1', name: 'Old Name' }];
});
await act(async () => {
await result.current.fetchPlaylist('playlist1');
});
expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
expect(result.current.playlists).toEqual([mockPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch playlist error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
api.getPlaylist.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
await act(async () => {
await result.current.fetchPlaylist('playlist1');
});
expect(result.current.error).toBe('Failed to load playlists.');
expect(result.current.isLoading).toBe(false);
consoleErrorSpy.mockRestore();
});
it('should fetch playlists successfully', async () => {
const mockPlaylists = [
{ id: 'playlist1', name: 'Playlist 1', profiles: ['profile1'] },
{ id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] },
];
api.getPlaylists.mockResolvedValue(mockPlaylists);
const { result } = renderHook(() => usePlaylistsStore());
await act(async () => {
await result.current.fetchPlaylists();
});
expect(api.getPlaylists).toHaveBeenCalled();
expect(result.current.playlists).toEqual(mockPlaylists);
expect(result.current.profiles).toEqual({
playlist1: ['profile1'],
playlist2: ['profile2'],
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch playlists error', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
api.getPlaylists.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
await act(async () => {
await result.current.fetchPlaylists();
});
expect(result.current.error).toBe('Failed to load playlists.');
expect(result.current.isLoading).toBe(false);
consoleErrorSpy.mockRestore();
});
it('should add playlist', () => {
const { result } = renderHook(() => usePlaylistsStore());
const newPlaylist = {
id: 'playlist1',
name: 'New Playlist',
profiles: ['profile1'],
};
act(() => {
result.current.addPlaylist(newPlaylist);
});
expect(result.current.playlists).toEqual([newPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1'] });
});
it('should update playlist', () => {
const { result } = renderHook(() => usePlaylistsStore());
const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
act(() => {
result.current.playlists = [existingPlaylist];
result.current.profiles = { playlist1: ['profile1'] };
});
act(() => {
result.current.updatePlaylist(updatedPlaylist);
});
expect(result.current.playlists).toEqual([updatedPlaylist]);
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
});
it('should update profiles', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.profiles = { playlist1: ['profile1'] };
});
act(() => {
result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
});
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
});
it('should remove playlists', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.playlists = [
{ id: 'playlist1', name: 'Playlist 1' },
{ id: 'playlist2', name: 'Playlist 2' },
{ id: 'playlist3', name: 'Playlist 3' },
];
});
act(() => {
result.current.removePlaylists(['playlist1', 'playlist3']);
});
expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
});
it('should set refresh progress with two parameters', () => {
const { result } = renderHook(() => usePlaylistsStore());
const progressData = { action: 'refreshing', progress: 50 };
act(() => {
result.current.setRefreshProgress('account1', progressData);
});
expect(result.current.refreshProgress).toEqual({ account1: progressData });
});
it('should set refresh progress with WebSocket data', () => {
const { result } = renderHook(() => usePlaylistsStore());
const wsData = { account: 'account1', action: 'refreshing', progress: 50 };
act(() => {
result.current.setRefreshProgress(wsData);
});
expect(result.current.refreshProgress).toEqual({ account1: wsData });
});
it('should preserve initializing status until real progress', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.refreshProgress = {
account1: { action: 'initializing', progress: 0 },
};
});
act(() => {
result.current.setRefreshProgress({ account: 'account1', progress: 0 });
});
expect(result.current.refreshProgress.account1.action).toBe('initializing');
act(() => {
result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
});
expect(result.current.refreshProgress.account1.action).toBe('refreshing');
});
it('should remove refresh progress', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.refreshProgress = {
account1: { action: 'refreshing', progress: 50 },
account2: { action: 'refreshing', progress: 75 },
};
});
act(() => {
result.current.removeRefreshProgress('account1');
});
expect(result.current.refreshProgress).toEqual({
account2: { action: 'refreshing', progress: 75 },
});
});
it('should set profile preview', () => {
const { result } = renderHook(() => usePlaylistsStore());
act(() => {
result.current.setProfilePreview('search text', 'result data');
});
expect(result.current.profileSearchPreview).toBe('search text');
expect(result.current.profileResult).toBe('result data');
});
});

View file

@ -0,0 +1,234 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePluginStore } from '../plugins';
import API from '../../api';
vi.mock('../../api');
describe('usePluginStore', () => {
beforeEach(() => {
vi.clearAllMocks();
usePluginStore.setState({
plugins: [],
loading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => usePluginStore());
expect(result.current.plugins).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch plugins successfully', async () => {
const mockPlugins = [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
];
API.getPlugins.mockResolvedValue(mockPlugins);
const { result } = renderHook(() => usePluginStore());
await act(async () => {
await result.current.fetchPlugins();
});
expect(API.getPlugins).toHaveBeenCalled();
expect(result.current.plugins).toEqual(mockPlugins);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch plugins with empty response', async () => {
API.getPlugins.mockResolvedValue(null);
const { result } = renderHook(() => usePluginStore());
await act(async () => {
await result.current.fetchPlugins();
});
expect(result.current.plugins).toEqual([]);
expect(result.current.loading).toBe(false);
});
it('should handle fetch plugins error', async () => {
const mockError = new Error('Network error');
API.getPlugins.mockRejectedValue(mockError);
const { result } = renderHook(() => usePluginStore());
await act(async () => {
await result.current.fetchPlugins();
});
expect(result.current.error).toEqual(mockError);
expect(result.current.loading).toBe(false);
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
API.getPlugins.mockReturnValue(promise);
const { result } = renderHook(() => usePluginStore());
// Start the fetch without awaiting
act(() => {
result.current.fetchPlugins();
});
// Check loading is true synchronously
expect(result.current.loading).toBe(true);
// Resolve the promise and wait for state update
await act(async () => {
resolvePromise([]);
await promise;
});
expect(result.current.loading).toBe(false);
});
it('should update plugin', () => {
const { result } = renderHook(() => usePluginStore());
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: false },
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
],
});
});
act(() => {
result.current.updatePlugin('plugin1', { enabled: true });
});
expect(result.current.plugins).toEqual([
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
]);
});
it('should not modify other plugins when updating', () => {
const { result } = renderHook(() => usePluginStore());
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: false },
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
],
});
});
act(() => {
result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
});
expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
});
it('should add plugin', () => {
const { result } = renderHook(() => usePluginStore());
const newPlugin = { key: 'plugin1', name: 'New Plugin', enabled: true };
act(() => {
result.current.addPlugin(newPlugin);
});
expect(result.current.plugins).toEqual([newPlugin]);
});
it('should add plugin to existing plugins', () => {
const { result } = renderHook(() => usePluginStore());
const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
act(() => {
usePluginStore.setState({ plugins: [existingPlugin] });
});
act(() => {
result.current.addPlugin(newPlugin);
});
expect(result.current.plugins).toEqual([existingPlugin, newPlugin]);
});
it('should remove plugin', () => {
const { result } = renderHook(() => usePluginStore());
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
],
});
});
act(() => {
result.current.removePlugin('plugin1');
});
expect(result.current.plugins).toEqual([
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
]);
});
it('should handle removing non-existent plugin', () => {
const { result } = renderHook(() => usePluginStore());
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
],
});
});
act(() => {
result.current.removePlugin('nonexistent');
});
expect(result.current.plugins).toEqual([
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
]);
});
it('should invalidate plugins and refetch', async () => {
const mockPlugins = [
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
];
API.getPlugins.mockResolvedValue(mockPlugins);
const { result } = renderHook(() => usePluginStore());
act(() => {
usePluginStore.setState({
plugins: [
{ key: 'old-plugin', name: 'Old Plugin', enabled: false },
],
});
});
await act(async () => {
await result.current.invalidatePlugins();
});
expect(result.current.plugins).toEqual(mockPlugins);
expect(API.getPlugins).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,353 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useSettingsStore from '../settings';
import api from '../../api';
vi.mock('../../api');
describe('useSettingsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
settings: {},
environment: {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
},
version: {
version: '',
timestamp: null,
},
isLoading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useSettingsStore());
expect(result.current.settings).toEqual({});
expect(result.current.environment).toEqual({
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch settings successfully', async () => {
const mockSettings = [
{ key: 'setting1', value: 'value1' },
{ key: 'setting2', value: 'value2' },
];
const mockEnv = {
public_ip: '192.168.1.1',
country_code: 'US',
country_name: 'United States',
env_mode: 'dev',
};
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(api.getSettings).toHaveBeenCalled();
expect(api.getEnvironmentSettings).toHaveBeenCalled();
expect(result.current.settings).toEqual({
setting1: { key: 'setting1', value: 'value1' },
setting2: { key: 'setting2', value: 'value2' },
});
expect(result.current.environment).toEqual(mockEnv);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle null environment response', async () => {
const mockSettings = [{ key: 'setting1', value: 'value1' }];
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(null);
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(result.current.environment).toEqual({
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
});
});
it('should handle fetch settings error', async () => {
const mockError = new Error('Network error');
api.getSettings.mockRejectedValue(mockError);
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(result.current.error).toBe('Failed to load settings.');
expect(result.current.isLoading).toBe(false);
});
it('should set loading state during fetch', async () => {
let resolveSettingsPromise;
let resolveEnvPromise;
const settingsPromise = new Promise((resolve) => {
resolveSettingsPromise = resolve;
});
const envPromise = new Promise((resolve) => {
resolveEnvPromise = resolve;
});
api.getSettings.mockReturnValue(settingsPromise);
api.getEnvironmentSettings.mockReturnValue(envPromise);
const { result } = renderHook(() => useSettingsStore());
act(() => {
result.current.fetchSettings();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolveSettingsPromise([]);
resolveEnvPromise({});
await settingsPromise;
await envPromise;
});
expect(result.current.isLoading).toBe(false);
});
it('should update setting', () => {
useSettingsStore.setState({
settings: {
setting1: { key: 'setting1', value: 'old_value' },
setting2: { key: 'setting2', value: 'value2' },
},
});
const { result } = renderHook(() => useSettingsStore());
act(() => {
result.current.updateSetting({ key: 'setting1', value: 'new_value' });
});
expect(result.current.settings).toEqual({
setting1: { key: 'setting1', value: 'new_value' },
setting2: { key: 'setting2', value: 'value2' },
});
});
it('should add new setting when updating non-existent key', () => {
useSettingsStore.setState({
settings: {
setting1: { key: 'setting1', value: 'value1' },
},
});
const { result } = renderHook(() => useSettingsStore());
act(() => {
result.current.updateSetting({ key: 'setting2', value: 'new_value' });
});
expect(result.current.settings).toEqual({
setting1: { key: 'setting1', value: 'value1' },
setting2: { key: 'setting2', value: 'new_value' },
});
});
it('should not modify other settings when updating', () => {
useSettingsStore.setState({
settings: {
setting1: { key: 'setting1', value: 'value1' },
setting2: { key: 'setting2', value: 'value2' },
},
});
const { result } = renderHook(() => useSettingsStore());
act(() => {
result.current.updateSetting({ key: 'setting1', value: 'updated' });
});
expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
});
it('should handle empty settings array', async () => {
api.getSettings.mockResolvedValue([]);
api.getEnvironmentSettings.mockResolvedValue({});
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(result.current.settings).toEqual({});
});
it('should initialize version with default state', () => {
const { result } = renderHook(() => useSettingsStore());
expect(result.current.version).toEqual({
version: '',
timestamp: null,
});
});
it('should fetch version successfully', async () => {
const mockVersion = {
version: '1.2.3',
timestamp: '2024-01-01T00:00:00Z',
};
api.getVersion.mockResolvedValue(mockVersion);
const { result } = renderHook(() => useSettingsStore());
let versionResult;
await act(async () => {
versionResult = await result.current.fetchVersion();
});
expect(api.getVersion).toHaveBeenCalled();
expect(result.current.version).toEqual({
version: '1.2.3',
timestamp: '2024-01-01T00:00:00Z',
});
expect(versionResult).toEqual({
version: '1.2.3',
timestamp: '2024-01-01T00:00:00Z',
});
});
it('should skip fetching version if already loaded', async () => {
useSettingsStore.setState({
version: {
version: '1.0.0',
timestamp: '2023-01-01T00:00:00Z',
},
});
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
const { result } = renderHook(() => useSettingsStore());
let versionResult;
await act(async () => {
versionResult = await result.current.fetchVersion();
});
expect(api.getVersion).not.toHaveBeenCalled();
expect(result.current.version).toEqual({
version: '1.0.0',
timestamp: '2023-01-01T00:00:00Z',
});
expect(versionResult).toEqual({
version: '1.0.0',
timestamp: '2023-01-01T00:00:00Z',
});
});
it('should handle null version response', async () => {
api.getVersion.mockResolvedValue(null);
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchVersion();
});
expect(result.current.version).toEqual({
version: '',
timestamp: null,
});
});
it('should handle fetch version error', async () => {
const mockError = new Error('Version fetch failed');
api.getVersion.mockRejectedValue(mockError);
const { result } = renderHook(() => useSettingsStore());
let versionResult;
await act(async () => {
versionResult = await result.current.fetchVersion();
});
expect(versionResult).toEqual({
version: '',
timestamp: null,
});
});
it('should fetch version with settings when version not loaded', async () => {
const mockSettings = [{ key: 'setting1', value: 'value1' }];
const mockEnv = { public_ip: '192.168.1.1' };
const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' };
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
api.getVersion.mockResolvedValue(mockVersion);
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(api.getVersion).toHaveBeenCalled();
expect(result.current.version).toEqual({
version: '1.0.0',
timestamp: '2024-01-01T00:00:00Z',
});
});
it('should skip fetching version with settings when already loaded', async () => {
useSettingsStore.setState({
version: {
version: '1.0.0',
timestamp: '2023-01-01T00:00:00Z',
},
});
const mockSettings = [{ key: 'setting1', value: 'value1' }];
const mockEnv = { public_ip: '192.168.1.1' };
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
const { result } = renderHook(() => useSettingsStore());
await act(async () => {
await result.current.fetchSettings();
});
expect(api.getVersion).not.toHaveBeenCalled();
expect(result.current.version).toEqual({
version: '1.0.0',
timestamp: '2023-01-01T00:00:00Z',
});
});
});

View file

@ -0,0 +1,265 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useStreamProfilesStore from '../streamProfiles';
import api from '../../api';
vi.mock('../../api');
describe('useStreamProfilesStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useStreamProfilesStore.setState({
profiles: [],
isLoading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useStreamProfilesStore());
expect(result.current.profiles).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch profiles successfully', async () => {
const mockProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
];
api.getStreamProfiles.mockResolvedValue(mockProfiles);
const { result } = renderHook(() => useStreamProfilesStore());
await act(async () => {
await result.current.fetchProfiles();
});
expect(api.getStreamProfiles).toHaveBeenCalled();
expect(result.current.profiles).toEqual(mockProfiles);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch profiles error', async () => {
const mockError = new Error('Network error');
api.getStreamProfiles.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useStreamProfilesStore());
await act(async () => {
await result.current.fetchProfiles();
});
expect(result.current.error).toBe('Failed to load profiles.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
consoleErrorSpy.mockRestore();
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getStreamProfiles.mockReturnValue(promise);
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.fetchProfiles();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise([]);
await promise;
});
expect(result.current.isLoading).toBe(false);
});
it('should add stream profile', () => {
useStreamProfilesStore.setState({
profiles: [{ id: 1, name: 'Profile 1', bitrate: 5000 }],
});
const { result } = renderHook(() => useStreamProfilesStore());
const newProfile = { id: 2, name: 'Profile 2', bitrate: 8000 };
act(() => {
result.current.addStreamProfile(newProfile);
});
expect(result.current.profiles).toEqual([
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
]);
});
it('should add stream profile to empty profiles', () => {
const { result } = renderHook(() => useStreamProfilesStore());
const newProfile = { id: 1, name: 'Profile 1', bitrate: 5000 };
act(() => {
result.current.addStreamProfile(newProfile);
});
expect(result.current.profiles).toEqual([newProfile]);
});
it('should update stream profile', () => {
useStreamProfilesStore.setState({
profiles: [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
],
});
const { result } = renderHook(() => useStreamProfilesStore());
const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
act(() => {
result.current.updateStreamProfile(updatedProfile);
});
expect(result.current.profiles).toEqual([
{ id: 1, name: 'Updated Profile', bitrate: 10000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
]);
});
it('should not modify other profiles when updating', () => {
useStreamProfilesStore.setState({
profiles: [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
],
});
const { result } = renderHook(() => useStreamProfilesStore());
const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
act(() => {
result.current.updateStreamProfile(updatedProfile);
});
expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
});
it('should not modify profiles when updating non-existent profile', () => {
const initialProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
];
useStreamProfilesStore.setState({
profiles: initialProfiles,
});
const { result } = renderHook(() => useStreamProfilesStore());
const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
act(() => {
result.current.updateStreamProfile(nonExistentProfile);
});
expect(result.current.profiles).toEqual(initialProfiles);
});
it('should remove single stream profile', () => {
useStreamProfilesStore.setState({
profiles: [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
{ id: 3, name: 'Profile 3', bitrate: 10000 },
],
});
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.removeStreamProfiles([2]);
});
expect(result.current.profiles).toEqual([
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 3, name: 'Profile 3', bitrate: 10000 },
]);
});
it('should remove multiple stream profiles', () => {
useStreamProfilesStore.setState({
profiles: [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
{ id: 3, name: 'Profile 3', bitrate: 10000 },
],
});
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.removeStreamProfiles([1, 3]);
});
expect(result.current.profiles).toEqual([
{ id: 2, name: 'Profile 2', bitrate: 8000 },
]);
});
it('should handle removing non-existent profiles', () => {
const initialProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
{ id: 2, name: 'Profile 2', bitrate: 8000 },
];
useStreamProfilesStore.setState({
profiles: initialProfiles,
});
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.removeStreamProfiles([999]);
});
expect(result.current.profiles).toEqual(initialProfiles);
});
it('should handle removing from empty profiles', () => {
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.removeStreamProfiles([1, 2]);
});
expect(result.current.profiles).toEqual([]);
});
it('should handle empty array when removing profiles', () => {
const initialProfiles = [
{ id: 1, name: 'Profile 1', bitrate: 5000 },
];
useStreamProfilesStore.setState({
profiles: initialProfiles,
});
const { result } = renderHook(() => useStreamProfilesStore());
act(() => {
result.current.removeStreamProfiles([]);
});
expect(result.current.profiles).toEqual(initialProfiles);
});
});

View file

@ -0,0 +1,289 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useStreamsStore from '../streams';
import api from '../../api';
vi.mock('../../api');
describe('useStreamsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useStreamsStore.setState({
streams: [],
count: 0,
isLoading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useStreamsStore());
expect(result.current.streams).toEqual([]);
expect(result.current.count).toBe(0);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch streams successfully', async () => {
const mockResponse = {
results: [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
],
count: 2,
};
api.getStreams.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useStreamsStore());
await act(async () => {
await result.current.fetchStreams();
});
expect(api.getStreams).toHaveBeenCalled();
expect(result.current.streams).toEqual(mockResponse.results);
expect(result.current.count).toBe(2);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch streams error', async () => {
const mockError = new Error('Network error');
api.getStreams.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useStreamsStore());
await act(async () => {
await result.current.fetchStreams();
});
expect(result.current.error).toBe('Failed to load streams.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
consoleErrorSpy.mockRestore();
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getStreams.mockReturnValue(promise);
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.fetchStreams();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise({ results: [], count: 0 });
await promise;
});
expect(result.current.isLoading).toBe(false);
});
it('should add stream', () => {
useStreamsStore.setState({
streams: [{ id: 1, name: 'Stream 1', url: 'http://example.com/1' }],
});
const { result } = renderHook(() => useStreamsStore());
const newStream = { id: 2, name: 'Stream 2', url: 'http://example.com/2' };
act(() => {
result.current.addStream(newStream);
});
expect(result.current.streams).toEqual([
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
]);
});
it('should add stream to empty streams', () => {
const { result } = renderHook(() => useStreamsStore());
const newStream = { id: 1, name: 'Stream 1', url: 'http://example.com/1' };
act(() => {
result.current.addStream(newStream);
});
expect(result.current.streams).toEqual([newStream]);
});
it('should update stream', () => {
useStreamsStore.setState({
streams: [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
],
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
act(() => {
result.current.updateStream(updatedStream);
});
expect(result.current.streams).toEqual([
{ id: 1, name: 'Updated Stream', url: 'http://example.com/updated' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
]);
});
it('should not modify other streams when updating', () => {
useStreamsStore.setState({
streams: [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
],
});
const { result } = renderHook(() => useStreamsStore());
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
act(() => {
result.current.updateStream(updatedStream);
});
expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
});
it('should not modify streams when updating non-existent stream', () => {
const initialStreams = [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
];
useStreamsStore.setState({
streams: initialStreams,
});
const { result } = renderHook(() => useStreamsStore());
const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
act(() => {
result.current.updateStream(nonExistentStream);
});
expect(result.current.streams).toEqual(initialStreams);
});
it('should remove single stream', () => {
useStreamsStore.setState({
streams: [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
],
});
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.removeStreams([2]);
});
expect(result.current.streams).toEqual([
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
]);
});
it('should remove multiple streams', () => {
useStreamsStore.setState({
streams: [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
],
});
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.removeStreams([1, 3]);
});
expect(result.current.streams).toEqual([
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
]);
});
it('should handle removing non-existent streams', () => {
const initialStreams = [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
];
useStreamsStore.setState({
streams: initialStreams,
});
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.removeStreams([999]);
});
expect(result.current.streams).toEqual(initialStreams);
});
it('should handle removing from empty streams', () => {
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.removeStreams([1, 2]);
});
expect(result.current.streams).toEqual([]);
});
it('should handle empty array when removing streams', () => {
const initialStreams = [
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
];
useStreamsStore.setState({
streams: initialStreams,
});
const { result } = renderHook(() => useStreamsStore());
act(() => {
result.current.removeStreams([]);
});
expect(result.current.streams).toEqual(initialStreams);
});
it('should handle fetch with empty results', async () => {
const mockResponse = {
results: [],
count: 0,
};
api.getStreams.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useStreamsStore());
await act(async () => {
await result.current.fetchStreams();
});
expect(result.current.streams).toEqual([]);
expect(result.current.count).toBe(0);
});
});

View file

@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import useStreamsTableStore from '../streamsTable';
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key) => {
delete store[key];
})
};
})();
globalThis.localStorage = localStorageMock;
describe('useStreamsTableStore', () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Initial State', () => {
it('should initialize with default state', () => {
const { result } = renderHook(() => useStreamsTableStore());
expect(result.current.streams).toEqual([]);
expect(result.current.pageCount).toBe(0);
expect(result.current.totalCount).toBe(0);
expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]);
expect(result.current.pagination).toEqual({
pageIndex: 0,
pageSize: 50,
});
expect(result.current.selectedStreamIds).toEqual([]);
expect(result.current.allQueryIds).toEqual([]);
expect(result.current.lastQueryParams).toBeNull();
});
it('should initialize pagination with localStorage value if available', () => {
// Set localStorage BEFORE getting the store state
localStorageMock.setItem('streams-page-size', JSON.stringify(100));
// Now update the store to re-read from localStorage
useStreamsTableStore.setState({
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
});
const { result } = renderHook(() => useStreamsTableStore());
expect(result.current.pagination.pageSize).toBe(100);
});
it('should use default page size if localStorage is empty', () => {
// Ensure localStorage is empty
localStorageMock.clear();
useStreamsTableStore.setState({
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
}
});
const { result } = renderHook(() => useStreamsTableStore());
expect(result.current.pagination.pageSize).toBe(50);
});
});
describe('queryStreams', () => {
it('should update streams, totalCount, and pageCount', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockResults = [
{ id: 1, name: 'Stream 1' },
{ id: 2, name: 'Stream 2' },
];
const mockCount = 100;
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryStreams(
{ results: mockResults, count: mockCount },
mockParams
);
});
expect(result.current.streams).toEqual(mockResults);
expect(result.current.totalCount).toBe(100);
expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50)
});
it('should calculate pageCount correctly with different page sizes', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
result.current.queryStreams(
{ results: [], count: 75 },
mockParams
);
});
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
});
it('should handle empty results', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
result.current.queryStreams(
{ results: [], count: 0 },
mockParams
);
});
expect(result.current.streams).toEqual([]);
expect(result.current.totalCount).toBe(0);
expect(result.current.pageCount).toBe(0);
});
});
describe('setAllQueryIds', () => {
it('should update allQueryIds', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockIds = [1, 2, 3, 4, 5];
act(() => {
result.current.setAllQueryIds(mockIds);
});
expect(result.current.allQueryIds).toEqual(mockIds);
});
it('should replace previous allQueryIds', () => {
const { result } = renderHook(() => useStreamsTableStore());
act(() => {
result.current.setAllQueryIds([1, 2, 3]);
});
act(() => {
result.current.setAllQueryIds([4, 5, 6]);
});
expect(result.current.allQueryIds).toEqual([4, 5, 6]);
});
});
describe('setSelectedStreamIds', () => {
it('should update selectedStreamIds', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockIds = [1, 3, 5];
act(() => {
result.current.setSelectedStreamIds(mockIds);
});
expect(result.current.selectedStreamIds).toEqual(mockIds);
});
it('should handle empty selection', () => {
const { result } = renderHook(() => useStreamsTableStore());
act(() => {
result.current.setSelectedStreamIds([1, 2, 3]);
});
act(() => {
result.current.setSelectedStreamIds([]);
});
expect(result.current.selectedStreamIds).toEqual([]);
});
});
describe('setPagination', () => {
it('should update pagination state', () => {
const { result } = renderHook(() => useStreamsTableStore());
const newPagination = {
pageIndex: 2,
pageSize: 100,
};
act(() => {
result.current.setPagination(newPagination);
});
expect(result.current.pagination).toEqual(newPagination);
});
it('should replace entire pagination object', () => {
const { result } = renderHook(() => useStreamsTableStore());
act(() => {
result.current.setPagination({ pageIndex: 1, pageSize: 25 });
});
act(() => {
result.current.setPagination({ pageIndex: 3, pageSize: 75 });
});
expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 });
});
});
describe('setSorting', () => {
it('should update sorting state', () => {
const { result } = renderHook(() => useStreamsTableStore());
const newSorting = [{ id: 'created_at', desc: true }];
act(() => {
result.current.setSorting(newSorting);
});
expect(result.current.sorting).toEqual(newSorting);
});
it('should handle multiple sorting columns', () => {
const { result } = renderHook(() => useStreamsTableStore());
const newSorting = [
{ id: 'name', desc: false },
{ id: 'created_at', desc: true },
];
act(() => {
result.current.setSorting(newSorting);
});
expect(result.current.sorting).toEqual(newSorting);
});
it('should handle empty sorting array', () => {
const { result } = renderHook(() => useStreamsTableStore());
act(() => {
result.current.setSorting([]);
});
expect(result.current.sorting).toEqual([]);
});
});
describe('setLastQueryParams', () => {
it('should update lastQueryParams', () => {
const { result } = renderHook(() => useStreamsTableStore());
const mockParams = new URLSearchParams({ page: '1', search: 'test' });
act(() => {
result.current.setLastQueryParams(mockParams);
});
expect(result.current.lastQueryParams).toBe(mockParams);
});
it('should handle null value', () => {
const { result } = renderHook(() => useStreamsTableStore());
act(() => {
result.current.setLastQueryParams(new URLSearchParams());
});
act(() => {
result.current.setLastQueryParams(null);
});
expect(result.current.lastQueryParams).toBeNull();
});
});
describe('Integration Tests', () => {
it('should handle a typical query flow', () => {
const { result } = renderHook(() => useStreamsTableStore());
// Set pagination
act(() => {
result.current.setPagination({ pageIndex: 0, pageSize: 25 });
});
// Set sorting
act(() => {
result.current.setSorting([{ id: 'created_at', desc: true }]);
});
// Query streams
const mockResults = [
{ id: 1, name: 'Stream 1' },
{ id: 2, name: 'Stream 2' },
];
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
result.current.queryStreams(
{ results: mockResults, count: 50 },
mockParams
);
});
// Set query IDs
act(() => {
result.current.setAllQueryIds([1, 2, 3, 4, 5]);
});
// Select streams
act(() => {
result.current.setSelectedStreamIds([1, 2]);
});
// Set last query params
act(() => {
result.current.setLastQueryParams(mockParams);
});
expect(result.current.streams).toEqual(mockResults);
expect(result.current.totalCount).toBe(50);
expect(result.current.pageCount).toBe(2);
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
expect(result.current.selectedStreamIds).toEqual([1, 2]);
expect(result.current.lastQueryParams).toBe(mockParams);
});
});
});

View file

@ -0,0 +1,755 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useVODStore from '../useVODStore';
import api from '../../api';
vi.mock('../../api');
describe('useVODStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useVODStore.setState({
content: {},
currentPageContent: [],
episodes: {},
categories: {},
loading: false,
error: null,
filters: {
type: 'all',
search: '',
category: '',
},
currentPage: 1,
totalCount: 0,
pageSize: 24,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useVODStore());
expect(result.current.content).toEqual({});
expect(result.current.currentPageContent).toEqual([]);
expect(result.current.episodes).toEqual({});
expect(result.current.categories).toEqual({});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.filters).toEqual({
type: 'all',
search: '',
category: '',
});
expect(result.current.currentPage).toBe(1);
expect(result.current.totalCount).toBe(0);
expect(result.current.pageSize).toBe(24);
});
it('should set filters and reset to first page', () => {
useVODStore.setState({ currentPage: 5 });
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setFilters({ search: 'test', category: 'action' });
});
expect(result.current.filters).toEqual({
type: 'all',
search: 'test',
category: 'action',
});
expect(result.current.currentPage).toBe(1);
});
it('should set page', () => {
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setPage(3);
});
expect(result.current.currentPage).toBe(3);
});
it('should set page size and reset to first page', () => {
useVODStore.setState({ currentPage: 3 });
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setPageSize(50);
});
expect(result.current.pageSize).toBe(50);
expect(result.current.currentPage).toBe(1);
});
it('should fetch all content successfully', async () => {
const mockResponse = {
results: [
{ id: 1, name: 'Movie 1', content_type: 'movie' },
{ id: 2, name: 'Series 1', content_type: 'series' },
],
count: 2,
};
api.getAllContent.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchContent();
});
expect(api.getAllContent).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
{ id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
]);
expect(result.current.totalCount).toBe(2);
expect(result.current.loading).toBe(false);
});
it('should fetch only movies when filter type is movies', async () => {
const mockResponse = {
results: [
{ id: 1, name: 'Movie 1' },
{ id: 2, name: 'Movie 2' },
],
count: 2,
};
api.getMovies.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setFilters({ type: 'movies' });
});
await act(async () => {
await result.current.fetchContent();
});
expect(api.getMovies).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Movie 1', contentType: 'movie' },
{ id: 2, name: 'Movie 2', contentType: 'movie' },
]);
expect(result.current.totalCount).toBe(2);
});
it('should fetch only series when filter type is series', async () => {
const mockResponse = {
results: [
{ id: 1, name: 'Series 1' },
{ id: 2, name: 'Series 2' },
],
count: 2,
};
api.getSeries.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setFilters({ type: 'series' });
});
await act(async () => {
await result.current.fetchContent();
});
expect(api.getSeries).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Series 1', contentType: 'series' },
{ id: 2, name: 'Series 2', contentType: 'series' },
]);
expect(result.current.totalCount).toBe(2);
});
it('should handle fetch content error', async () => {
const mockError = new Error('Network error');
api.getAllContent.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchContent();
});
expect(result.current.error).toBe('Failed to load content.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
consoleErrorSpy.mockRestore();
});
it('should handle invalid response format', async () => {
api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchContent();
});
expect(result.current.error).toBe('Failed to load content.');
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it('should fetch movie details successfully', async () => {
const mockResponse = {
id: 1,
name: 'Test Movie',
description: 'A test movie',
year: 2023,
url: 'http://example.com/movie.mp4',
};
api.getMovieDetails.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
let movieDetails;
await act(async () => {
movieDetails = await result.current.fetchMovieDetails(1);
});
expect(api.getMovieDetails).toHaveBeenCalledWith(1);
expect(movieDetails.id).toBe(1);
expect(movieDetails.name).toBe('Test Movie');
expect(movieDetails.stream_url).toBe('http://example.com/movie.mp4');
expect(result.current.content['movie_1']).toBeDefined();
expect(result.current.content['movie_1'].contentType).toBe('movie');
expect(result.current.loading).toBe(false);
});
it('should handle fetch movie details error', async () => {
const mockError = new Error('Not found');
api.getMovieDetails.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
try {
await result.current.fetchMovieDetails(999);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(result.current.error).toBe('Failed to load movie details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
consoleErrorSpy.mockRestore();
});
it('should fetch movie details from provider without merging to store', async () => {
const mockResponse = {
id: 1,
name: 'Provider Movie',
plot: 'From provider',
stream_url: 'http://provider.com/movie.mp4',
backdrop_path: ['path1', 'path2'],
};
api.getMovieProviderInfo.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
let movieDetails;
await act(async () => {
movieDetails = await result.current.fetchMovieDetailsFromProvider(1);
});
expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1);
expect(movieDetails.name).toBe('Provider Movie');
expect(movieDetails.description).toBe('From provider');
expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']);
expect(result.current.content['movie_1']).toBeUndefined();
expect(result.current.loading).toBe(false);
});
it('should handle fetch movie provider error', async () => {
const mockError = new Error('Provider error');
api.getMovieProviderInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
try {
await result.current.fetchMovieDetailsFromProvider(1);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(result.current.error).toBe('Failed to load movie details from provider.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
consoleErrorSpy.mockRestore();
});
it('should fetch movie providers successfully', async () => {
const mockProviders = [
{ id: 1, name: 'Provider 1' },
{ id: 2, name: 'Provider 2' },
];
api.getMovieProviders.mockResolvedValue(mockProviders);
const { result } = renderHook(() => useVODStore());
let providers;
await act(async () => {
providers = await result.current.fetchMovieProviders(1);
});
expect(api.getMovieProviders).toHaveBeenCalledWith(1);
expect(providers).toEqual(mockProviders);
});
it('should handle fetch movie providers error', async () => {
const mockError = new Error('Providers error');
api.getMovieProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
try {
await result.current.fetchMovieProviders(1);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
consoleErrorSpy.mockRestore();
});
it('should fetch series providers successfully', async () => {
const mockProviders = [{ id: 1, name: 'Series Provider 1' }];
api.getSeriesProviders.mockResolvedValue(mockProviders);
const { result } = renderHook(() => useVODStore());
let providers;
await act(async () => {
providers = await result.current.fetchSeriesProviders(1);
});
expect(api.getSeriesProviders).toHaveBeenCalledWith(1);
expect(providers).toEqual(mockProviders);
});
it('should handle fetch series providers error', async () => {
const mockError = new Error('Series providers error');
api.getSeriesProviders.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
try {
await result.current.fetchSeriesProviders(1);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
consoleErrorSpy.mockRestore();
});
it('should fetch series info successfully', async () => {
const mockResponse = {
id: 1,
name: 'Test Series',
description: 'A test series',
year: 2023,
cover: 'http://example.com/cover.jpg',
episodes: {
1: [
{
id: 101,
title: 'Episode 1',
episode_number: 1,
plot: 'First episode',
},
{
id: 102,
title: 'Episode 2',
episode_number: 2,
plot: 'Second episode',
},
],
},
};
api.getSeriesInfo.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
let seriesInfo;
await act(async () => {
seriesInfo = await result.current.fetchSeriesInfo(1);
});
expect(api.getSeriesInfo).toHaveBeenCalledWith(1);
expect(seriesInfo.id).toBe(1);
expect(seriesInfo.name).toBe('Test Series');
expect(seriesInfo.episodesList).toHaveLength(2);
expect(result.current.content['series_1']).toBeDefined();
expect(result.current.content['series_1'].contentType).toBe('series');
expect(result.current.episodes[101]).toBeDefined();
expect(result.current.episodes[102]).toBeDefined();
expect(result.current.episodes[101].name).toBe('Episode 1');
expect(result.current.loading).toBe(false);
});
it('should handle fetch series info error', async () => {
const mockError = new Error('Series not found');
api.getSeriesInfo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
try {
await result.current.fetchSeriesInfo(999);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(result.current.error).toBe('Failed to load series details.');
expect(result.current.loading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
consoleErrorSpy.mockRestore();
});
it('should fetch categories successfully with array response', async () => {
const mockCategories = [
{ id: 1, name: 'Action' },
{ id: 2, name: 'Comedy' },
];
api.getVODCategories.mockResolvedValue(mockCategories);
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchCategories();
});
expect(api.getVODCategories).toHaveBeenCalled();
expect(result.current.categories).toEqual({
1: { id: 1, name: 'Action' },
2: { id: 2, name: 'Comedy' },
});
});
it('should fetch categories successfully with paginated response', async () => {
const mockResponse = {
results: [
{ id: 1, name: 'Drama' },
{ id: 2, name: 'Thriller' },
],
};
api.getVODCategories.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchCategories();
});
expect(result.current.categories).toEqual({
1: { id: 1, name: 'Drama' },
2: { id: 2, name: 'Thriller' },
});
});
it('should handle fetch categories error', async () => {
const mockError = new Error('Categories error');
api.getVODCategories.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
await act(async () => {
await result.current.fetchCategories();
});
expect(result.current.error).toBe('Failed to load categories.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
consoleErrorSpy.mockRestore();
});
it('should add movie to content', () => {
const { result } = renderHook(() => useVODStore());
const movie = { id: 1, name: 'New Movie' };
act(() => {
result.current.addMovie(movie);
});
expect(result.current.content['movie_1']).toEqual({
id: 1,
name: 'New Movie',
contentType: 'movie',
});
});
it('should update movie in content', () => {
useVODStore.setState({
content: {
movie_1: { id: 1, name: 'Old Movie', contentType: 'movie' },
},
});
const { result } = renderHook(() => useVODStore());
const updatedMovie = { id: 1, name: 'Updated Movie' };
act(() => {
result.current.updateMovie(updatedMovie);
});
expect(result.current.content['movie_1']).toEqual({
id: 1,
name: 'Updated Movie',
contentType: 'movie',
});
});
it('should remove movie from content', () => {
useVODStore.setState({
content: {
movie_1: { id: 1, name: 'Movie to Remove', contentType: 'movie' },
movie_2: { id: 2, name: 'Movie to Keep', contentType: 'movie' },
},
});
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.removeMovie(1);
});
expect(result.current.content['movie_1']).toBeUndefined();
expect(result.current.content['movie_2']).toBeDefined();
});
it('should add series to content', () => {
const { result } = renderHook(() => useVODStore());
const series = { id: 1, name: 'New Series' };
act(() => {
result.current.addSeries(series);
});
expect(result.current.content['series_1']).toEqual({
id: 1,
name: 'New Series',
contentType: 'series',
});
});
it('should update series in content', () => {
useVODStore.setState({
content: {
series_1: { id: 1, name: 'Old Series', contentType: 'series' },
},
});
const { result } = renderHook(() => useVODStore());
const updatedSeries = { id: 1, name: 'Updated Series' };
act(() => {
result.current.updateSeries(updatedSeries);
});
expect(result.current.content['series_1']).toEqual({
id: 1,
name: 'Updated Series',
contentType: 'series',
});
});
it('should remove series from content', () => {
useVODStore.setState({
content: {
series_1: { id: 1, name: 'Series to Remove', contentType: 'series' },
series_2: { id: 2, name: 'Series to Keep', contentType: 'series' },
},
});
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.removeSeries(1);
});
expect(result.current.content['series_1']).toBeUndefined();
expect(result.current.content['series_2']).toBeDefined();
});
it('should get filtered content from current page', () => {
const mockContent = [
{ id: 1, name: 'Movie 1', contentType: 'movie' },
{ id: 2, name: 'Series 1', contentType: 'series' },
];
useVODStore.setState({
currentPageContent: mockContent,
});
const { result } = renderHook(() => useVODStore());
const filtered = result.current.getFilteredContent();
expect(filtered).toEqual(mockContent);
});
it('should get only movies from content', () => {
useVODStore.setState({
content: {
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
movie_2: { id: 3, name: 'Movie 2', contentType: 'movie' },
},
});
const { result } = renderHook(() => useVODStore());
const movies = result.current.getMovies();
expect(movies).toHaveLength(2);
expect(movies.every((item) => item.contentType === 'movie')).toBe(true);
});
it('should get only series from content', () => {
useVODStore.setState({
content: {
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
series_2: { id: 3, name: 'Series 2', contentType: 'series' },
},
});
const { result } = renderHook(() => useVODStore());
const series = result.current.getSeries();
expect(series).toHaveLength(2);
expect(series.every((item) => item.contentType === 'series')).toBe(true);
});
it('should clear all content', () => {
useVODStore.setState({
content: {
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
},
totalCount: 2,
});
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.clearContent();
});
expect(result.current.content).toEqual({});
expect(result.current.totalCount).toBe(0);
});
it('should handle fetch content with search filter', async () => {
const mockResponse = {
results: [{ id: 1, name: 'Searched Movie', content_type: 'movie' }],
count: 1,
};
api.getAllContent.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setFilters({ search: 'Searched' });
});
await act(async () => {
await result.current.fetchContent();
});
expect(api.getAllContent).toHaveBeenCalled();
const callArgs = api.getAllContent.mock.calls[0][0];
expect(callArgs.get('search')).toBe('Searched');
});
it('should handle fetch content with category filter', async () => {
const mockResponse = {
results: [{ id: 1, name: 'Action Movie', content_type: 'movie' }],
count: 1,
};
api.getAllContent.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.setFilters({ category: 'action' });
});
await act(async () => {
await result.current.fetchContent();
});
const callArgs = api.getAllContent.mock.calls[0][0];
expect(callArgs.get('category')).toBe('action');
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getAllContent.mockReturnValue(promise);
const { result } = renderHook(() => useVODStore());
act(() => {
result.current.fetchContent();
});
expect(result.current.loading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise({ results: [], count: 0 });
await promise;
});
expect(result.current.loading).toBe(false);
});
});

View file

@ -0,0 +1,182 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import useVideoStore from '../useVideoStore';
describe('useVideoStore', () => {
beforeEach(() => {
useVideoStore.setState({
isVisible: false,
streamUrl: null,
contentType: 'live',
metadata: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useVideoStore());
expect(result.current.isVisible).toBe(false);
expect(result.current.streamUrl).toBe(null);
expect(result.current.contentType).toBe('live');
expect(result.current.metadata).toBe(null);
});
it('should show video with live stream', () => {
const { result } = renderHook(() => useVideoStore());
const streamUrl = 'http://example.com/stream.ts';
act(() => {
result.current.showVideo(streamUrl);
});
expect(result.current.isVisible).toBe(true);
expect(result.current.streamUrl).toBe(streamUrl);
expect(result.current.contentType).toBe('live');
expect(result.current.metadata).toBe(null);
});
it('should show video with VOD content', () => {
const { result } = renderHook(() => useVideoStore());
const streamUrl = 'http://example.com/video.mp4';
const metadata = { title: 'Test Video', duration: 120 };
act(() => {
result.current.showVideo(streamUrl, 'vod', metadata);
});
expect(result.current.isVisible).toBe(true);
expect(result.current.streamUrl).toBe(streamUrl);
expect(result.current.contentType).toBe('vod');
expect(result.current.metadata).toEqual(metadata);
});
it('should show video with custom content type', () => {
const { result } = renderHook(() => useVideoStore());
const streamUrl = 'http://example.com/video.mkv';
act(() => {
result.current.showVideo(streamUrl, 'vod');
});
expect(result.current.isVisible).toBe(true);
expect(result.current.streamUrl).toBe(streamUrl);
expect(result.current.contentType).toBe('vod');
expect(result.current.metadata).toBe(null);
});
it('should hide video and reset state', () => {
const { result } = renderHook(() => useVideoStore());
act(() => {
result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
});
expect(result.current.isVisible).toBe(true);
act(() => {
result.current.hideVideo();
});
expect(result.current.isVisible).toBe(false);
expect(result.current.streamUrl).toBe(null);
expect(result.current.contentType).toBe('live');
expect(result.current.metadata).toBe(null);
});
it('should update stream when showing different video', () => {
const { result } = renderHook(() => useVideoStore());
const firstUrl = 'http://example.com/stream1.ts';
const secondUrl = 'http://example.com/stream2.ts';
act(() => {
result.current.showVideo(firstUrl);
});
expect(result.current.streamUrl).toBe(firstUrl);
act(() => {
result.current.showVideo(secondUrl);
});
expect(result.current.streamUrl).toBe(secondUrl);
expect(result.current.isVisible).toBe(true);
});
it('should handle showing video with null metadata explicitly', () => {
const { result } = renderHook(() => useVideoStore());
const streamUrl = 'http://example.com/stream.ts';
act(() => {
result.current.showVideo(streamUrl, 'live', null);
});
expect(result.current.isVisible).toBe(true);
expect(result.current.streamUrl).toBe(streamUrl);
expect(result.current.contentType).toBe('live');
expect(result.current.metadata).toBe(null);
});
it('should preserve metadata when showing VOD content', () => {
const { result } = renderHook(() => useVideoStore());
const metadata = {
title: 'Test Video',
duration: 120,
thumbnailUrl: 'http://example.com/thumb.jpg'
};
act(() => {
result.current.showVideo('http://example.com/video.mp4', 'vod', metadata);
});
expect(result.current.metadata).toEqual(metadata);
expect(result.current.metadata.title).toBe('Test Video');
expect(result.current.metadata.duration).toBe(120);
});
it('should override previous metadata when showing new video', () => {
const { result } = renderHook(() => useVideoStore());
const firstMetadata = { title: 'First Video' };
const secondMetadata = { title: 'Second Video' };
act(() => {
result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
});
expect(result.current.metadata).toEqual(firstMetadata);
act(() => {
result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
});
expect(result.current.metadata).toEqual(secondMetadata);
});
it('should handle hiding video when already hidden', () => {
const { result } = renderHook(() => useVideoStore());
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.hideVideo();
});
expect(result.current.isVisible).toBe(false);
expect(result.current.streamUrl).toBe(null);
});
it('should handle showing video multiple times consecutively', () => {
const { result } = renderHook(() => useVideoStore());
const url1 = 'http://example.com/stream1.ts';
const url2 = 'http://example.com/stream2.ts';
const url3 = 'http://example.com/stream3.ts';
act(() => {
result.current.showVideo(url1);
result.current.showVideo(url2);
result.current.showVideo(url3);
});
expect(result.current.isVisible).toBe(true);
expect(result.current.streamUrl).toBe(url3);
});
});

View file

@ -0,0 +1,277 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useUserAgentsStore from '../userAgents';
import api from '../../api';
vi.mock('../../api');
describe('useUserAgentsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useUserAgentsStore.setState({
userAgents: [],
isLoading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useUserAgentsStore());
expect(result.current.userAgents).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch user agents successfully', async () => {
const mockUserAgents = [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
];
api.getUserAgents.mockResolvedValue(mockUserAgents);
const { result } = renderHook(() => useUserAgentsStore());
await act(async () => {
await result.current.fetchUserAgents();
});
expect(api.getUserAgents).toHaveBeenCalled();
expect(result.current.userAgents).toEqual(mockUserAgents);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch user agents error', async () => {
const mockError = new Error('Network error');
api.getUserAgents.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useUserAgentsStore());
await act(async () => {
await result.current.fetchUserAgents();
});
expect(result.current.error).toBe('Failed to load userAgents.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
consoleErrorSpy.mockRestore();
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getUserAgents.mockReturnValue(promise);
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.fetchUserAgents();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise([]);
await promise;
});
expect(result.current.isLoading).toBe(false);
});
it('should add user agent', () => {
useUserAgentsStore.setState({
userAgents: [{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }],
});
const { result } = renderHook(() => useUserAgentsStore());
const newUserAgent = { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' };
act(() => {
result.current.addUserAgent(newUserAgent);
});
expect(result.current.userAgents).toEqual([
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
]);
});
it('should add user agent to empty user agents', () => {
const { result } = renderHook(() => useUserAgentsStore());
const newUserAgent = { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' };
act(() => {
result.current.addUserAgent(newUserAgent);
});
expect(result.current.userAgents).toEqual([newUserAgent]);
});
it('should update user agent', () => {
useUserAgentsStore.setState({
userAgents: [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
],
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
act(() => {
result.current.updateUserAgent(updatedUserAgent);
});
expect(result.current.userAgents).toEqual([
{ id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
]);
});
it('should not modify other user agents when updating', () => {
useUserAgentsStore.setState({
userAgents: [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
],
});
const { result } = renderHook(() => useUserAgentsStore());
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
act(() => {
result.current.updateUserAgent(updatedUserAgent);
});
expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
});
it('should not modify user agents when updating non-existent user agent', () => {
const initialUserAgents = [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
];
useUserAgentsStore.setState({
userAgents: initialUserAgents,
});
const { result } = renderHook(() => useUserAgentsStore());
const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
act(() => {
result.current.updateUserAgent(nonExistentUserAgent);
});
expect(result.current.userAgents).toEqual(initialUserAgents);
});
it('should remove single user agent', () => {
useUserAgentsStore.setState({
userAgents: [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
],
});
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.removeUserAgents([2]);
});
expect(result.current.userAgents).toEqual([
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
]);
});
it('should remove multiple user agents', () => {
useUserAgentsStore.setState({
userAgents: [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
],
});
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.removeUserAgents([1, 3]);
});
expect(result.current.userAgents).toEqual([
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
]);
});
it('should handle removing non-existent user agents', () => {
const initialUserAgents = [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
];
useUserAgentsStore.setState({
userAgents: initialUserAgents,
});
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.removeUserAgents([999]);
});
expect(result.current.userAgents).toEqual(initialUserAgents);
});
it('should handle removing from empty user agents', () => {
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.removeUserAgents([1, 2]);
});
expect(result.current.userAgents).toEqual([]);
});
it('should handle empty array when removing user agents', () => {
const initialUserAgents = [
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
];
useUserAgentsStore.setState({
userAgents: initialUserAgents,
});
const { result } = renderHook(() => useUserAgentsStore());
act(() => {
result.current.removeUserAgents([]);
});
expect(result.current.userAgents).toEqual(initialUserAgents);
});
it('should handle fetch with empty results', async () => {
api.getUserAgents.mockResolvedValue([]);
const { result } = renderHook(() => useUserAgentsStore());
await act(async () => {
await result.current.fetchUserAgents();
});
expect(result.current.userAgents).toEqual([]);
});
});

View file

@ -0,0 +1,258 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useUsersStore from '../users';
import api from '../../api';
vi.mock('../../api');
describe('useUsersStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useUsersStore.setState({
users: [],
isLoading: false,
error: null,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useUsersStore());
expect(result.current.users).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should fetch users successfully', async () => {
const mockUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
];
api.getUsers.mockResolvedValue(mockUsers);
const { result } = renderHook(() => useUsersStore());
await act(async () => {
await result.current.fetchUsers();
});
expect(api.getUsers).toHaveBeenCalled();
expect(result.current.users).toEqual(mockUsers);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch users error', async () => {
const mockError = new Error('Network error');
api.getUsers.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useUsersStore());
await act(async () => {
await result.current.fetchUsers();
});
expect(result.current.error).toBe('Failed to load users.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
consoleErrorSpy.mockRestore();
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getUsers.mockReturnValue(promise);
const { result } = renderHook(() => useUsersStore());
act(() => {
result.current.fetchUsers();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise([]);
await promise;
});
expect(result.current.isLoading).toBe(false);
});
it('should add user', () => {
useUsersStore.setState({
users: [{ id: 1, name: 'User 1', email: 'user1@example.com' }],
});
const { result } = renderHook(() => useUsersStore());
const newUser = { id: 2, name: 'User 2', email: 'user2@example.com' };
act(() => {
result.current.addUser(newUser);
});
expect(result.current.users).toEqual([
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
]);
});
it('should add user to empty users', () => {
const { result } = renderHook(() => useUsersStore());
const newUser = { id: 1, name: 'User 1', email: 'user1@example.com' };
act(() => {
result.current.addUser(newUser);
});
expect(result.current.users).toEqual([newUser]);
});
it('should update user', () => {
useUsersStore.setState({
users: [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
],
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
act(() => {
result.current.updateUser(updatedUser);
});
expect(result.current.users).toEqual([
{ id: 1, name: 'Updated User', email: 'updated@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
]);
});
it('should not modify other users when updating', () => {
useUsersStore.setState({
users: [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
],
});
const { result } = renderHook(() => useUsersStore());
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
act(() => {
result.current.updateUser(updatedUser);
});
expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
});
it('should not modify users when updating non-existent user', () => {
const initialUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
];
useUsersStore.setState({
users: initialUsers,
});
const { result } = renderHook(() => useUsersStore());
const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
act(() => {
result.current.updateUser(nonExistentUser);
});
expect(result.current.users).toEqual(initialUsers);
});
it('should remove user', () => {
useUsersStore.setState({
users: [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
{ id: 3, name: 'User 3', email: 'user3@example.com' },
],
});
const { result } = renderHook(() => useUsersStore());
act(() => {
result.current.removeUser(2);
});
expect(result.current.users).toEqual([
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 3, name: 'User 3', email: 'user3@example.com' },
]);
});
it('should handle removing non-existent user', () => {
const initialUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
];
useUsersStore.setState({
users: initialUsers,
});
const { result } = renderHook(() => useUsersStore());
act(() => {
result.current.removeUser(999);
});
expect(result.current.users).toEqual(initialUsers);
});
it('should handle removing from empty users', () => {
const { result } = renderHook(() => useUsersStore());
act(() => {
result.current.removeUser(1);
});
expect(result.current.users).toEqual([]);
});
it('should handle fetch with empty results', async () => {
api.getUsers.mockResolvedValue([]);
const { result } = renderHook(() => useUsersStore());
await act(async () => {
await result.current.fetchUsers();
});
expect(result.current.users).toEqual([]);
});
it('should not modify other users when removing', () => {
useUsersStore.setState({
users: [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' },
{ id: 3, name: 'User 3', email: 'user3@example.com' },
],
});
const { result } = renderHook(() => useUsersStore());
act(() => {
result.current.removeUser(2);
});
expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
});
});

View file

@ -0,0 +1,550 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import useVODLogosStore from '../vodLogos';
import api from '../../api';
vi.mock('../../api');
describe('useVODLogosStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useVODLogosStore.setState({
vodLogos: {},
logos: [],
isLoading: false,
hasLoaded: false,
error: null,
totalCount: 0,
currentPage: 1,
pageSize: 25,
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useVODLogosStore());
expect(result.current.vodLogos).toEqual({});
expect(result.current.logos).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.hasLoaded).toBe(false);
expect(result.current.error).toBe(null);
expect(result.current.totalCount).toBe(0);
expect(result.current.currentPage).toBe(1);
expect(result.current.pageSize).toBe(25);
});
it('should set VOD logos with normalized structure', () => {
const { result } = renderHook(() => useVODLogosStore());
const mockLogos = [
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
{ id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
];
act(() => {
result.current.setVODLogos(mockLogos, 2);
});
expect(result.current.vodLogos).toEqual({
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
});
expect(result.current.totalCount).toBe(2);
expect(result.current.hasLoaded).toBe(true);
});
it('should fetch VOD logos successfully with array response', async () => {
const mockLogos = [
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
{ id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
];
api.getVODLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.fetchVODLogos();
});
expect(api.getVODLogos).toHaveBeenCalled();
expect(result.current.vodLogos).toEqual({
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
});
expect(result.current.logos).toEqual(mockLogos);
expect(result.current.totalCount).toBe(2);
expect(result.current.isLoading).toBe(false);
expect(result.current.hasLoaded).toBe(true);
});
it('should fetch VOD logos successfully with paginated response', async () => {
const mockLogos = [
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
];
const mockResponse = {
results: mockLogos,
count: 10,
};
api.getVODLogos.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.fetchVODLogos({ page: 1, page_size: 25 });
});
expect(result.current.vodLogos).toEqual({
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
});
expect(result.current.logos).toEqual(mockLogos);
expect(result.current.totalCount).toBe(10);
});
it('should handle fetch VOD logos error', async () => {
const mockError = new Error('Network error');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
try {
await result.current.fetchVODLogos();
} catch (error) {
// Expected error
}
});
expect(result.current.error).toBe('Failed to load VOD logos.');
expect(result.current.isLoading).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
consoleErrorSpy.mockRestore();
});
it('should set loading state during fetch', async () => {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
api.getVODLogos.mockReturnValue(promise);
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current.fetchVODLogos();
});
expect(result.current.isLoading).toBe(true);
expect(result.current.error).toBe(null);
await act(async () => {
resolvePromise([]);
await promise;
});
expect(result.current.isLoading).toBe(false);
});
it('should remove single VOD logo from state', () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
2: { id: 2, name: 'Logo 2' },
3: { id: 3, name: 'Logo 3' },
},
logos: [
{ id: 1, name: 'Logo 1' },
{ id: 2, name: 'Logo 2' },
{ id: 3, name: 'Logo 3' },
],
totalCount: 3,
});
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current.removeVODLogo(2);
});
expect(result.current.vodLogos).toEqual({
1: { id: 1, name: 'Logo 1' },
3: { id: 3, name: 'Logo 3' },
});
expect(result.current.logos).toEqual([
{ id: 1, name: 'Logo 1' },
{ id: 3, name: 'Logo 3' },
]);
expect(result.current.totalCount).toBe(2);
});
it('should remove multiple VOD logos from state using _removeLogosFromState', () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
2: { id: 2, name: 'Logo 2' },
3: { id: 3, name: 'Logo 3' },
},
logos: [
{ id: 1, name: 'Logo 1' },
{ id: 2, name: 'Logo 2' },
{ id: 3, name: 'Logo 3' },
],
totalCount: 3,
});
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current._removeLogosFromState([1, 3]);
});
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.totalCount).toBe(1);
});
it('should delete single VOD logo successfully', async () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
2: { id: 2, name: 'Logo 2' },
},
logos: [
{ id: 1, name: 'Logo 1' },
{ id: 2, name: 'Logo 2' },
],
totalCount: 2,
});
api.deleteVODLogo.mockResolvedValue({});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.deleteVODLogo(1);
});
expect(api.deleteVODLogo).toHaveBeenCalledWith(1);
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
expect(result.current.logos).toEqual([
{ id: 2, name: 'Logo 2' },
]);
expect(result.current.totalCount).toBe(1);
});
it('should handle delete VOD logo error', async () => {
const mockError = new Error('Delete failed');
api.deleteVODLogo.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
try {
await result.current.deleteVODLogo(1);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
consoleErrorSpy.mockRestore();
});
it('should delete multiple VOD logos successfully', async () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
2: { id: 2, name: 'Logo 2' },
3: { id: 3, name: 'Logo 3' },
},
logos: [
{ id: 1, name: 'Logo 1' },
{ id: 2, name: 'Logo 2' },
{ id: 3, name: 'Logo 3' },
],
totalCount: 3,
});
api.deleteVODLogos.mockResolvedValue({});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.deleteVODLogos([1, 2]);
});
expect(api.deleteVODLogos).toHaveBeenCalledWith([1, 2]);
expect(result.current.vodLogos).toEqual({
3: { id: 3, name: 'Logo 3' },
});
expect(result.current.logos).toEqual([
{ id: 3, name: 'Logo 3' },
]);
expect(result.current.totalCount).toBe(1);
});
it('should handle delete multiple VOD logos error', async () => {
const mockError = new Error('Bulk delete failed');
api.deleteVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
try {
await result.current.deleteVODLogos([1, 2]);
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
consoleErrorSpy.mockRestore();
});
it('should cleanup unused VOD logos and refresh', async () => {
useVODLogosStore.setState({
currentPage: 2,
pageSize: 10,
});
const mockCleanupResult = { deleted: 5 };
const mockRefreshedLogos = [{ id: 1, name: 'Logo 1' }];
api.cleanupUnusedVODLogos.mockResolvedValue(mockCleanupResult);
api.getVODLogos.mockResolvedValue(mockRefreshedLogos);
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
const cleanupResult = await result.current.cleanupUnusedVODLogos();
expect(cleanupResult).toEqual(mockCleanupResult);
});
expect(api.cleanupUnusedVODLogos).toHaveBeenCalled();
expect(api.getVODLogos).toHaveBeenCalledWith({
page: 2,
page_size: 10,
});
expect(result.current.logos).toEqual(mockRefreshedLogos);
});
it('should handle cleanup unused VOD logos error', async () => {
const mockError = new Error('Cleanup failed');
api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
try {
await result.current.cleanupUnusedVODLogos();
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
consoleErrorSpy.mockRestore();
});
it('should clear VOD logos', () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
},
logos: [{ id: 1, name: 'Logo 1' }],
hasLoaded: true,
totalCount: 1,
error: 'Some error',
});
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current.clearVODLogos();
});
expect(result.current.vodLogos).toEqual({});
expect(result.current.logos).toEqual([]);
expect(result.current.hasLoaded).toBe(false);
expect(result.current.totalCount).toBe(0);
expect(result.current.error).toBe(null);
});
it('should handle removing non-existent logo', () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
},
logos: [{ id: 1, name: 'Logo 1' }],
totalCount: 1,
});
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current.removeVODLogo(999);
});
expect(result.current.vodLogos).toEqual({
1: { id: 1, name: 'Logo 1' },
});
expect(result.current.logos).toEqual([{ id: 1, name: 'Logo 1' }]);
expect(result.current.totalCount).toBe(1);
});
it('should handle fetch with empty response', async () => {
api.getVODLogos.mockResolvedValue([]);
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.fetchVODLogos();
});
expect(result.current.vodLogos).toEqual({});
expect(result.current.logos).toEqual([]);
expect(result.current.totalCount).toBe(0);
});
it('should not allow totalCount to go below zero', () => {
useVODLogosStore.setState({
vodLogos: {
1: { id: 1, name: 'Logo 1' },
},
logos: [{ id: 1, name: 'Logo 1' }],
totalCount: 1,
});
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current._removeLogosFromState([1, 2, 3]); // Remove more than exist
});
expect(result.current.totalCount).toBe(0);
});
it('should handle _removeLogosFromState with empty array', () => {
const initialState = {
vodLogos: {
1: { id: 1, name: 'Logo 1' },
},
logos: [{ id: 1, name: 'Logo 1' }],
totalCount: 1,
};
useVODLogosStore.setState(initialState);
const { result } = renderHook(() => useVODLogosStore());
act(() => {
result.current._removeLogosFromState([]);
});
expect(result.current.vodLogos).toEqual(initialState.vodLogos);
expect(result.current.logos).toEqual(initialState.logos);
expect(result.current.totalCount).toBe(1);
});
it('should fetch with custom params', async () => {
const mockLogos = [{ id: 1, name: 'Logo 1' }];
api.getVODLogos.mockResolvedValue(mockLogos);
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
await result.current.fetchVODLogos({ search: 'test', page: 2 });
});
expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 });
});
it('should get unused logos count successfully', async () => {
const mockResponse = {
results: [{ id: 1, name: 'Unused Logo' }],
count: 42,
};
api.getVODLogos.mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVODLogosStore());
let unusedCount;
await act(async () => {
unusedCount = await result.current.getUnusedLogosCount();
});
expect(api.getVODLogos).toHaveBeenCalledWith({
used: 'false',
page_size: 1,
});
expect(unusedCount).toBe(42);
});
it('should return 0 when unused logos count response has no count', async () => {
api.getVODLogos.mockResolvedValue({ results: [] });
const { result } = renderHook(() => useVODLogosStore());
let unusedCount;
await act(async () => {
unusedCount = await result.current.getUnusedLogosCount();
});
expect(unusedCount).toBe(0);
});
it('should handle get unused logos count error', async () => {
const mockError = new Error('Failed to fetch count');
api.getVODLogos.mockRejectedValue(mockError);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
await act(async () => {
try {
await result.current.getUnusedLogosCount();
} catch (error) {
expect(error).toBe(mockError);
}
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
consoleErrorSpy.mockRestore();
});
it('should update currentPage and pageSize state', () => {
const { result } = renderHook(() => useVODLogosStore());
act(() => {
useVODLogosStore.setState({
currentPage: 3,
pageSize: 50,
});
});
expect(result.current.currentPage).toBe(3);
expect(result.current.pageSize).toBe(50);
});
});

View file

@ -0,0 +1,195 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import useWarningsStore from '../warnings';
describe('useWarningsStore', () => {
beforeEach(() => {
useWarningsStore.setState({
suppressedWarnings: {},
});
});
it('should initialize with default state', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.suppressedWarnings).toEqual({});
});
it('should suppress a warning', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: true,
});
});
it('should suppress multiple warnings', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
result.current.suppressWarning('deleteProfile');
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: true,
deleteProfile: true,
});
});
it('should suppress warning with explicit true value', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream', true);
});
expect(result.current.suppressedWarnings.deleteStream).toBe(true);
});
it('should unsuppress a warning with explicit false value', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
});
expect(result.current.suppressedWarnings.deleteStream).toBe(true);
act(() => {
result.current.suppressWarning('deleteStream', false);
});
expect(result.current.suppressedWarnings.deleteStream).toBe(false);
});
it('should check if warning is suppressed', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
act(() => {
result.current.suppressWarning('deleteStream');
});
expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
});
it('should return false for non-existent warning', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.isWarningSuppressed('nonExistentAction')).toBe(false);
});
it('should reset all suppressions', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
result.current.suppressWarning('deleteProfile');
result.current.suppressWarning('deleteUser');
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: true,
deleteProfile: true,
deleteUser: true,
});
act(() => {
result.current.resetSuppressions();
});
expect(result.current.suppressedWarnings).toEqual({});
});
it('should maintain other suppressions when adding new one', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
result.current.suppressWarning('deleteProfile');
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: true,
deleteProfile: true,
});
});
it('should handle resetting when already empty', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.suppressedWarnings).toEqual({});
act(() => {
result.current.resetSuppressions();
});
expect(result.current.suppressedWarnings).toEqual({});
});
it('should handle checking suppression after unsuppressing', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream', true);
});
expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
act(() => {
result.current.suppressWarning('deleteStream', false);
});
expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
});
it('should preserve other warnings when unsuppressing one', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
result.current.suppressWarning('deleteProfile');
});
act(() => {
result.current.suppressWarning('deleteStream', false);
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: false,
deleteProfile: true,
});
});
it('should handle suppressing same warning multiple times', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('deleteStream');
result.current.suppressWarning('deleteStream');
});
expect(result.current.suppressedWarnings).toEqual({
deleteStream: true,
});
});
it('should handle different action keys independently', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('action1');
result.current.suppressWarning('action2', false);
});
expect(result.current.isWarningSuppressed('action1')).toBe(true);
expect(result.current.isWarningSuppressed('action2')).toBe(false);
expect(result.current.isWarningSuppressed('action3')).toBe(false);
});
});

View file

@ -1,5 +1,4 @@
import { create } from 'zustand';
import api from '../api';
import useSettingsStore from './settings';
import useChannelsStore from './channels';
import usePlaylistsStore from './playlists';
@ -25,6 +24,7 @@ const isTokenExpired = (expirationTime) => {
const useAuthStore = create((set, get) => ({
isAuthenticated: false,
isInitialized: false,
isInitializing: false,
needsSuperuser: false,
user: {
username: '',
@ -37,20 +37,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 +72,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;
}
},
@ -82,20 +103,16 @@ const useAuthStore = create((set, get) => ({
getToken: async () => {
const tokenExpiration = localStorage.getItem('tokenExpiration');
let accessToken = null;
if (isTokenExpired(tokenExpiration)) {
accessToken = await get().getRefreshToken();
} else {
accessToken = localStorage.getItem('accessToken');
}
return accessToken;
return isTokenExpired(tokenExpiration)
? await get().getRefreshToken()
: localStorage.getItem('accessToken');
},
// Action to login
login: async ({ username, password }) => {
try {
const response = await api.login(username, password);
const response = await API.login(username, password);
if (response.access) {
const expiration = decodeToken(response.access);
set({
@ -118,26 +135,25 @@ 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);
if (data && data.access) {
const data = await API.refreshToken(refreshToken);
if (data?.access) {
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 +172,8 @@ const useAuthStore = create((set, get) => ({
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
isInitialized: false,
isInitializing: false,
user: null,
});
localStorage.removeItem('accessToken');

View file

@ -1,9 +1,91 @@
import { create } from 'zustand';
import api from '../api';
import { notifications } from '@mantine/notifications';
import { showNotification } from '../utils/notificationUtils.js';
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
const reduceChannels = (channels) => {
const channelsByUUID = {};
const channelsByID = channels.reduce((acc, channel) => {
acc[channel.id] = channel;
channelsByUUID[channel.uuid] = channel.id;
return acc;
}, {});
return { channelsByUUID, channelsByID };
}
const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
const channelId = channelsByUUID[ch.channel_id];
const channel = channelId ? channels[channelId] : null;
if (channel) {
showNotification({
title: 'New channel streaming',
message: channel.name,
color: 'blue.5',
});
}
}
}
}
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
if (oldClients[client.client_id] === undefined) {
showNotification({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
color: 'blue.5',
});
}
}
}
const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
// Add null check for channel name
const channelId = channelsByUUID[uuid];
const channel = channelId && channels[channelId];
if (channel) {
showNotification({
title: 'Channel streaming stopped',
message: channel.name,
color: 'blue.5',
});
} else {
showNotification({
title: 'Channel streaming stopped',
message: `Channel (${uuid})`,
color: 'blue.5',
});
}
}
}
}
}
const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
if (currentStats.channels) {
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
showNotification({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
}
}
}
}
const useChannelsStore = create((set, get) => ({
channels: [],
channelsByUUID: {},
@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({
set({ isLoading: true, error: null });
try {
const channels = await api.getChannels();
const channelsByUUID = {};
const channelsByID = channels.reduce((acc, channel) => {
acc[channel.id] = channel;
channelsByUUID[channel.uuid] = channel.id;
return acc;
}, {});
const { channelsByUUID, channelsByID } = reduceChannels(channels);
set({
channels: channelsByID,
channelsByUUID,
@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({
addChannels: (newChannels) =>
set((state) => {
const channelsByUUID = {};
const profileChannels = new Set();
const channelsByID = newChannels.reduce((acc, channel) => {
acc[channel.id] = channel;
channelsByUUID[channel.uuid] = channel.id;
profileChannels.add(channel.id);
return acc;
}, {});
const { channelsByUUID, channelsByID } = reduceChannels(newChannels);
// Don't automatically add to all profiles anymore - let the backend handle profile assignments
// Just maintain the existing profile structure
@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({
);
return;
}
const channelsByUUID = {};
const updatedChannels = channels.reduce((acc, chan) => {
channelsByUUID[chan.uuid] = chan.id;
acc[chan.id] = chan;
return acc;
}, {});
const { channelsByUUID, updatedChannels } = reduceChannels(channels);
set((state) => ({
channels: {
@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({
delete updatedProfiles[id];
}
let additionalUpdates = {};
if (profileIds.includes(state.selectedProfileId)) {
additionalUpdates = {
selectedProfileId: '0',
};
}
const additionalUpdates = profileIds.includes(state.selectedProfileId)
? { selectedProfileId: '0' }
: {};
return {
profiles: updatedProfiles,
@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({
channels: currentChannelsSet,
};
const updates = {
return {
profiles: {
...state.profiles,
[profileId]: updatedProfile,
},
};
return updates;
}),
setChannelsPageSelection: (channelsPageSelection) =>
@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({
channelsByUUID,
} = state;
const newClients = {};
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
const channelId = channelsByUUID[ch.channel_id];
const channel = channelId ? channels[channelId] : null;
if (channel) {
notifications.show({
title: 'New channel streaming',
message: channel.name,
color: 'blue.5',
});
}
}
}
ch.clients.map((client) => {
newClients[client.client_id] = client;
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
if (oldClients[client.client_id] === undefined) {
notifications.show({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
color: 'blue.5',
});
}
}
});
return acc;
}, {});
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
if (newChannels[uuid] === undefined) {
// Add null check for channel name
const channelId = channelsByUUID[uuid];
const channel = channelId && channels[channelId];
if (channel) {
notifications.show({
title: 'Channel streaming stopped',
message: channel.name,
color: 'blue.5',
});
} else {
notifications.show({
title: 'Channel streaming stopped',
message: `Channel (${uuid})`,
color: 'blue.5',
});
}
}
}
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
notifications.show({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
}
}
}
stats.channels.forEach(ch => {
showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
ch.clients.forEach(client => {
newClients[client.client_id] = client;
showNotificationIfNewClient(currentStats, oldClients, client);
});
});
showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
showNotificationIfClientStopped(currentStats, oldClients, newClients);
return {
stats,
activeChannels: newChannels,

View file

@ -1,7 +1,4 @@
import { create } from 'zustand';
import api from '../api';
import { notifications } from '@mantine/notifications';
import API from '../api';
const useChannelsTableStore = create((set, get) => ({
channels: [],
@ -15,6 +12,7 @@ const useChannelsTableStore = create((set, get) => ({
},
selectedChannelIds: [],
allQueryIds: [],
isUnlocked: false,
queryChannels: ({ results, count }, params) => {
set((state) => {
@ -54,6 +52,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;

View file

@ -1,6 +1,14 @@
import { create } from 'zustand';
import api from '../api';
const determineEPGStatus = (data, currentEpg) => {
if (data.status) return data.status;
if (data.action === 'downloading') return 'fetching';
if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
if (data.progress === 100) return 'success';
return currentEpg?.status || 'idle';
};
const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({
}
// Create a new refreshProgress object that includes the current update
const newRefreshProgress = {
const refreshProgress = {
...state.refreshProgress,
[data.source]: {
action: data.action,
@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({
// Set the EPG source status based on the update
// First prioritize explicit status values from the backend
const sourceStatus = data.status
? data.status // Use explicit status if provided
: data.action === 'downloading'
? 'fetching'
: data.action === 'parsing_channels' ||
data.action === 'parsing_programs'
? 'parsing'
: data.progress === 100
? 'success' // Mark as success when progress is 100%
: state.epgs[data.source]?.status || 'idle';
const status = determineEPGStatus(data, state.epgs[data.source]);
// Only update epgs object if status or last_message actually changed
// This prevents unnecessary re-renders on every progress update
const currentEpg = state.epgs[data.source];
const newLastMessage =
data.status === 'error'
? data.error || 'Unknown error'
: currentEpg?.last_message;
const lastMessage = data.status === 'error'
? (data.error || 'Unknown error')
: state.epgs[data.source]?.last_message;
let newEpgs = state.epgs;
if (
currentEpg &&
(currentEpg.status !== sourceStatus ||
currentEpg.last_message !== newLastMessage)
) {
newEpgs = {
const currentEpg = state.epgs[data.source];
const shouldUpdateEpg = currentEpg &&
(currentEpg.status !== status || currentEpg.last_message !== lastMessage);
const epgs = shouldUpdateEpg
? {
...state.epgs,
[data.source]: {
...currentEpg,
status: sourceStatus,
last_message: newLastMessage,
status,
last_message: lastMessage,
},
};
}
}
: state.epgs;
return {
refreshProgress: newRefreshProgress,
epgs: newEpgs,
};
return { refreshProgress, epgs };
}),
}));

View file

@ -1,6 +1,12 @@
import { create } from 'zustand';
import api from '../api';
const getLogosArray = (response) => {
return Array.isArray(response)
? response
: response.results || [];
}
const useLogosStore = create((set, get) => ({
logos: {},
channelLogos: {}, // Separate cache for channel forms to avoid reloading
@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({
// Add to channelLogos if the user has loaded channel logos
// This means they're using channel forms and the new logo should be available there
let newChannelLogos = state.channelLogos;
if (state.hasLoadedChannelLogos) {
newChannelLogos = {
...state.channelLogos,
[newLogo.id]: { ...newLogo },
};
}
const newChannelLogos = state.hasLoadedChannelLogos
? {
...state.channelLogos,
[newLogo.id]: { ...newLogo },
}
: state.channelLogos;
return {
logos: newLogos,
@ -67,15 +72,12 @@ const useLogosStore = create((set, get) => ({
// Smart loading methods
fetchLogos: async (pageSize = 100) => {
// Don't fetch if logo fetching is not allowed yet
if (!get().allowLogoFetching) return [];
set({ isLoading: true, error: null });
try {
const response = await api.getLogos({ page_size: pageSize });
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
const logos = getLogosArray(response);
set({
logos: logos.reduce((acc, logo) => {
@ -109,9 +111,7 @@ const useLogosStore = create((set, get) => ({
const response = await api.getLogos({ no_pagination: 'true' });
// Handle both paginated and non-paginated responses
const logosArray = Array.isArray(response)
? response
: response.results || [];
const logosArray = getLogosArray(response);
set({
logos: logosArray.reduce((acc, logo) => {
@ -139,7 +139,7 @@ const useLogosStore = create((set, get) => ({
});
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
const logos = getLogosArray(response);
set((state) => ({
logos: {
@ -190,7 +190,7 @@ const useLogosStore = create((set, get) => ({
const response = await api.getLogosByIds(missingIds);
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
const logos = getLogosArray(response);
set((state) => ({
logos: {
@ -262,9 +262,7 @@ const useLogosStore = create((set, get) => ({
try {
// Use the API directly to avoid interfering with the main isLoading state
const response = await api.getLogos({ no_pagination: 'true' });
const logosArray = Array.isArray(response)
? response
: response.results || [];
const logosArray = getLogosArray(response);
// Process logos in smaller chunks to avoid blocking the main thread
const chunkSize = 1000;

View file

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

View 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;

View file

@ -1,6 +1,124 @@
import { create } from 'zustand';
import api from '../api';
const getFetchContentParams = (state) => {
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
return params;
}
const getMovieDetails = (response, movieId) => {
return {
id: response.id || movieId,
name: response.name || '',
description: response.description || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.url || '',
logo: response.logo_url || null,
type: 'movie',
director: response.director || '',
actors: response.actors || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
}
const getMovieDetailsWithProvider = (response, movieId) => {
return {
id: response.id || movieId,
name: response.name || '',
description: response.description || response.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.stream_url || '',
logo: response.logo || response.cover || null,
type: 'movie',
director: response.director || '',
actors: response.actors || response.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
youtube_trailer: response.youtube_trailer || '',
// Additional provider fields
backdrop_path: response.backdrop_path || [],
release_date: response.release_date || response.releasedate || '',
movie_image: response.movie_image || null,
o_name: response.o_name || '',
age: response.age || '',
episode_run_time: response.episode_run_time || null,
bitrate: response.bitrate || 0,
video: response.video || {},
audio: response.audio || {},
};
}
const getSeriesDetails = (response, seriesId) => {
return {
id: response.id || seriesId,
name: response.name || '',
description: response.description || response.custom_properties?.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
logo: response.cover || null,
type: 'series',
director: response.custom_properties?.director || '',
cast: response.custom_properties?.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
episode_count: response.episode_count || 0,
// Additional provider fields
backdrop_path: response.custom_properties?.backdrop_path || [],
release_date: response.release_date || '',
series_image: response.series_image || null,
o_name: response.o_name || '',
age: response.age || '',
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
}
const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
return {
id: episode.id,
stream_id: episode.id,
name: episode.title || '',
description: episode.plot || '',
season_number: parseInt(seasonNumber) || 0,
episode_number: episode.episode_number || 0,
duration_secs: episode.duration_secs || null,
rating: episode.rating || '',
container_extension: episode.container_extension || '',
series: {
id: seriesInfo.id,
name: seriesInfo.name,
},
type: 'episode',
uuid: episode.uuid,
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
}
const useVODStore = create((set, get) => ({
content: {}, // Store for individual content details (when fetching movie/series details)
currentPageContent: [], // Store the current page's results
@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({
set({ loading: true, error: null });
const state = get();
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
const params = getFetchContentParams(state);
let allResults = [];
let totalCount = 0;
@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({
// Fetch only movies
const response = await api.getMovies(params);
const results = response.results || response;
allResults = results.map((item) => ({ ...item, contentType: 'movie' }));
totalCount = response.count || results.length;
} else if (state.filters.type === 'series') {
// Fetch only series
const response = await api.getSeries(params);
const results = response.results || response;
allResults = results.map((item) => ({
...item,
contentType: 'series',
@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({
// Use the new unified backend endpoint for 'all' view
const response = await api.getAllContent(params);
console.log('getAllContent response:', response);
console.log('response type:', typeof response);
console.log(
'response keys:',
response ? Object.keys(response) : 'no response'
);
const results = response.results || response;
console.log('results:', results);
console.log('results type:', typeof results);
console.log('results is array:', Array.isArray(results));
// Check if results is actually an array before calling map
if (!Array.isArray(results)) {
@ -110,54 +213,13 @@ const useVODStore = create((set, get) => ({
}
},
fetchSeriesEpisodes: async (seriesId) => {
set({ loading: true, error: null });
try {
const response = await api.getSeriesEpisodes(seriesId);
set((state) => ({
episodes: {
...state.episodes,
...response.reduce((acc, episode) => {
acc[episode.id] = episode;
return acc;
}, {}),
},
loading: false,
}));
return response;
} catch (error) {
console.error('Failed to fetch series episodes:', error);
set({ error: 'Failed to load episodes.', loading: false });
throw error; // Re-throw to allow calling component to handle
}
},
fetchMovieDetails: async (movieId) => {
set({ loading: true, error: null });
try {
const response = await api.getMovieDetails(movieId);
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.url || '',
logo: response.logo_url || null,
type: 'movie',
director: response.director || '',
actors: response.actors || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
const movieDetails = getMovieDetails(response, movieId);
console.log('Fetched Movie Details:', movieDetails);
set((state) => ({
content: {
@ -184,33 +246,7 @@ const useVODStore = create((set, get) => ({
const response = await api.getMovieProviderInfo(movieId);
// Transform the response data to match our expected format
const movieDetails = {
id: response.id || movieId,
name: response.name || '',
description: response.description || response.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
duration_secs: response.duration_secs || null,
stream_url: response.stream_url || '',
logo: response.logo || response.cover || null,
type: 'movie',
director: response.director || '',
actors: response.actors || response.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
youtube_trailer: response.youtube_trailer || '',
// Additional provider fields
backdrop_path: response.backdrop_path || [],
release_date: response.release_date || response.releasedate || '',
movie_image: response.movie_image || null,
o_name: response.o_name || '',
age: response.age || '',
episode_run_time: response.episode_run_time || null,
bitrate: response.bitrate || 0,
video: response.video || {},
audio: response.audio || {},
};
const movieDetails = getMovieDetailsWithProvider(response, movieId);
set({ loading: false }); // Only update loading state
@ -316,31 +352,7 @@ const useVODStore = create((set, get) => ({
const response = await api.getSeriesInfo(seriesId);
// Transform the response data to match our expected format
const seriesInfo = {
id: response.id || seriesId,
name: response.name || '',
description:
response.description || response.custom_properties?.plot || '',
year: response.year || null,
genre: response.genre || '',
rating: response.rating || '',
logo: response.cover || null,
type: 'series',
director: response.custom_properties?.director || '',
cast: response.custom_properties?.cast || '',
country: response.country || '',
tmdb_id: response.tmdb_id || '',
imdb_id: response.imdb_id || '',
episode_count: response.episode_count || 0,
// Additional provider fields
backdrop_path: response.custom_properties?.backdrop_path || [],
release_date: response.release_date || '',
series_image: response.series_image || null,
o_name: response.o_name || '',
age: response.age || '',
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
const seriesInfo = getSeriesDetails(response, seriesId);
let episodesData = {};
@ -349,29 +361,11 @@ const useVODStore = create((set, get) => ({
Object.entries(response.episodes).forEach(
([seasonNumber, seasonEpisodes]) => {
seasonEpisodes.forEach((episode) => {
const episodeData = {
id: episode.id,
stream_id: episode.id,
name: episode.title || '',
description: episode.plot || '',
season_number: parseInt(seasonNumber) || 0,
episode_number: episode.episode_number || 0,
duration_secs: episode.duration_secs || null,
rating: episode.rating || '',
container_extension: episode.container_extension || '',
series: {
id: seriesInfo.id,
name: seriesInfo.name,
},
type: 'episode',
uuid: episode.uuid,
logo: episode.movie_image ? { url: episode.movie_image } : null,
air_date: episode.air_date || null,
movie_image: episode.movie_image || null,
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
episodesData[episode.id] = episodeData;
episodesData[episode.id] = getEpisodeDetails(
episode,
seasonNumber,
seriesInfo
);
});
}
);

View file

@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({
currentPage: 1,
pageSize: 25,
_removeLogosFromState: (logoIds) => {
set((state) => {
const newVODLogos = { ...state.vodLogos };
const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]);
let removedCount = 0;
logoIdSet.forEach((id) => {
if (newVODLogos[id]) {
delete newVODLogos[id];
removedCount++;
}
});
const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
return {
vodLogos: newVODLogos,
logos: newLogos,
totalCount: Math.max(0, state.totalCount - removedCount),
};
});
},
setVODLogos: (logos, totalCount = 0) => {
set({
vodLogos: logos.reduce((acc, logo) => {
@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({
});
},
removeVODLogo: (logoId) =>
set((state) => {
const newVODLogos = { ...state.vodLogos };
delete newVODLogos[logoId];
return {
vodLogos: newVODLogos,
totalCount: Math.max(0, state.totalCount - 1),
};
}),
removeVODLogo: (logoId) => {
const state = useVODLogosStore.getState();
state._removeLogosFromState(logoId);
},
fetchVODLogos: async (params = {}) => {
set({ isLoading: true, error: null });
@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({
deleteVODLogo: async (logoId) => {
try {
await api.deleteVODLogo(logoId);
set((state) => {
const newVODLogos = { ...state.vodLogos };
delete newVODLogos[logoId];
const newLogos = state.logos.filter((logo) => logo.id !== logoId);
return {
vodLogos: newVODLogos,
logos: newLogos,
totalCount: Math.max(0, state.totalCount - 1),
};
});
const state = useVODLogosStore.getState();
state._removeLogosFromState(logoId);
} catch (error) {
console.error('Failed to delete VOD logo:', error);
throw error;
@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({
deleteVODLogos: async (logoIds) => {
try {
await api.deleteVODLogos(logoIds);
set((state) => {
const newVODLogos = { ...state.vodLogos };
logoIds.forEach((id) => delete newVODLogos[id]);
const logoIdSet = new Set(logoIds);
const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
return {
vodLogos: newVODLogos,
logos: newLogos,
totalCount: Math.max(0, state.totalCount - logoIds.length),
};
});
const state = useVODLogosStore.getState();
state._removeLogosFromState(logoIds);
} catch (error) {
console.error('Failed to delete VOD logos:', error);
throw error;
@ -116,6 +117,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: {},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

@ -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',

Some files were not shown because too many files have changed in this diff Show more