From a170663407b688507e17ace3e9d809d53cf6d626 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 4 Feb 2026 21:09:35 -0600 Subject: [PATCH 001/154] feat: Add sorting by Group and EPG columns to Channels and Streams tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Backend: Add epg_data__name to ChannelViewSet ordering_fields - ChannelsTable: Add sort icons to EPG and Group column headers using rightSection prop - ChannelsTable: Add field mapping for channel_group → channel_group__name and epg → epg_data__name - StreamsTable: Add sort icon to Group column header for consistency Adds the ability to sort channels by Group and EPG columns, and streams by Group column. Closes #854 --- apps/channels/api_views.py | 2 +- .../src/components/tables/ChannelsTable.jsx | 85 +++++++++++++------ .../src/components/tables/StreamsTable.jsx | 9 ++ 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ad5a270f..b1ea9a45 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -461,7 +461,7 @@ class ChannelViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = ChannelFilter search_fields = ["name", "channel_group__name"] - ordering_fields = ["channel_number", "name", "channel_group__name"] + ordering_fields = ["channel_number", "name", "channel_group__name", "epg_data__name"] ordering = ["-channel_number"] def create(self, request, *args, **kwargs): diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 371cb77f..8cc33904 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -443,7 +443,15 @@ const ChannelsTable = ({ onReady }) => { // Apply sorting if (sorting.length > 0) { - const sortField = sorting[0].id; + let sortField = sorting[0].id; + // Map frontend column ids to backend ordering field names + const fieldMapping = { + channel_group: 'channel_group__name', + epg: 'epg_data__name', + }; + if (fieldMapping[sortField]) { + sortField = fieldMapping[sortField]; + } const sortDirection = sorting[0].desc ? '-' : ''; params.append('ordering', `${sortDirection}${sortField}`); } @@ -931,7 +939,7 @@ const ChannelsTable = ({ onReady }) => { /> ), size: columnSizing.epg || 200, - minSize: 80, + minSize: 120, }, { id: 'channel_group', @@ -942,8 +950,8 @@ const ChannelsTable = ({ onReady }) => { cell: (props) => ( ), - size: columnSizing.channel_group || 175, - minSize: 100, + size: columnSizing.channel_group || 200, + minSize: 120, }, { id: 'logo', @@ -1025,6 +1033,15 @@ const ChannelsTable = ({ onReady }) => { : [] } style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('epg'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); case 'enabled': @@ -1036,11 +1053,16 @@ const ChannelsTable = ({ onReady }) => { case 'channel_number': return ( - + # -
+
{ + e.stopPropagation(); + onSortingChange('channel_number'); + }} + style={{ cursor: 'pointer' }} + > {React.createElement(sortingIcon, { - onClick: () => onSortingChange('channel_number'), size: 14, })}
@@ -1049,25 +1071,27 @@ const ChannelsTable = ({ onReady }) => { case 'name': return ( - - e.stopPropagation()} - onChange={handleFilterChange} - size="xs" - variant="unstyled" - className="table-input-header" - leftSection={} - /> -
- {React.createElement(sortingIcon, { - onClick: () => onSortingChange('name'), - size: 14, - })} -
-
+ e.stopPropagation()} + onChange={handleFilterChange} + size="xs" + variant="unstyled" + className="table-input-header" + leftSection={} + style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('name'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} + /> ); case 'channel_group': @@ -1090,6 +1114,15 @@ const ChannelsTable = ({ onReady }) => { : [] } style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('channel_group'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); } diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index d7c1b059..cd66f74f 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -1102,6 +1102,15 @@ const StreamsTable = ({ onReady }) => { className="table-input-header custom-multiselect" clearable style={{ width: '100%' }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('group'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} /> ); } From 49b7b9e21b0dd896764d9d12abb61983bd7896e9 Mon Sep 17 00:00:00 2001 From: None Date: Thu, 5 Feb 2026 23:12:30 -0600 Subject: [PATCH 002/154] Fix: Connection capacity leak - failed stream initialization permanently consumes connection slot Fixed four leak paths in the TS proxy streaming initialization: 1. url_utils.py - generate_stream_url(): Wrapped post-get_stream() code in try/except that calls release_stream() on any failure 2. views.py - stream_ts() retry loop: Added release_stream() before returning 503 to clean up the error-checking get_stream() call at line 181 which could INCR without a matching release when the retry loop times out 3. views.py - stream_ts() init failure: Added guarded release_stream() when initialize_channel() returns False, using a connection_allocated flag to prevent incorrect decrements when joining an existing channel 4. views.py - stream_ts() outer except: Added guarded release_stream() as a safety net for unexpected exceptions, only releasing when the flag confirms the slot was allocated and the channel hasn't been initialized yet The connection_allocated flag prevents double-decrements by tracking whether request flow actually INCR'd the counter (fresh initialization) vs returned cached results (joining an existing channel on another worker) --- apps/proxy/ts_proxy/url_utils.py | 60 +++++++++++++++++--------------- apps/proxy/ts_proxy/views.py | 23 ++++++++++-- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 8b467b7f..9daaf4ef 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -129,39 +129,41 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] logger.error(f"No stream available for channel {channel_id}: {error_reason}") return None, None, False, None - # Look up the Stream and Profile objects + # get_stream() allocated a connection slot - ensure it's released on any error try: + # Look up the Stream and Profile objects stream = Stream.objects.get(id=stream_id) profile = M3UAccountProfile.objects.get(id=profile_id) - except (Stream.DoesNotExist, M3UAccountProfile.DoesNotExist) as e: - logger.error(f"Error getting stream or profile: {e}") + + # Get the M3U account profile for URL pattern + m3u_profile = profile + + # Get the appropriate user agent + m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) + stream_user_agent = m3u_account.get_user_agent().user_agent + + if stream_user_agent is None: + stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) + logger.debug(f"No user agent found for account, using default: {stream_user_agent}") + + # Generate stream URL based on the selected profile + input_url = stream.url + stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) + + # Check if transcoding is needed + stream_profile = channel.get_stream_profile() + if stream_profile.is_proxy() or stream_profile is None: + transcode = False + else: + transcode = True + + stream_profile_id = stream_profile.id + + return stream_url, stream_user_agent, transcode, stream_profile_id + except Exception as e: + logger.error(f"Error generating stream URL for channel {channel_id}: {e}") + channel.release_stream() return None, None, False, None - - # Get the M3U account profile for URL pattern - m3u_profile = profile - - # Get the appropriate user agent - m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) - stream_user_agent = m3u_account.get_user_agent().user_agent - - if stream_user_agent is None: - stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()) - logger.debug(f"No user agent found for account, using default: {stream_user_agent}") - - # Generate stream URL based on the selected profile - input_url = stream.url - stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) - - # Check if transcoding is needed - stream_profile = channel.get_stream_profile() - if stream_profile.is_proxy() or stream_profile is None: - transcode = False - else: - transcode = True - - stream_profile_id = stream_profile.id - - return stream_url, stream_user_agent, transcode, stream_profile_id except Exception as e: logger.error(f"Error generating stream URL: {e}") return None, None, False, None diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 91f254a7..6980f957 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -54,6 +54,7 @@ def stream_ts(request, channel_id): client_user_agent = None proxy_server = ProxyServer.get_instance() + connection_allocated = False # Track if connection slot was allocated via get_stream() try: # Generate a unique client ID @@ -219,9 +220,9 @@ def stream_ts(request, channel_id): ) if stream_url is None: - # Release the channel's stream lock if one was acquired - # Note: Only call this if get_stream() actually assigned a stream - # In our case, if stream_url is None, no stream was ever assigned, so don't release + # Release any connection slot that may have been allocated + # by the error-checking get_stream() call during retries + channel.release_stream() # Get the specific error message if available wait_duration = f"{int(time.time() - wait_start_time)}s" @@ -237,6 +238,11 @@ def stream_ts(request, channel_id): {"error": error_msg, "waited": wait_duration}, status=503 ) # 503 Service Unavailable is appropriate here + # generate_stream_url() called get_stream() which allocated a connection + # slot (INCR'd profile_connections) - track this for cleanup on error + if needs_initialization: + connection_allocated = True + # Get the stream ID from the channel stream_id, m3u_profile_id, _ = channel.get_stream() logger.info( @@ -344,10 +350,16 @@ def stream_ts(request, channel_id): ) if not success: + if connection_allocated: + channel.release_stream() + connection_allocated = False return JsonResponse( {"error": "Failed to initialize channel"}, status=500 ) + # Channel initialized - cleanup lifecycle now owns the connection release + connection_allocated = False + # If we're the owner, wait for connection to establish if proxy_server.am_i_owner(channel_id): manager = proxy_server.stream_managers.get(channel_id) @@ -507,6 +519,11 @@ def stream_ts(request, channel_id): except Exception as e: logger.error(f"Error in stream_ts: {e}", exc_info=True) + if connection_allocated: + try: + channel.release_stream() + except Exception: + pass return JsonResponse({"error": str(e)}, status=500) From 16309cd9b455daf98fd1bc5af213c83531111ea9 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 10 Feb 2026 18:15:48 -0600 Subject: [PATCH 003/154] Remove if logic from scheduled shutdown - stream_generator.py: _schedule_channel_shutdown_if_needed() already guards itself, the if logic created conflict between release_stream() and stop_channel(), so if the stream was already released the shutdown would not be scheduled --- apps/proxy/ts_proxy/stream_generator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index 50404f1d..771b0aa9 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -472,8 +472,7 @@ class StreamGenerator: logger.error(f"Could not log client disconnect event: {e}") # Schedule channel shutdown if no clients left - if not stream_released: # Only if we haven't already released the stream - self._schedule_channel_shutdown_if_needed(local_clients) + self._schedule_channel_shutdown_if_needed(local_clients) def _schedule_channel_shutdown_if_needed(self, local_clients): """ From 15d67c7f6518cd599823c57d070562da792f23d2 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 10 Feb 2026 18:30:43 -0600 Subject: [PATCH 004/154] Atomize profile counter swap in update_stream_profile to prevent counter drift DECR/SET/INCR during stream profile switches are now executed in a Redis pipeline, preventing partial updates if an exception occurs mid-operation --- apps/channels/models.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index ec5e135b..dd67095c 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -566,18 +566,18 @@ class Channel(models.Model): if current_profile_id == new_profile_id: return True - # Decrement connection count for old profile + # Use pipeline for atomic profile switch to prevent counter drift + # if an exception occurs between DECR and INCR old_profile_connections_key = f"profile_connections:{current_profile_id}" - old_count = int(redis_client.get(old_profile_connections_key) or 0) - if old_count > 0: - redis_client.decr(old_profile_connections_key) - - # Update the profile mapping - redis_client.set(f"stream_profile:{stream_id}", new_profile_id) - - # Increment connection count for new profile new_profile_connections_key = f"profile_connections:{new_profile_id}" - redis_client.incr(new_profile_connections_key) + old_count = int(redis_client.get(old_profile_connections_key) or 0) + + pipe = redis_client.pipeline() + if old_count > 0: + pipe.decr(old_profile_connections_key) + pipe.set(f"stream_profile:{stream_id}", new_profile_id) + pipe.incr(new_profile_connections_key) + pipe.execute() logger.info( f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}" ) From 9e8f227f494eb0d1aa27b1359d8b470221a56e3a Mon Sep 17 00:00:00 2001 From: None Date: Tue, 10 Feb 2026 18:58:17 -0600 Subject: [PATCH 005/154] Fix: VOD proxy double-DECR of profile connection counter during cleanup - multi_worker_connection_manager.py: Error handler at line 1042: removed connection_manager=self from cleanup() call since the DECR was already done at line 1036. Prevents double-DECR. - connection_manager.py: Added connection_key_exists check before calling remove_connection(). The fallback DECR from session data now only fires when the connection tracking key has already expired. Prevents double-DECR. --- apps/proxy/vod_proxy/connection_manager.py | 22 +++++++++++++------ .../multi_worker_connection_manager.py | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index aafc75cd..42d69a11 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -1363,17 +1363,25 @@ class VODConnectionManager: client_id = session_id # Remove individual connection tracking keys created during streaming + # Check if connection key exists first - remove_connection() + # handles the profile DECR when the key exists, but skips it + # if the key already expired by TTL + connection_key_exists = False if content_type and content_uuid: + connection_key = self._get_connection_key(content_type, content_uuid, client_id) + connection_key_exists = bool(self.redis_client.exists(connection_key)) logger.info(f"[{session_id}] Cleaning up connection tracking keys") self.remove_connection(content_type, content_uuid, client_id) - # Remove from profile connections if counted (additional safety check) - if session_data.get(b'connection_counted') == b'True' and profile_id: - profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8'))) - current_count = int(self.redis_client.get(profile_key) or 0) - if current_count > 0: - self.redis_client.decr(profile_key) - logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections") + # Fallback DECR: only if the connection tracking key had already + # expired (remove_connection skips DECR in that case) + if not connection_key_exists: + if session_data.get(b'connection_counted') == b'True' and profile_id: + profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8'))) + current_count = int(self.redis_client.get(profile_key) or 0) + if current_count > 0: + self.redis_client.decr(profile_key) + logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections (fallback)") # Remove session tracking key self.redis_client.delete(session_key) diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 1534f761..58d6bd04 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -1036,9 +1036,10 @@ class MultiWorkerVODConnectionManager: self._decrement_profile_connections(m3u_profile.id) # Also clean up the Redis connection state since we won't be using it + # Pass connection_manager=None since we already decremented above if redis_connection: try: - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + redis_connection.cleanup(current_worker_id=self.worker_id) except Exception as cleanup_error: logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}") From cf96287c90a32120ad22aa7a17973ddae96a627a Mon Sep 17 00:00:00 2001 From: None Date: Tue, 10 Feb 2026 19:06:51 -0600 Subject: [PATCH 006/154] Add guard to remove_connection() - multi_worker_connection_manager.py: Add missing count > 0 guard to multi-worker remove_connection() This method doesn't appear to be called anywhere but added the guard in case it is put back into service --- apps/proxy/vod_proxy/multi_worker_connection_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 58d6bd04..63eeae00 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -1263,12 +1263,14 @@ class MultiWorkerVODConnectionManager: profile_id = connection_data.get('m3u_profile_id') if profile_id: profile_connections_key = f"profile_connections:{profile_id}" + current_count = int(self.redis_client.get(profile_connections_key) or 0) # Use pipeline for atomic operations pipe = self.redis_client.pipeline() pipe.delete(connection_key) pipe.srem(content_connections_key, client_id) - pipe.decr(profile_connections_key) + if current_count > 0: + pipe.decr(profile_connections_key) pipe.execute() logger.info(f"Removed Redis-backed connection: {client_id}") From 5a4b51b60a90d64d11a56df273607cc0be6ac72d Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 18:08:24 -0600 Subject: [PATCH 007/154] Fix TOCTOU race condition in Channel.get_stream() with atomic slot reservation Add _check_and_reserve_profile_slot() method that uses an INCR-first-then-check pattern to atomically reserve connection slots. This eliminates the TOCTOU race condition where separate GET > check > INCR operations allowed concurrent requests to both pass the capacity check, exceeding max_streams. For profiles with max_streams=0 (unlimited), INCR is skipped entirely. If over capacity, the increment is rolled back with DECR. Based on PR #838 by patchy8736, adapted from Lua script to native Redis commands for consistency with project style and easier debugging. --- apps/channels/models.py | 53 ++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index dd67095c..50538238 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -391,6 +391,38 @@ class Channel(models.Model): return stream_profile + def _check_and_reserve_profile_slot(self, profile, redis_client): + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET + check + INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Args: + profile: M3UAccountProfile instance + redis_client: Redis client instance + + Returns: + tuple: (reserved: bool, current_count: int) + """ + if profile.max_streams == 0: + return (True, 0) + + profile_connections_key = f"profile_connections:{profile.id}" + + # Atomically increment first — this is a single Redis command + new_count = redis_client.incr(profile_connections_key) + + if new_count <= profile.max_streams: + return (True, new_count) + + # Over capacity — roll back the increment + redis_client.decr(profile_connections_key) + return (False, new_count - 1) + def get_stream(self): """ Finds an available stream for the requested channel and returns the selected stream and profile. @@ -456,24 +488,16 @@ class Channel(models.Model): for profile in profiles: has_active_profiles = True - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int( - redis_client.get(profile_connections_key) or 0 + # Atomically check and reserve a slot (INCR-first pattern) + reserved, current_count = self._check_and_reserve_profile_slot( + profile, redis_client ) - # Check if profile has available slots (or unlimited connections) - if ( - profile.max_streams == 0 - or current_connections < profile.max_streams - ): - # Start a new stream + if reserved: + # Slot reserved — assign stream to this channel redis_client.set(f"channel_stream:{self.id}", stream.id) redis_client.set(f"stream_profile:{stream.id}", profile.id) - # Increment connection count for profiles with limits - if profile.max_streams > 0: - redis_client.incr(profile_connections_key) - return ( stream.id, profile.id, @@ -483,7 +507,8 @@ class Channel(models.Model): # This profile is at max connections has_streams_but_maxed_out = True logger.debug( - f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}" + f"Profile {profile.id} at max connections: " + f"{current_count}/{profile.max_streams}" ) # No available streams - determine specific reason From 33f68a9808a1a29eed16afb6d85ab8b04ff58c85 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 19:11:28 -0600 Subject: [PATCH 008/154] Remove unused Stream.get_stream() with TOCTOU vulnerability Remove the Stream.get_stream() method which had the same TOCTOU race condition as Channel.get_stream() All stream acquisition goes through Channel.get_stream(), making this dead code. Removing it eliminates a potential source of connection capacity leaks Based on PR #838 by patchy8736 --- apps/channels/models.py | 48 ----------------------------------------- 1 file changed, 48 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 50538238..998b7aca 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -202,54 +202,6 @@ class Stream(models.Model): return stream_profile - def get_stream(self): - """ - Finds an available stream for the requested channel and returns the selected stream and profile. - """ - redis_client = RedisClient.get_client() - profile_id = redis_client.get(f"stream_profile:{self.id}") - if profile_id: - profile_id = int(profile_id) - return self.id, profile_id, None - - # Retrieve the M3U account associated with the stream. - m3u_account = self.m3u_account - m3u_profiles = m3u_account.profiles.all() - default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) - profiles = [default_profile] + [ - obj for obj in m3u_profiles if not obj.is_default - ] - - for profile in profiles: - logger.info(profile) - # Skip inactive profiles - if profile.is_active == False: - continue - - profile_connections_key = f"profile_connections:{profile.id}" - current_connections = int(redis_client.get(profile_connections_key) or 0) - - # Check if profile has available slots (or unlimited connections) - if profile.max_streams == 0 or current_connections < profile.max_streams: - # Start a new stream - redis_client.set(f"channel_stream:{self.id}", self.id) - redis_client.set( - f"stream_profile:{self.id}", profile.id - ) # Store only the matched profile - - # Increment connection count for profiles with limits - if profile.max_streams > 0: - redis_client.incr(profile_connections_key) - - return ( - self.id, - profile.id, - None, - ) # Return newly assigned stream and matched profile - - # 4. No available streams - return None, None, None - def release_stream(self): """ Called when a stream is finished to release the lock. From 56719d93fe53445226a8c2dd812e9a23287f2ced Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 19:16:01 -0600 Subject: [PATCH 009/154] Add boolean return and metadata fallback to Channel.release_stream() Channel.release_stream() now returns True on success and False when no stream/profile info could be found for cleanup. This allows callers to detect and handle failed releases. When the primary Redis keys (channel_stream/stream_profile) are already cleaned up by the proxy, the method falls back to the channel metadata hash (ts_proxy:channel:{uuid}:metadata) to recover stream_id and profile_id for proper connection counter cleanup. Uses str(self.uuid) for metadata key lookup - fixes a bug in PR #838 which used self.id (integer PK), but metadata is keyed by UUID. Enhanced logging now includes channel UUID for better traceability. Based on PR #838 by patchy8736 --- apps/channels/models.py | 58 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 998b7aca..9e466cc7 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -3,6 +3,8 @@ from django.core.exceptions import ValidationError from django.conf import settings from core.models import StreamProfile, CoreSettings from core.utils import RedisClient +from apps.proxy.ts_proxy.redis_keys import RedisKeys +from apps.proxy.ts_proxy.constants import ChannelMetadataField import logging import uuid from datetime import datetime @@ -476,32 +478,74 @@ class Channel(models.Model): def release_stream(self): """ Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no stream/profile info could be found for cleanup. """ redis_client = RedisClient.get_client() stream_id = redis_client.get(f"channel_stream:{self.id}") if not stream_id: - logger.debug("Invalid stream ID pulled from channel index") - return + # Primary key missing — try metadata hash fallback. + # The proxy may have already cleaned up channel_stream/stream_profile + # keys, but the metadata hash can still have the stream_id and profile. + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_stream_id = redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_ID + ) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + + if meta_stream_id and meta_profile_id: + stream_id = int(meta_stream_id) + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered stream_id={stream_id}, " + f"profile_id={profile_id} from metadata fallback" + ) + # Clean up any remaining keys + redis_client.delete(f"channel_stream:{self.id}") + redis_client.delete(f"stream_profile:{stream_id}") + + profile_connections_key = f"profile_connections:{profile_id}" + current_count = int( + redis_client.get(profile_connections_key) or 0 + ) + if current_count > 0: + redis_client.decr(profile_connections_key) + return True + + logger.debug( + f"Channel {self.uuid}: no stream info found in primary keys " + f"or metadata fallback" + ) + return False redis_client.delete(f"channel_stream:{self.id}") # Remove active stream stream_id = int(stream_id) logger.debug( - f"Found stream ID {stream_id} associated with channel stream {self.id}" + f"Channel {self.uuid}: found stream_id={stream_id} for " + f"channel_stream:{self.id}" ) # Get the matched profile for cleanup profile_id = redis_client.get(f"stream_profile:{stream_id}") if not profile_id: - logger.debug("Invalid profile ID pulled from stream index") - return + logger.debug( + f"Channel {self.uuid}: no profile found for " + f"stream_profile:{stream_id}" + ) + return False redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association profile_id = int(profile_id) logger.debug( - f"Found profile ID {profile_id} associated with stream {stream_id}" + f"Channel {self.uuid}: found profile_id={profile_id} for " + f"stream {stream_id}" ) profile_connections_key = f"profile_connections:{profile_id}" @@ -511,6 +555,8 @@ class Channel(models.Model): if current_count > 0: redis_client.decr(profile_connections_key) + return True + def update_stream_profile(self, new_profile_id): """ Updates the profile for the current stream and adjusts connection counts. From 6826e94921b0d7fa49a032d161059c014ce2ddde Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 19:27:45 -0600 Subject: [PATCH 010/154] Add boolean return to Stream.release_stream() Stream.release_stream() now returns True on success and False when no profile info could be found in stream_profile:{stream_id}. This allows callers to detect and handle failed releases. Enhanced logging to include stream ID for better traceability. Based on PR #838 by patchy8736 --- apps/channels/models.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 9e466cc7..b6cb6ab0 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -207,6 +207,10 @@ class Stream(models.Model): def release_stream(self): """ Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no profile info could be found for cleanup. """ redis_client = RedisClient.get_client() @@ -214,14 +218,17 @@ class Stream(models.Model): # Get the matched profile for cleanup profile_id = redis_client.get(f"stream_profile:{stream_id}") if not profile_id: - logger.debug("Invalid profile ID pulled from stream index") - return + logger.debug( + f"Stream {stream_id}: no profile found in " + f"stream_profile:{stream_id}" + ) + return False redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association profile_id = int(profile_id) logger.debug( - f"Found profile ID {profile_id} associated with stream {stream_id}" + f"Stream {stream_id}: found profile_id={profile_id}" ) profile_connections_key = f"profile_connections:{profile_id}" @@ -231,6 +238,8 @@ class Stream(models.Model): if current_count > 0: redis_client.decr(profile_connections_key) + return True + class ChannelManager(models.Manager): def active(self): From fe17d0d5850c0b58e2cea150d97202e8e713afdb Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 19:49:11 -0600 Subject: [PATCH 011/154] Check release_stream() boolean return at all call sites All 12 call sites for Channel.release_stream() and Stream.release_stream() now check the boolean return value and log warnings when release fails. Files updated: - server.py: 4 call sites (zombie cleanup + _clean_redis_keys) - channel_service.py: 2 call sites (stop_channel) - stream_generator.py: 1 call site (last client disconnect) - views.py: 4 call sites (failed init, redirect, init failure, exception) - url_utils.py: 1 call site (URL generation error) Also fixed bare except clause in _clean_redis_keys and uncaught Stream.DoesNotExist in channel_service.stop_channel. Based on PR #838 by patchy8736 --- apps/proxy/ts_proxy/server.py | 25 +++++++++++++------ .../ts_proxy/services/channel_service.py | 22 ++++++++++------ apps/proxy/ts_proxy/stream_generator.py | 8 +++--- apps/proxy/ts_proxy/url_utils.py | 3 ++- apps/proxy/ts_proxy/views.py | 12 ++++++--- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index df51744d..feef4957 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -788,13 +788,17 @@ class ProxyServer: # Force release resources in the Channel model try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - logger.info(f"Released stream allocation for zombie channel {channel_id}") + if not channel.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: try: stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() - logger.info(f"Released stream allocation for zombie channel {channel_id}") + if not stream.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") @@ -1339,10 +1343,15 @@ class ProxyServer: # Release the channel, stream, and profile keys from the channel try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - except: - stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() + if not channel.release_stream(): + logger.debug(f"Channel {channel_id}: release_stream found no keys to clean") + except Channel.DoesNotExist: + try: + stream = Stream.objects.get(stream_hash=channel_id) + if not stream.release_stream(): + logger.debug(f"Stream {channel_id}: release_stream found no keys to clean") + except Stream.DoesNotExist: + logger.debug(f"No Channel or Stream found for {channel_id}") if not self.redis_client: return 0 diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 4c4a73ac..efe07c7e 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -265,15 +265,23 @@ class ChannelService: # Release the channel in the channel model if applicable try: channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() - logger.info(f"Released channel {channel_id} stream allocation") - model_released = True + model_released = channel.release_stream() + if model_released: + logger.info(f"Released channel {channel_id} stream allocation") + else: + logger.warning(f"Channel {channel_id}: release_stream found no keys to clean") except Channel.DoesNotExist: logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") - stream = Stream.objects.get(stream_hash=channel_id) - stream.release_stream() - logger.info(f"Released stream {channel_id} stream allocation") - model_released = True + try: + stream = Stream.objects.get(stream_hash=channel_id) + model_released = stream.release_stream() + if model_released: + logger.info(f"Released stream {channel_id} stream allocation") + else: + logger.warning(f"Stream {channel_id}: release_stream found no keys to clean") + except Stream.DoesNotExist: + logger.error(f"No Channel or Stream found for {channel_id}") + model_released = False except Exception as e: logger.error(f"Error releasing channel stream: {e}") model_released = False diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index 771b0aa9..3c2a66fe 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -441,9 +441,11 @@ class StreamGenerator: try: # Get the channel by UUID channel = Channel.objects.get(uuid=self.channel_id) - channel.release_stream() - stream_released = True - logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") + stream_released = channel.release_stream() + if stream_released: + logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") + else: + logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}") except Exception as e: logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}") except Exception as e: diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 9daaf4ef..fe06ad48 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -162,7 +162,8 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]] return stream_url, stream_user_agent, transcode, stream_profile_id except Exception as e: logger.error(f"Error generating stream URL for channel {channel_id}: {e}") - channel.release_stream() + if not channel.release_stream(): + logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") return None, None, False, None except Exception as e: logger.error(f"Error generating stream URL: {e}") diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 6980f957..5060a3a7 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -222,7 +222,8 @@ def stream_ts(request, channel_id): if stream_url is None: # Release any connection slot that may have been allocated # by the error-checking get_stream() call during retries - channel.release_stream() + if not channel.release_stream(): + logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup") # Get the specific error message if available wait_duration = f"{int(time.time() - wait_start_time)}s" @@ -314,7 +315,8 @@ def stream_ts(request, channel_id): f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}" ) # Release stream lock before redirecting - channel.release_stream() + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream before redirect") # Final decision based on validation results if is_valid: logger.info( @@ -351,7 +353,8 @@ def stream_ts(request, channel_id): if not success: if connection_allocated: - channel.release_stream() + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream after init failure") connection_allocated = False return JsonResponse( {"error": "Failed to initialize channel"}, status=500 @@ -521,7 +524,8 @@ def stream_ts(request, channel_id): logger.error(f"Error in stream_ts: {e}", exc_info=True) if connection_allocated: try: - channel.release_stream() + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream in exception handler") except Exception: pass return JsonResponse({"error": str(e)}, status=500) From 6360271e2830831fbfca9bf5c938e29dd25d7ef5 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 20:09:17 -0600 Subject: [PATCH 012/154] Fix unprotected _clean_redis_keys() call in check_if_channel_exists Wrap _clean_redis_keys() call in check_if_channel_exists() with try/except to prevent exception propagation when Channel/Stream lookup or release_stream() encounters DB or Redis errors. --- apps/proxy/ts_proxy/server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index feef4957..3a6e8ae3 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -766,7 +766,10 @@ class ProxyServer: if self.redis_client.exists(key): # Found orphaned keys without metadata - clean them up logger.warning(f"Found orphaned keys for channel {channel_id} without metadata - cleaning up") - self._clean_redis_keys(channel_id) + try: + self._clean_redis_keys(channel_id) + except Exception as e: + logger.error(f"Error cleaning redis keys for channel {channel_id}: {e}") return False return False From 8bb8a0fca904ccfc3c554ac6702e88ab94d3f094 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 20:52:30 -0600 Subject: [PATCH 013/154] fix(ts-proxy): Remove redundant local import causing UnboundLocalError The local `from apps.channels.models import Channel` inside a conditional block in _handle_client_disconnect() shadowed the top-level import. When the condition was False, Python's function-level scoping caused line 462's Channel.objects.get() to raise UnboundLocalError. Channel is already imported at module level (line 11), making the local import unnecessary. Discovered when testing proxy changes --- apps/proxy/ts_proxy/stream_generator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index 3c2a66fe..875d9d0e 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -437,7 +437,6 @@ class StreamGenerator: client_count = proxy_server.client_managers[self.channel_id].get_total_client_count() # Only the last client or owner should release the stream if client_count <= 1 and proxy_server.am_i_owner(self.channel_id): - from apps.channels.models import Channel try: # Get the channel by UUID channel = Channel.objects.get(uuid=self.channel_id) From 837ba847685bb93f6d9351720e68c3ca19c6d04e Mon Sep 17 00:00:00 2001 From: None Date: Wed, 11 Feb 2026 23:57:10 -0600 Subject: [PATCH 014/154] fix(ts-proxy): Restore Stream.get_stream() with atomic slot reservation Stream.get_stream() was incorrectly removed as dead code in commit 33f68a98. It is actually called get_stream_object() in views.py when a stream hash (not channel UUID) is used, such as during stream preview. The variable is named channel but holds a Stream instance, which masked the dependency during analysis. Restored with the INCR-first-then-check pattern applied, fixing the same TOCTOU vulnerability the original method had. --- apps/channels/models.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/channels/models.py b/apps/channels/models.py index b6cb6ab0..b9c1dde1 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -204,6 +204,52 @@ class Stream(models.Model): return stream_profile + def get_stream(self): + """ + Finds an available profile for this stream and reserves a connection slot. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + """ + redis_client = RedisClient.get_client() + profile_id = redis_client.get(f"stream_profile:{self.id}") + if profile_id: + profile_id = int(profile_id) + return self.id, profile_id, None + + # Retrieve the M3U account associated with the stream. + m3u_account = self.m3u_account + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [default_profile] + [ + obj for obj in m3u_profiles if not obj.is_default + ] + + for profile in profiles: + logger.info(profile) + # Skip inactive profiles + if profile.is_active == False: + continue + + # Atomic slot reservation: INCR first, check, rollback if over capacity + if profile.max_streams == 0: + reserved = True + else: + profile_connections_key = f"profile_connections:{profile.id}" + new_count = redis_client.incr(profile_connections_key) + if new_count <= profile.max_streams: + reserved = True + else: + redis_client.decr(profile_connections_key) + reserved = False + + if reserved: + redis_client.set(f"channel_stream:{self.id}", self.id) + redis_client.set(f"stream_profile:{self.id}", profile.id) + return self.id, profile.id, None + + return None, None, "All active M3U profiles have reached maximum connection limits" + def release_stream(self): """ Called when a stream is finished to release the lock. From 20ce45f7adfd7a0f959207f09125accec2e93806 Mon Sep 17 00:00:00 2001 From: None Date: Thu, 12 Feb 2026 00:02:40 -0600 Subject: [PATCH 015/154] fix(ts-proxy): Add Stream fallback in cleanup path to prevent counter leak on stream preview When previewing a stream directly (via stream hash), the cleanup in stream_generator.py tried Channel.objects.get(uuid=stream_hash) which failed with ValidationError since the hash is not a UUID. This prevented release_stream() from running, permanently leaking the connection counter. With max_streams=1, a single preview permanently blocked all subsequent streams. Added Stream.objects.get(stream_hash=...) fallback so the cleanup path works for both Channel (UUID) and Stream (hash) identifiers. Also added Stream import to stream_generator.py. --- apps/proxy/ts_proxy/stream_generator.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index 875d9d0e..f43eb528 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -8,7 +8,7 @@ import logging import threading import gevent # Add this import at the top of your file from apps.proxy.config import TSConfig as Config -from apps.channels.models import Channel +from apps.channels.models import Channel, Stream from core.utils import log_system_event from .server import ProxyServer from .utils import create_ts_packet, get_logger @@ -438,9 +438,12 @@ class StreamGenerator: # Only the last client or owner should release the stream if client_count <= 1 and proxy_server.am_i_owner(self.channel_id): try: - # Get the channel by UUID - channel = Channel.objects.get(uuid=self.channel_id) - stream_released = channel.release_stream() + # Try Channel first (normal flow), fall back to Stream (preview flow) + try: + obj = Channel.objects.get(uuid=self.channel_id) + except (Channel.DoesNotExist, Exception): + obj = Stream.objects.get(stream_hash=self.channel_id) + stream_released = obj.release_stream() if stream_released: logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") else: From 51b58247378a63de9adfa0dd075adb85f8cb5e44 Mon Sep 17 00:00:00 2001 From: None Date: Thu, 12 Feb 2026 00:09:29 -0600 Subject: [PATCH 016/154] Fix cleanup paths for stream preview Fix exception handling in _clean_redis_keys() and stop_channel() where `except Channel.DoesNotExist` didn't catch ValidationError from invalid UUID format, bypassing the Stream fallback path. --- apps/proxy/ts_proxy/server.py | 4 ++-- apps/proxy/ts_proxy/services/channel_service.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index 3a6e8ae3..d036bc2e 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -1348,12 +1348,12 @@ class ProxyServer: channel = Channel.objects.get(uuid=channel_id) if not channel.release_stream(): logger.debug(f"Channel {channel_id}: release_stream found no keys to clean") - except Channel.DoesNotExist: + except (Channel.DoesNotExist, Exception): try: stream = Stream.objects.get(stream_hash=channel_id) if not stream.release_stream(): logger.debug(f"Stream {channel_id}: release_stream found no keys to clean") - except Stream.DoesNotExist: + except (Stream.DoesNotExist, Exception): logger.debug(f"No Channel or Stream found for {channel_id}") if not self.redis_client: diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index efe07c7e..e765ece3 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -270,7 +270,7 @@ class ChannelService: logger.info(f"Released channel {channel_id} stream allocation") else: logger.warning(f"Channel {channel_id}: release_stream found no keys to clean") - except Channel.DoesNotExist: + except (Channel.DoesNotExist, Exception): logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") try: stream = Stream.objects.get(stream_hash=channel_id) @@ -279,12 +279,9 @@ class ChannelService: logger.info(f"Released stream {channel_id} stream allocation") else: logger.warning(f"Stream {channel_id}: release_stream found no keys to clean") - except Stream.DoesNotExist: - logger.error(f"No Channel or Stream found for {channel_id}") + except (Stream.DoesNotExist, Exception) as e: + logger.error(f"No Channel or Stream found for {channel_id}: {e}") model_released = False - except Exception as e: - logger.error(f"Error releasing channel stream: {e}") - model_released = False return { 'status': 'success', From c69718b55987362403d468913dbe6cd7dd040954 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 17 Feb 2026 21:18:20 -0600 Subject: [PATCH 017/154] Fix: Make release_stream() idempotent to prevent double-DECR on rapid channel switching Multiple code paths call release_stream() during channel shutdown (stream generator cleanup, _clean_redis_keys, ChannelService.stop_channel), causing the profile_connections counter to be decremented multiple times for the same stream. The metadata hash fallback persisted longer than the primary keys, allowing subsequent calls to find stale stream_id/m3u_profile and DECR again. Changes: - Clear STREAM_ID and M3U_PROFILE metadata fields after decrementing in both the primary and fallback paths, making release_stream() safe to call multiple times - Add metadata fallback when stream_profile:{id} key is missing but channel_stream:{id} exists, preventing a counter leak in that edge case - Replace redundant get_stream() call in views.py with direct Redis reads to avoid side-effect INCR from a method that can allocate connection slots --- apps/channels/models.py | 47 +++++++++++++++++++++++++++++------- apps/proxy/ts_proxy/views.py | 14 +++++++++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index b9c1dde1..217a92c0 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -564,6 +564,14 @@ class Channel(models.Model): redis_client.delete(f"channel_stream:{self.id}") redis_client.delete(f"stream_profile:{stream_id}") + # Clear metadata fields so duplicate release_stream() calls + # won't find them and DECR again + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + profile_connections_key = f"profile_connections:{profile_id}" current_count = int( redis_client.get(profile_connections_key) or 0 @@ -588,16 +596,27 @@ class Channel(models.Model): # Get the matched profile for cleanup profile_id = redis_client.get(f"stream_profile:{stream_id}") - if not profile_id: - logger.debug( - f"Channel {self.uuid}: no profile found for " - f"stream_profile:{stream_id}" + if profile_id: + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + profile_id = int(profile_id) + else: + # stream_profile key missing — try metadata hash fallback + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE ) - return False - - redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association - - profile_id = int(profile_id) + if meta_profile_id: + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered profile_id={profile_id} " + f"from metadata fallback (stream_profile:{stream_id} was missing)" + ) + else: + logger.warning( + f"Channel {self.uuid}: no profile found for " + f"stream_profile:{stream_id} or in metadata fallback" + ) + return False logger.debug( f"Channel {self.uuid}: found profile_id={profile_id} for " f"stream {stream_id}" @@ -610,6 +629,16 @@ class Channel(models.Model): if current_count > 0: redis_client.decr(profile_connections_key) + # Clear metadata fields so duplicate release_stream() calls + # (e.g. from _clean_redis_keys or ChannelService.stop_channel) + # won't find them via fallback and DECR again + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + return True def update_stream_profile(self, new_profile_id): diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 5060a3a7..90ac2673 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -244,8 +244,18 @@ def stream_ts(request, channel_id): if needs_initialization: connection_allocated = True - # Get the stream ID from the channel - stream_id, m3u_profile_id, _ = channel.get_stream() + # Read stream assignment from Redis (already set by generate_stream_url → get_stream). + # Avoid calling get_stream() again — (INCR profile counter) + # It could double-allocate if the keys were cleared by a concurrent release. + stream_id = None + m3u_profile_id = None + if proxy_server.redis_client: + stream_id_bytes = proxy_server.redis_client.get(f"channel_stream:{channel.id}") + if stream_id_bytes: + stream_id = int(stream_id_bytes) + profile_id_bytes = proxy_server.redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + m3u_profile_id = int(profile_id_bytes) logger.info( f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}" ) From e7d4dbd6375d4a17e3b34c71424a548fabc539aa Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:41:58 -0600 Subject: [PATCH 018/154] Add restart policy to dispatcharr container Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.aio.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index 2b1fd2ae..d24ee041 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -4,6 +4,7 @@ services: # context: . # dockerfile: Dockerfile image: ghcr.io/dispatcharr/dispatcharr:latest + restart: unless-stopped container_name: dispatcharr ports: - 9191:9191 From dd0f5e0b00c3f51da488063acb1d7bebd1148d66 Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:43:17 -0600 Subject: [PATCH 019/154] Add restart policy to Docker services Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 519b288a..e212bd37 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,6 +9,7 @@ services: web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web + restart: unless-stopped ports: - 9191:9191 volumes: @@ -80,6 +81,7 @@ services: celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery + restart: unless-stopped depends_on: - db - redis @@ -132,6 +134,7 @@ services: db: image: postgres:17 container_name: dispatcharr_db + restart: unless-stopped ports: - "5436:5432" environment: @@ -152,6 +155,7 @@ services: redis: image: redis:latest container_name: dispatcharr_redis + restart: unless-stopped # --- Authentication Configuration (Optional) --- # By default, Redis runs without authentication. From 39f2f8f970b79e8020b17a1c70bc66e959ef70cb Mon Sep 17 00:00:00 2001 From: MotWakorb <31100779+MotWakorb@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:44:07 -0600 Subject: [PATCH 020/154] Add restart policy to Docker services Adding a restart policy so that the default recommendation is to use unless-stopped. --- docker/docker-compose.dev.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index b20c3296..e640e843 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -5,6 +5,7 @@ services: # dockerfile: docker/Dockerfile.dev image: ghcr.io/dispatcharr/dispatcharr:base container_name: dispatcharr_dev + restart: unless-stopped ports: - 5656:5656 - 9191:9191 @@ -33,6 +34,7 @@ services: pgadmin: image: dpage/pgadmin4 + restart: unless-stopped environment: PGADMIN_DEFAULT_EMAIL: admin@admin.com PGADMIN_DEFAULT_PASSWORD: admin @@ -43,6 +45,7 @@ services: redis-commander: image: rediscommander/redis-commander:latest + restart: unless-stopped environment: - REDIS_HOSTS=dispatcharr:dispatcharr:6379:0 - TRUST_PROXY=true From a0ed2a9c84fa8121af842803b014fb754ca77f24 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 16:52:48 -0600 Subject: [PATCH 021/154] specify minimum version for django-celery-beat --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 82961ccb..9071f8a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "channels", "channels-redis==4.3.0", "django-filter", - "django-celery-beat", + "django-celery-beat>=2.8.1", "lxml==6.0.2", "packaging", ] From ac37973a9a1f224a461d35333e8943273e55e17e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 16:14:11 -0600 Subject: [PATCH 022/154] Roll django back to version 5 (still has remediations). django-celery-beat does not support Django 6 quite yet. Should soon. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9071f8a6..089a5219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "CC-BY-NC-SA-4.0" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "Django==5.2.9", + "Django==5.2.11", "psycopg2-binary==2.9.11", "celery[redis]==5.6.0", "djangorestframework==3.16.1", From 29a2e07f967d8b57d15427b4e542bb9659cdacd8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 20 Feb 2026 15:12:08 -0600 Subject: [PATCH 023/154] =?UTF-8?q?-=20Dependency=20updates:=20=20=20-=20`?= =?UTF-8?q?Django`=205.2.9=20=E2=86=92=206.0.2=20(major=20version=20upgrad?= =?UTF-8?q?e;=20includes=20security=20fixes)=20=20=20-=20`celery`=205.6.0?= =?UTF-8?q?=20=E2=86=92=205.6.2=20=20=20-=20`psutil`=207.1.3=20=E2=86=92?= =?UTF-8?q?=207.2.2=20=20=20-=20`torch`=202.9.1+cpu=20=E2=86=92=202.10.0+c?= =?UTF-8?q?pu=20=20=20-=20`sentence-transformers`=205.2.0=20=E2=86=92=205.?= =?UTF-8?q?2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 089a5219..8b87a9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,10 @@ dynamic = ["version"] dependencies = [ "Django==5.2.11", "psycopg2-binary==2.9.11", - "celery[redis]==5.6.0", + "celery[redis]==5.6.2", "djangorestframework==3.16.1", "requests==2.32.5", - "psutil==7.1.3", + "psutil==7.2.2", "pillow", "drf-spectacular>=0.29.0", "streamlink", @@ -27,8 +27,8 @@ dependencies = [ "regex", "tzlocal", "pytz", - "torch==2.9.1+cpu", - "sentence-transformers==5.2.0", + "torch==2.10.0+cpu", + "sentence-transformers==5.2.3", "channels", "channels-redis==4.3.0", "django-filter", From a9402207fb95c2ab6ab473b0b0e7b8a1f2defae1 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:58:36 -0800 Subject: [PATCH 024/154] Added tests --- .../__tests__/ConfirmationDialog.test.jsx | 275 ++++++ .../__tests__/ErrorBoundary.test.jsx | 99 ++ .../src/components/__tests__/Field.test.jsx | 388 ++++++++ .../__tests__/FloatingVideo.test.jsx | 454 +++++++++ .../components/__tests__/GuideRow.test.jsx | 335 +++++++ .../__tests__/HourTimeline.test.jsx | 318 +++++++ .../components/__tests__/LazyLogo.test.jsx | 398 ++++++++ .../__tests__/M3URefreshNotification.test.jsx | 712 +++++++++++++++ .../__tests__/RecordingSynopsis.test.jsx | 169 ++++ .../components/__tests__/SeriesModal.test.jsx | 864 ++++++++++++++++++ .../src/components/__tests__/Sidebar.test.jsx | 455 +++++++++ .../__tests__/SystemEvents.test.jsx | 251 +++++ .../components/__tests__/VODModal.test.jsx | 439 +++++++++ .../__tests__/YouTubeTrailerModal.test.jsx | 109 +++ .../__tests__/FloatingVideoUtils.test.js | 235 +++++ .../__tests__/SeriesModalUtils.test.js | 397 ++++++++ .../__tests__/VODModalUtils.test.js | 344 +++++++ 17 files changed, 6242 insertions(+) create mode 100644 frontend/src/components/__tests__/ConfirmationDialog.test.jsx create mode 100644 frontend/src/components/__tests__/ErrorBoundary.test.jsx create mode 100644 frontend/src/components/__tests__/Field.test.jsx create mode 100644 frontend/src/components/__tests__/FloatingVideo.test.jsx create mode 100644 frontend/src/components/__tests__/GuideRow.test.jsx create mode 100644 frontend/src/components/__tests__/HourTimeline.test.jsx create mode 100644 frontend/src/components/__tests__/LazyLogo.test.jsx create mode 100644 frontend/src/components/__tests__/M3URefreshNotification.test.jsx create mode 100644 frontend/src/components/__tests__/RecordingSynopsis.test.jsx create mode 100644 frontend/src/components/__tests__/SeriesModal.test.jsx create mode 100644 frontend/src/components/__tests__/Sidebar.test.jsx create mode 100644 frontend/src/components/__tests__/SystemEvents.test.jsx create mode 100644 frontend/src/components/__tests__/VODModal.test.jsx create mode 100644 frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx create mode 100644 frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/SeriesModalUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/VODModalUtils.test.js diff --git a/frontend/src/components/__tests__/ConfirmationDialog.test.jsx b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx new file mode 100644 index 00000000..741f2f16 --- /dev/null +++ b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx @@ -0,0 +1,275 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ConfirmationDialog from '../ConfirmationDialog'; +import useWarningsStore from '../../store/warnings'; + +// Mock the warnings store +vi.mock('../../store/warnings'); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ {children} +
+ ) : null, + Group: ({ children }) =>
{children}
, + Button: ({ children, onClick, disabled }) => ( + + ), + Checkbox: ({ label, checked, onChange }) => ( + + ), + Box: ({ children }) =>
{children}
, + }; +}); + +describe('ConfirmationDialog', () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + const mockOnSuppressChange = vi.fn(); + const mockSuppressWarning = vi.fn(); + const mockIsWarningSuppressed = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useWarningsStore.mockImplementation((selector) => { + const state = { + suppressWarning: mockSuppressWarning, + isWarningSuppressed: mockIsWarningSuppressed, + }; + return selector ? selector(state) : state; + }); + }); + + it('should render when opened', () => { + render( + + ); + + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument(); + }); + + it('should not render when closed', () => { + render( + + ); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should display custom title and message', () => { + render( + + ); + + expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item'); + expect(screen.getByText('This action cannot be undone')).toBeInTheDocument(); + }); + + it('should call onConfirm when confirm button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByText('Delete')); + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByText('Cancel')); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should show suppress checkbox when actionKey is provided', () => { + render( + + ); + + expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument(); + }); + + it('should not show suppress checkbox when actionKey is not provided', () => { + render( + + ); + + expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument(); + }); + + it('should call suppressWarning when suppress is checked and confirmed', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText("Don't ask me again")); + fireEvent.click(screen.getByText('Confirm')); + + expect(mockSuppressWarning).toHaveBeenCalledWith('delete-action'); + }); + + it('should call onSuppressChange when suppress checkbox is toggled', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText("Don't ask me again")); + expect(mockOnSuppressChange).toHaveBeenCalledWith(true); + }); + + it('should show delete file option when enabled', () => { + render( + + ); + + expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument(); + }); + + it('should pass deleteFiles state to onConfirm when delete option is checked', () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Also delete files from disk')); + fireEvent.click(screen.getByText('Confirm')); + + expect(mockOnConfirm).toHaveBeenCalledWith(true); + }); + + it('should reset deleteFiles state after confirmation', () => { + const { rerender } = render( + + ); + + fireEvent.click(screen.getByLabelText('Also delete files from disk')); + fireEvent.click(screen.getByText('Confirm')); + + rerender( + + ); + + expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked(); + }); + + it('should show loading state on confirm button', () => { + render( + + ); + + expect(screen.getByText('Confirm')).toBeDisabled(); + }); + + it('should disable cancel button when loading', () => { + render( + + ); + + expect(screen.getByText('Cancel')).toBeDisabled(); + }); + + it('should initialize suppress checkbox based on store state', () => { + mockIsWarningSuppressed.mockReturnValue(true); + + render( + + ); + + expect(screen.getByLabelText("Don't ask me again")).toBeChecked(); + }); +}); diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx new file mode 100644 index 00000000..bab0778d --- /dev/null +++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ErrorBoundary from '../ErrorBoundary'; + +// Component that throws an error for testing +const ThrowError = ({ shouldThrow }) => { + if (shouldThrow) { + throw new Error('Test error'); + } + return
Child component
; +}; + +describe('ErrorBoundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Suppress console.error for cleaner test output + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should render children when there is no error', () => { + render( + +
Test content
+
+ ); + + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + it('should render error message when child component throws error', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.queryByText('Child component')).not.toBeInTheDocument(); + }); + + it('should not render error message when child component does not throw', () => { + render( + + + + ); + + expect(screen.getByText('Child component')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('should handle multiple children', () => { + render( + +
First child
+
Second child
+
+ ); + + expect(screen.getByText('First child')).toBeInTheDocument(); + expect(screen.getByText('Second child')).toBeInTheDocument(); + }); + + it('should catch errors from nested children', () => { + render( + +
+
+ +
+
+
+ ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('should have hasError state set to true after catching error', () => { + const { container } = render( + + + + ); + + // Verify error boundary rendered fallback UI + expect(container.querySelector('div')).toHaveTextContent('Something went wrong'); + }); + + it('should have hasError state set to false initially', () => { + const { container } = render( + +
Normal content
+
+ ); + + // Verify children are rendered (not error state) + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx new file mode 100644 index 00000000..3f57356d --- /dev/null +++ b/frontend/src/components/__tests__/Field.test.jsx @@ -0,0 +1,388 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Field } from '../Field'; + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + TextInput: ({ label, description, value, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + NumberInput: ({ label, description, value, onChange }) => ( +
+ + onChange(Number(e.target.value))} + aria-describedby={description} + /> + {description &&
{description}
} +
+ ), + Switch: ({ label, description, checked, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + Select: ({ label, description, value, data, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + }; +}); + +describe('Field', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('TextInput (string type)', () => { + it('should render TextInput for string type', () => { + const field = { + id: 'name', + type: 'string', + label: 'Name', + help_text: 'Enter your name', + default: '', + }; + + render(); + + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByText('Enter your name')).toBeInTheDocument(); + }); + + it('should use provided value', () => { + const field = { + id: 'name', + type: 'string', + label: 'Name', + default: '', + }; + + render(); + + expect(screen.getByLabelText('Name')).toHaveValue('John'); + }); + + it('should use default value when value is null', () => { + const field = { + id: 'name', + type: 'string', + label: 'Name', + default: 'Default Name', + }; + + render(); + + expect(screen.getByLabelText('Name')).toHaveValue('Default Name'); + }); + + it('should call onChange with field id and value', () => { + const field = { + id: 'name', + type: 'string', + label: 'Name', + default: '', + }; + + render(); + + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'New Value' }, + }); + + expect(mockOnChange).toHaveBeenCalledWith('name', 'New Value'); + }); + }); + + describe('NumberInput (number type)', () => { + it('should render NumberInput for number type', () => { + const field = { + id: 'age', + type: 'number', + label: 'Age', + help_text: 'Enter your age', + default: 0, + }; + + render(); + + expect(screen.getByLabelText('Age')).toBeInTheDocument(); + expect(screen.getByText('Enter your age')).toBeInTheDocument(); + }); + + it('should use provided value', () => { + const field = { + id: 'age', + type: 'number', + label: 'Age', + default: 0, + }; + + render(); + + expect(screen.getByLabelText('Age')).toHaveValue(25); + }); + + it('should default to 0 when value and default are null', () => { + const field = { + id: 'age', + type: 'number', + label: 'Age', + default: null, + }; + + render(); + + expect(screen.getByLabelText('Age')).toHaveValue(0); + }); + + it('should call onChange with field id and numeric value', () => { + const field = { + id: 'age', + type: 'number', + label: 'Age', + default: 0, + }; + + render(); + + fireEvent.change(screen.getByLabelText('Age'), { + target: { value: '30' }, + }); + + expect(mockOnChange).toHaveBeenCalledWith('age', 30); + }); + }); + + describe('Switch (boolean type)', () => { + it('should render Switch for boolean type', () => { + const field = { + id: 'active', + type: 'boolean', + label: 'Active', + help_text: 'Toggle active state', + default: false, + }; + + render(); + + expect(screen.getByLabelText('Active')).toBeInTheDocument(); + expect(screen.getByText('Toggle active state')).toBeInTheDocument(); + }); + + it('should be checked when value is true', () => { + const field = { + id: 'active', + type: 'boolean', + label: 'Active', + default: false, + }; + + render(); + + expect(screen.getByLabelText('Active')).toBeChecked(); + }); + + it('should be unchecked when value is false', () => { + const field = { + id: 'active', + type: 'boolean', + label: 'Active', + default: false, + }; + + render(); + + expect(screen.getByLabelText('Active')).not.toBeChecked(); + }); + + it('should use default value when value is null', () => { + const field = { + id: 'active', + type: 'boolean', + label: 'Active', + default: true, + }; + + render(); + + expect(screen.getByLabelText('Active')).toBeChecked(); + }); + + it('should call onChange with field id and checked state', () => { + const field = { + id: 'active', + type: 'boolean', + label: 'Active', + default: false, + }; + + render(); + + fireEvent.click(screen.getByLabelText('Active')); + + expect(mockOnChange).toHaveBeenCalledWith('active', true); + }); + }); + + describe('Select (select type)', () => { + it('should render Select for select type', () => { + const field = { + id: 'country', + type: 'select', + label: 'Country', + help_text: 'Select your country', + default: '', + options: [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + ], + }; + + render(); + + expect(screen.getByLabelText('Country')).toBeInTheDocument(); + expect(screen.getByText('Select your country')).toBeInTheDocument(); + }); + + it('should render options correctly', () => { + const field = { + id: 'country', + type: 'select', + label: 'Country', + default: '', + options: [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + ], + }; + + render(); + + expect(screen.getByText('United States')).toBeInTheDocument(); + expect(screen.getByText('Canada')).toBeInTheDocument(); + }); + + it('should use provided value', () => { + const field = { + id: 'country', + type: 'select', + label: 'Country', + default: '', + options: [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + ], + }; + + render(); + + expect(screen.getByLabelText('Country')).toHaveValue('ca'); + }); + + it('should convert value to string', () => { + const field = { + id: 'status', + type: 'select', + label: 'Status', + default: 1, + options: [ + { value: 1, label: 'Active' }, + { value: 2, label: 'Inactive' }, + ], + }; + + render(); + + expect(screen.getByLabelText('Status')).toHaveValue('1'); + }); + + it('should handle empty options array', () => { + const field = { + id: 'country', + type: 'select', + label: 'Country', + default: '', + options: null, + }; + + render(); + + expect(screen.getByLabelText('Country')).toBeInTheDocument(); + }); + + it('should call onChange with field id and selected value', () => { + const field = { + id: 'country', + type: 'select', + label: 'Country', + default: '', + options: [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + ], + }; + + render(); + + fireEvent.change(screen.getByLabelText('Country'), { + target: { value: 'ca' }, + }); + + expect(mockOnChange).toHaveBeenCalledWith('country', 'ca'); + }); + }); + + describe('Default fallback', () => { + it('should render TextInput for unknown type', () => { + const field = { + id: 'custom', + type: 'unknown', + label: 'Custom Field', + default: '', + }; + + render(); + + expect(screen.getByLabelText('Custom Field')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx new file mode 100644 index 00000000..9c537d2f --- /dev/null +++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx @@ -0,0 +1,454 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import FloatingVideo from '../FloatingVideo'; +import useVideoStore from '../../store/useVideoStore'; + +// Mock the video store +vi.mock('../../store/useVideoStore'); + +// Mock mpegts.js +vi.mock('mpegts.js', () => ({ + default: { + createPlayer: vi.fn(), + getFeatureList: vi.fn(), + Events: { + LOADING_COMPLETE: 'loading_complete', + METADATA_ARRIVED: 'metadata_arrived', + ERROR: 'error', + MEDIA_INFO: 'media_info', + }, + }, +})); + +// Import the mocked module after mocking +const mpegts = (await import('mpegts.js')).default; + +// Mock react-draggable +vi.mock('react-draggable', () => ({ + default: ({ children, nodeRef }) =>
{children}
, +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + CloseButton: ({ onClick, onTouchEnd }) => ( + + ), + Flex: ({ children, ...props }) =>
{children}
, + Box: ({ children, ...props }) =>
{children}
, + Loader: () =>
Loading...
, + Text: ({ children, ...props }) =>
{children}
, + }; +}); + +describe('FloatingVideo', () => { + const mockHideVideo = vi.fn(); + let mockPlayer; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Mock HTMLVideoElement methods + HTMLVideoElement.prototype.load = vi.fn(); + HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve()); + HTMLVideoElement.prototype.pause = vi.fn(); + + mockPlayer = { + attachMediaElement: vi.fn(), + load: vi.fn(), + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(), + destroy: vi.fn(), + on: vi.fn(), + }; + + mpegts.createPlayer.mockReturnValue(mockPlayer); + mpegts.getFeatureList.mockReturnValue({ mseLivePlayback: true }); + + useVideoStore.mockImplementation((selector) => { + const state = { + isVisible: false, + streamUrl: null, + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }); + }); + + describe('Visibility', () => { + it('should not render when isVisible is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should not render when streamUrl is null', () => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: null, + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render when isVisible is true and streamUrl is provided', () => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + + render(); + expect(screen.getByTestId('close-button')).toBeInTheDocument(); + }); + }); + + describe('Live Stream Player', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should initialize mpegts player for live streams', () => { + render(); + + expect(mpegts.createPlayer).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mpegts', + url: 'http://example.com/stream.ts', + isLive: true, + }) + ); + }); + + it('should show loading state initially', () => { + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByText('Loading stream...')).toBeInTheDocument(); + }); + + it('should attach player to video element', () => { + render(); + expect(mockPlayer.attachMediaElement).toHaveBeenCalled(); + }); + + it('should handle player errors', async () => { + render(); + + const errorCallback = mockPlayer.on.mock.calls.find( + (call) => call[0] === mpegts.Events.ERROR + )?.[1]; + + errorCallback('MediaError', 'AC3 codec not supported'); + + await screen.findByText(/Audio codec not supported/i); + }); + + it('should handle unsupported browser', () => { + mpegts.getFeatureList.mockReturnValue({ + mseLivePlayback: false, + }); + + render(); + + expect( + screen.getByText(/browser doesn't support live video streaming/i) + ).toBeInTheDocument(); + }); + + it('should play video on MEDIA_INFO event', async () => { + render(); + + const mediaInfoCallback = mockPlayer.on.mock.calls.find( + (call) => call[0] === mpegts.Events.MEDIA_INFO + )?.[1]; + + await mediaInfoCallback(); + + expect(mockPlayer.play).toHaveBeenCalled(); + }); + }); + + describe('VOD Player', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/video.mp4', + contentType: 'vod', + metadata: { + name: 'Test Movie', + year: '2024', + logo: { url: 'http://example.com/poster.jpg' }, + }, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should use native video player for VOD', () => { + render(); + expect(mpegts.createPlayer).not.toHaveBeenCalled(); + }); + + it('should set video source for VOD', () => { + const { container } = render(); + const video = container.querySelector('video'); + expect(video).toBeInTheDocument(); + expect(video.src).toBe('http://example.com/video.mp4'); + expect(video.poster).toBe('http://example.com/poster.jpg'); + }); + + it('should show metadata overlay', () => { + const { container } = render(); + const video = container.querySelector('video'); + + // Simulate video loaded event to clear loading state + fireEvent.loadedData(video); + + expect(screen.getByText('Test Movie')).toBeInTheDocument(); + expect(screen.getByText('2024')).toBeInTheDocument(); + }); + + it('should hide overlay after 4 seconds', () => { + vi.useFakeTimers(); + + const { container } = render(); + const video = container.querySelector('video'); + + fireEvent.loadedData(video); + fireEvent.canPlay(video); + + expect(screen.getByText('Test Movie')).toBeInTheDocument(); + + + vi.advanceTimersByTime(4000); + + waitFor(() => { + expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it('should show overlay on mouse enter', () => { + const { container } = render(); + const video = container.querySelector('video'); + + fireEvent.loadedData(video); + fireEvent.canPlay(video); + + const videoContainer = screen.getByText('Test Movie').closest('div'); + + fireEvent.mouseEnter(videoContainer); + + expect(screen.getByText('Test Movie')).toBeInTheDocument(); + }); + + it('should hide overlay on mouse leave', () => { + vi.useFakeTimers(); + + const { container } = render(); + const video = container.querySelector('video'); + + fireEvent.loadedData(video); + fireEvent.canPlay(video); + + const videoContainer = screen.getByText('Test Movie').closest('div'); + + fireEvent.mouseEnter(videoContainer); + fireEvent.mouseLeave(videoContainer); + + vi.advanceTimersByTime(4000); + + waitFor(() => { + expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + }); + + describe('Close functionality', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should call hideVideo when close button is clicked', () => { + vi.useFakeTimers(); + + render(); + + fireEvent.click(screen.getByTestId('close-button')); + + vi.advanceTimersByTime(50); + + waitFor(() => { + expect(mockHideVideo).toHaveBeenCalled(); + expect(mockPlayer.destroy).toHaveBeenCalled(); + }); + + vi.useRealTimers(); + }); + }); + + describe('Error handling', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/video.mp4', + contentType: 'vod', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should display video error messages', () => { + const { container } = render(); + const video = container.querySelector('video'); + + Object.defineProperty(video, 'error', { + value: { code: 3, message: 'MEDIA_ERR_DECODE' }, + writable: true, + }); + + fireEvent.error(video); + + expect( + screen.getByText(/MEDIA_ERR_DECODE/i) + ).toBeInTheDocument(); + }); + + it('should handle network errors', () => { + const { container } = render(); + const video = container.querySelector('video'); + + Object.defineProperty(video, 'error', { + value: { code: 2, message: 'MEDIA_ERR_NETWORK' }, + writable: true, + }); + + fireEvent.error(video); + + expect( + screen.getByText(/MEDIA_ERR_NETWORK/i) + ).toBeInTheDocument(); + }); + }); + + describe('Player cleanup', () => { + it('should cleanup player on unmount', () => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + + const { unmount } = render(); + + unmount(); + + expect(mockPlayer.destroy).toHaveBeenCalled(); + }); + + it('should cleanup player when streamUrl changes', () => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream1.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + + const { rerender } = render(); + + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream2.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + + rerender(); + + expect(mockPlayer.destroy).toHaveBeenCalled(); + }); + }); + + describe('Resize functionality', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should render resize handles', () => { + const { container } = render(); + const handles = container.querySelectorAll( + '[class*="floating-video-no-drag"]' + ); + + // Should have 4 resize handles plus video element + expect(handles.length).toBeGreaterThanOrEqual(4); + }); + }); +}); diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx new file mode 100644 index 00000000..3f103305 --- /dev/null +++ b/frontend/src/components/__tests__/GuideRow.test.jsx @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import GuideRow from '../GuideRow'; +import { + CHANNEL_WIDTH, + EXPANDED_PROGRAM_HEIGHT, + HOUR_WIDTH, + PROGRAM_HEIGHT, +} from '../../pages/guideUtils'; + +// Mock logo import +vi.mock('../../images/logo.png', () => ({ + default: 'mocked-logo.png', +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Play: (props) =>
, +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Box: ({ children, ...props }) =>
{children}
, + Flex: ({ children, ...props }) =>
{children}
, + Text: ({ children, ...props }) =>
{children}
, + }; +}); + +describe('GuideRow', () => { + const mockChannel = { + id: 'channel-1', + name: 'Test Channel', + channel_number: '101', + logo_id: 'logo-1', + }; + + const mockProgram = { + id: 'program-1', + title: 'Test Program', + start_time: '2024-01-01T10:00:00Z', + end_time: '2024-01-01T11:00:00Z', + }; + + const mockLogos = { + 'logo-1': { + cache_url: 'https://example.com/logo.png', + }, + }; + + const mockData = { + filteredChannels: [mockChannel], + programsByChannelId: new Map([[mockChannel.id, [mockProgram]]]), + expandedProgramId: null, + rowHeights: {}, + logos: mockLogos, + hoveredChannelId: null, + setHoveredChannelId: vi.fn(), + renderProgram: vi.fn((program) => ( +
+ {program.title} +
+ )), + handleLogoClick: vi.fn(), + contentWidth: 1920, + }; + + const mockStyle = { + position: 'absolute', + left: 0, + top: 0, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render channel row with channel information', () => { + render( + + ); + + expect(screen.getByTestId('guide-row')).toBeInTheDocument(); + expect(screen.getByAltText('Test Channel')).toBeInTheDocument(); + expect(screen.getByText('101')).toBeInTheDocument(); + }); + + it('should return null when channel does not exist', () => { + const data = { ...mockData, filteredChannels: [] }; + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should use default logo when channel logo is not available', () => { + const channelWithoutLogo = { ...mockChannel, logo_id: 'missing-logo' }; + const data = { + ...mockData, + filteredChannels: [channelWithoutLogo], + }; + + render(); + + const img = screen.getByAltText('Test Channel'); + expect(img).toHaveAttribute('src', 'mocked-logo.png'); + }); + + it('should display channel number or dash if missing', () => { + const channelWithoutNumber = { ...mockChannel, channel_number: null }; + const data = { + ...mockData, + filteredChannels: [channelWithoutNumber], + }; + + render(); + + expect(screen.getByText('-')).toBeInTheDocument(); + }); + }); + + describe('Row Height Calculation', () => { + it('should use default PROGRAM_HEIGHT when no expanded program', () => { + render( + + ); + + const row = screen.getByTestId('guide-row'); + expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` }); + }); + + it('should use EXPANDED_PROGRAM_HEIGHT when program is expanded', () => { + const data = { + ...mockData, + expandedProgramId: mockProgram.id, + }; + + render(); + + const row = screen.getByTestId('guide-row'); + expect(row).toHaveStyle({ height: `${EXPANDED_PROGRAM_HEIGHT}px` }); + }); + + it('should use pre-calculated row height from rowHeights array', () => { + const customHeight = 150; + const data = { + ...mockData, + rowHeights: { 0: customHeight }, + }; + + render(); + + const row = screen.getByTestId('guide-row'); + expect(row).toHaveStyle({ height: `${customHeight}px` }); + }); + }); + + describe('Programs Rendering', () => { + it('should render programs when channel has programs', () => { + render( + + ); + + expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument(); + expect(screen.getByText('Test Program')).toBeInTheDocument(); + expect(mockData.renderProgram).toHaveBeenCalledWith( + mockProgram, + undefined, + mockChannel + ); + }); + + it('should render multiple programs', () => { + const programs = [ + mockProgram, + { ...mockProgram, id: 'program-2', title: 'Another Program' }, + ]; + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, programs]]), + }; + + render(); + + expect(screen.getByText('Test Program')).toBeInTheDocument(); + expect(screen.getByText('Another Program')).toBeInTheDocument(); + expect(mockData.renderProgram).toHaveBeenCalledTimes(2); + }); + + it('should render placeholder when channel has no programs', () => { + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, []]]), + }; + + render(); + + const placeholders = screen.getAllByText('No program data'); + expect(placeholders.length).toBe(Math.ceil(24 / 2)); + }); + + it('should render placeholder when programsByChannelId does not contain channel', () => { + const data = { + ...mockData, + programsByChannelId: new Map(), + }; + + render(); + + const placeholders = screen.getAllByText('No program data'); + expect(placeholders.length).toBe(Math.ceil(24 / 2)); + }); + + it('should position placeholder programs correctly', () => { + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, []]]), + }; + + const { container } = render( + + ); + + const placeholders = container.querySelectorAll('[pos*="absolute"]'); + const filteredPlaceholders = Array.from(placeholders).filter(el => + el.textContent.includes('No program data') + ); + + filteredPlaceholders.forEach((placeholder, index) => { + expect(placeholder).toHaveAttribute('left', `${index * (HOUR_WIDTH * 2)}`); + expect(placeholder).toHaveAttribute('w', `${HOUR_WIDTH * 2}`); + }); + }); + }); + + describe('Channel Logo Interactions', () => { + it('should call handleLogoClick when logo is clicked', () => { + render( + + ); + + const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); + fireEvent.click(logo); + + expect(mockData.handleLogoClick).toHaveBeenCalledWith( + mockChannel, + expect.any(Object) + ); + }); + + it('should show play icon on hover', () => { + const data = { + ...mockData, + hoveredChannelId: mockChannel.id, + }; + + render(); + + expect(screen.getByTestId('play-icon')).toBeInTheDocument(); + }); + + it('should not show play icon when not hovering', () => { + render( + + ); + + expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument(); + }); + + it('should call setHoveredChannelId on mouse enter', () => { + render( + + ); + + const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); + fireEvent.mouseEnter(logo); + + expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id); + }); + + it('should call setHoveredChannelId with null on mouse leave', () => { + render( + + ); + + const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); + fireEvent.mouseLeave(logo); + + expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null); + }); + }); + + describe('Layout and Styling', () => { + it('should set correct channel logo width', () => { + const { container } = render( + + ); + + const logoContainer = container.querySelector('.channel-logo'); + expect(logoContainer).toHaveAttribute('w', `${CHANNEL_WIDTH}`); + expect(logoContainer).toHaveAttribute('miw', `${CHANNEL_WIDTH}`); + }); + + it('should apply content width to row', () => { + const customWidth = 2400; + const data = { + ...mockData, + contentWidth: customWidth, + }; + + render(); + + const row = screen.getByTestId('guide-row'); + expect(row).toHaveStyle({ width: `${customWidth}px` }); + }); + + it('should adjust logo image container height based on row height', () => { + const customHeight = 200; + const data = { + ...mockData, + rowHeights: { 0: customHeight }, + }; + + const { container } = render( + + ); + + const imageContainer = container.querySelector('img').parentElement; + expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`); + }); + }); +}); diff --git a/frontend/src/components/__tests__/HourTimeline.test.jsx b/frontend/src/components/__tests__/HourTimeline.test.jsx new file mode 100644 index 00000000..372ec155 --- /dev/null +++ b/frontend/src/components/__tests__/HourTimeline.test.jsx @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import HourTimeline from '../HourTimeline'; +import { format } from '../../utils/dateTimeUtils'; +import { HOUR_WIDTH } from '../../pages/guideUtils'; + +// Mock date utilities +vi.mock('../../utils/dateTimeUtils', () => ({ + format: vi.fn((date, formatStr) => { + if (!formatStr) return date.toISOString(); + return formatStr === 'h:mm a' ? '12:00 PM' : 'Jan 1'; + }), +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Box: ({ children, ...props }) =>
{children}
, + Text: ({ children, ...props }) => {children}, + }; +}); + +describe('HourTimeline', () => { + const mockTime1 = new Date('2024-01-01T10:00:00Z'); + const mockTime2 = new Date('2024-01-01T11:00:00Z'); + const mockTime3 = new Date('2024-01-02T00:00:00Z'); + + const mockHourTimeline = [ + { time: mockTime1, isNewDay: false }, + { time: mockTime2, isNewDay: false }, + ]; + + const mockTimeFormat = 'h:mm a'; + const mockFormatDayLabel = vi.fn((time) => 'Mon'); + const mockHandleTimeClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render all hour blocks', () => { + render( + + ); + + expect(mockFormatDayLabel).toHaveBeenCalledTimes(2); + expect(format).toHaveBeenCalledWith(mockTime1, mockTimeFormat); + expect(format).toHaveBeenCalledWith(mockTime2, mockTimeFormat); + }); + + it('should render empty when hourTimeline is empty', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render formatted time labels', () => { + render( + + ); + + const timeLabels = screen.getAllByText('12:00 PM'); + expect(timeLabels.length).toBe(2); + }); + + it('should render day labels', () => { + render( + + ); + + const dayLabels = screen.getAllByText('Mon'); + expect(dayLabels.length).toBe(2); + }); + }); + + describe('HourBlock Styling', () => { + it('should apply correct width and height to hour blocks', () => { + const { container } = render( + + ); + + const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]'); + hourBlocks.forEach((block) => { + expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`); + expect(block).toHaveAttribute('h', '40px'); + expect(block).toHaveAttribute('pos', 'relative'); + }); + }); + + it('should apply default styling for non-new-day blocks', () => { + const { container } = render( + + ); + + const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]'); + expect(hourBlocks[0]).toHaveStyle({ + backgroundColor: '#1B2421', + }); + }); + + it('should apply special styling for new day blocks', () => { + const newDayTimeline = [ + { time: mockTime3, isNewDay: true }, + ]; + + const { container } = render( + + ); + + const hourBlock = container.querySelector('[style*="cursor: pointer"]'); + expect(hourBlock).toHaveStyle({ + borderLeft: '2px solid #3BA882', + backgroundColor: '#1E2A27', + }); + }); + + it('should apply bold font weight to day label on new day', () => { + const newDayTimeline = [ + { time: mockTime3, isNewDay: true }, + ]; + + const { container } = render( + + ); + + const dayLabel = screen.getByText('Mon'); + expect(dayLabel).toHaveAttribute('fw', '600'); + expect(dayLabel).toHaveAttribute('c', '#3BA882'); + }); + + it('should apply normal font weight to day label on regular day', () => { + const { container } = render( + + ); + + const dayLabels = screen.getAllByText('Mon'); + expect(dayLabels[0]).toHaveAttribute('fw', '400'); + }); + }); + + describe('Quarter Hour Markers', () => { + it('should render quarter hour markers', () => { + const { container } = render( + + ); + + const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]'); + expect(markers.length).toBe(3); // 15, 30, 45 minute markers + }); + + it('should position quarter hour markers correctly', () => { + const { container } = render( + + ); + + const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]'); + const positions = ['25%', '50%', '75%']; + + markers.forEach((marker, index) => { + expect(marker).toHaveStyle({ + left: positions[index], + width: '1px', + height: '8px', + position: 'absolute', + bottom: '0px', + }); + }); + }); + }); + + describe('Click Interactions', () => { + it('should call handleTimeClick when hour block is clicked', () => { + const { container } = render( + + ); + + const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]'); + fireEvent.click(hourBlocks[0]); + + expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object)); + }); + + it('should call handleTimeClick with correct time for each block', () => { + const { container } = render( + + ); + + const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]'); + + fireEvent.click(hourBlocks[0]); + expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object)); + + fireEvent.click(hourBlocks[1]); + expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object)); + }); + }); + + describe('Component Keys', () => { + it('should use formatted time as key for each hour block', () => { + format.mockImplementation((date) => date.toISOString()); + + const { container } = render( + + ); + + expect(format).toHaveBeenCalledWith(mockTime1); + expect(format).toHaveBeenCalledWith(mockTime2); + }); + }); + + describe('Time Label Positioning', () => { + it('should position time label correctly', () => { + const { container } = render( + + ); + + const timeLabel = container.querySelector('[pos*="absolute"][top*="8"]'); + expect(timeLabel).toHaveAttribute('left', '4'); + }); + }); + + describe('Visual Separators', () => { + it('should render left separator box', () => { + const { container } = render( + + ); + + const separator = container.querySelector('[w*="1px"][left*="0"]'); + expect(separator).toHaveAttribute('pos', 'absolute'); + expect(separator).toHaveAttribute('top', '0'); + expect(separator).toHaveAttribute('bottom', '0'); + }); + }); +}); diff --git a/frontend/src/components/__tests__/LazyLogo.test.jsx b/frontend/src/components/__tests__/LazyLogo.test.jsx new file mode 100644 index 00000000..dfd9d27c --- /dev/null +++ b/frontend/src/components/__tests__/LazyLogo.test.jsx @@ -0,0 +1,398 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import LazyLogo from '../LazyLogo'; +import useLogosStore from '../../store/logos'; + +// Mock the logos store +vi.mock('../../store/logos', () => ({ + default: vi.fn(), +})); + +// Mock the logo import +vi.mock('../../images/logo.png', () => ({ + default: 'mocked-default-logo.png', +})); + +// Mock Mantine Skeleton component +vi.mock('@mantine/core', async () => { + return { + Skeleton: ({ height, width, style, ...props }) => { + return
; + }, + }; +}); + +describe('LazyLogo', () => { + let mockFetchLogosByIds; + let mockStore; + + beforeEach(() => { + vi.clearAllMocks(); + vi.clearAllTimers(); + vi.useFakeTimers(); + + mockFetchLogosByIds = vi.fn().mockResolvedValue(undefined); + + mockStore = { + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + allowLogoRendering: true, + }; + + useLogosStore.mockImplementation((selector) => selector(mockStore)); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + describe('Rendering', () => { + it('should render image with logo data', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + render(); + + const img = screen.getByAltText('Test Logo'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/logo.png'); + }); + + it('should render with default style', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + render(); + + const img = screen.getByAltText('logo'); + expect(img).toHaveStyle({ + maxHeight: '18px', + maxWidth: '55px', + }); + }); + + it('should render with custom style', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + const customStyle = { maxHeight: 30, maxWidth: 100 }; + render(); + + const img = screen.getByAltText('logo'); + expect(img).toHaveStyle({ + maxHeight: '30px', + maxWidth: '100px', + }); + }); + + it('should render fallback logo when no logoId provided', () => { + render(); + + const img = screen.getByAltText('logo'); + expect(img).toHaveAttribute('src', 'mocked-default-logo.png'); + }); + + it('should pass through additional props to img element', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + render( + + ); + + const img = screen.getByTestId('custom-logo'); + expect(img).toHaveClass('test-class'); + }); + }); + + describe('Skeleton Loading State', () => { + it('should show skeleton when logo rendering is not allowed', () => { + mockStore.allowLogoRendering = false; + + render(); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + expect(screen.queryByAltText('logo')).not.toBeInTheDocument(); + }); + + it('should show skeleton when logo data is not available', () => { + mockStore.logos = {}; + + render(); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + }); + + it('should render skeleton with default dimensions', () => { + mockStore.logos = {}; + + render(); + + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveStyle({ + height: '18px', + width: '55px', + }); + }); + + it('should render skeleton with custom dimensions', () => { + mockStore.logos = {}; + + const customStyle = { maxHeight: 40, maxWidth: 120 }; + render(); + + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveStyle({ + height: '40px', + width: '120px', + }); + }); + + it('should apply border radius to skeleton', () => { + mockStore.logos = {}; + + render(); + + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveStyle({ + borderRadius: '4px', + }); + }); + }); + + describe('Logo Fetching', () => { + it('should fetch logo when logoId is provided and logo data is missing', async () => { + mockStore.logos = {}; + + render(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']); + }); + + it('should not fetch logo when logo data already exists', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + render(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).not.toHaveBeenCalled(); + }); + + it('should not fetch logo when allowLogoRendering is false', () => { + mockStore.allowLogoRendering = false; + mockStore.logos = {}; + + render(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).not.toHaveBeenCalled(); + }); + + it('should not fetch logo when no logoId is provided', () => { + render(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).not.toHaveBeenCalled(); + }); + + it('should batch multiple logo requests', async () => { + mockStore.logos = {}; + + render( + <> + + + + + ); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + expect(mockFetchLogosByIds).toHaveBeenCalledWith( + expect.arrayContaining(['logo-1', 'logo-2', 'logo-3']) + ); + }); + + it('should debounce fetch requests with 100ms delay', async () => { + mockStore.logos = {}; + + render(); + + vi.advanceTimersByTime(50); + expect(mockFetchLogosByIds).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(mockFetchLogosByIds).toHaveBeenCalled(); + }); + + it('should handle fetch errors gracefully', async () => { + mockFetchLogosByIds.mockRejectedValueOnce(new Error('Fetch failed')); + mockStore.logos = {}; + + render(); + + vi.advanceTimersByTime(100); + }); + + it('should not fetch same logo twice', async () => { + mockStore.logos = {}; + + const { rerender } = render(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + + rerender(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + }); + }); + + describe('Error Handling', () => { + it('should fallback to default logo on image load error', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' }, + }; + + render(); + + const img = screen.getByAltText('logo'); + + // Simulate image load error + img.dispatchEvent(new Event('error')); + + expect(img).toHaveAttribute('src', 'mocked-default-logo.png'); + }); + + it('should use custom fallback source on error', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' }, + }; + + const customFallback = 'custom-fallback.png'; + render(); + + const img = screen.getByAltText('logo'); + + img.dispatchEvent(new Event('error')); + + expect(img).toHaveAttribute('src', customFallback); + }); + + it('should only set fallback once to prevent infinite error loop', () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' }, + }; + + render(); + + const img = screen.getByAltText('logo'); + + // First error - should set fallback + img.dispatchEvent(new Event('error')); + expect(img).toHaveAttribute('src', 'mocked-default-logo.png'); + + // Reset src to test second error + img.src = 'https://example.com/another-invalid.png'; + + // Second error - should not change src again + img.dispatchEvent(new Event('error')); + expect(img).toHaveAttribute('src', 'mocked-default-logo.png'); + }); + + it('should reset error state when logoId changes', async () => { + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo1.png' }, + 'logo-2': { cache_url: 'https://example.com/logo2.png' }, + }; + + const { rerender } = render(); + + const img = screen.getByAltText('logo'); + img.dispatchEvent(new Event('error')); + + rerender(); + + const newImg = screen.getByAltText('logo'); + expect(newImg).toHaveAttribute('src', 'https://example.com/logo2.png'); + }); + }); + + describe('Component Lifecycle', () => { + it('should cleanup on unmount', () => { + const { unmount } = render(); + + unmount(); + + // Should not throw errors after unmount + vi.advanceTimersByTime(100); + }); + + it('should handle rapid logoId changes', async () => { + mockStore.logos = {}; + + const { rerender } = render(); + + rerender(); + rerender(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalled(); + }); + }); + + describe('Store Integration', () => { + it('should react to store updates', async () => { + mockStore.logos = {}; + + const { rerender } = render(); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + + // Update store with logo data + mockStore.logos = { + 'logo-1': { cache_url: 'https://example.com/logo.png' }, + }; + + rerender(); + + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); + expect(screen.getByAltText('logo')).toBeInTheDocument(); + }); + + it('should handle allowLogoRendering becoming true', async () => { + mockStore.allowLogoRendering = false; + mockStore.logos = {}; + + const { rerender } = render(); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + expect(mockFetchLogosByIds).not.toHaveBeenCalled(); + + mockStore.allowLogoRendering = true; + rerender(); + + vi.advanceTimersByTime(100); + + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']); + }); + }); +}); diff --git a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx new file mode 100644 index 00000000..14be3a7f --- /dev/null +++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx @@ -0,0 +1,712 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BrowserRouter } from 'react-router-dom'; +import M3URefreshNotification from '../M3URefreshNotification'; +import usePlaylistsStore from '../../store/playlists'; +import useStreamsStore from '../../store/streams'; +import useChannelsStore from '../../store/channels'; +import useEPGsStore from '../../store/epgs'; +import useVODStore from '../../store/useVODStore'; +import API from '../../api'; +import { showNotification } from '../../utils/notificationUtils'; + +// Mock all stores +vi.mock('../../store/playlists', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/streams', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/useVODStore', () => ({ + default: vi.fn(), +})); + +// Mock API +vi.mock('../../api', () => ({ + default: { + refreshPlaylist: vi.fn(), + requeryChannels: vi.fn(), + }, +})); + +// Mock notification utility +vi.mock('../../utils/notificationUtils', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('@mantine/core', async () => { + return { + Stack: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Button: ({ children, onClick }) => ( + + ), + }; +}); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + CircleCheck: () =>
, +})); + +const renderWithProviders = (component) => { + return render( + {component} + ); +}; + +describe('M3URefreshNotification', () => { + let mockPlaylistsStore; + let mockStreamsStore; + let mockChannelsStore; + let mockEPGsStore; + let mockVODStore; + + const mockPlaylist = { + id: 1, + name: 'Test Playlist', + url: 'https://example.com/playlist.m3u', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup default store mocks + mockPlaylistsStore = { + playlists: [mockPlaylist], + refreshProgress: {}, + fetchPlaylists: vi.fn(), + setEditPlaylistId: vi.fn(), + }; + + mockStreamsStore = { + fetchStreams: vi.fn(), + }; + + mockChannelsStore = { + fetchChannelGroups: vi.fn(), + fetchChannels: vi.fn(), + }; + + mockEPGsStore = { + fetchEPGData: vi.fn(), + }; + + mockVODStore = { + fetchCategories: vi.fn(), + }; + + usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore)); + useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore)); + useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore)); + useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore)); + useVODStore.mockImplementation((selector) => selector(mockVODStore)); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProviders(); + expect(container).toBeInTheDocument(); + }); + + it('should render empty fragment', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('Download Progress Notifications', () => { + it('should show notification when download starts', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'downloading', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'Downloading starting...', + loading: true, + autoClose: 2000, + icon: null, + }); + }); + }); + + it('should show notification when download completes', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'downloading', + progress: 100, + status: 'completed', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'Downloading complete!', + loading: false, + autoClose: 2000, + icon: expect.anything(), + }); + }); + }); + + it('should not show notification for intermediate progress', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'downloading', + progress: 50, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Parsing Progress Notifications', () => { + it('should show notification when parsing starts', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'Stream parsing starting...', + loading: true, + autoClose: 2000, + icon: null, + }); + }); + }); + + it('should show notification and trigger fetches when parsing completes', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 100, + status: 'completed', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalled(); + expect(mockStreamsStore.fetchStreams).toHaveBeenCalled(); + expect(API.requeryChannels).toHaveBeenCalled(); + expect(mockChannelsStore.fetchChannels).toHaveBeenCalled(); + }); + }); + }); + + describe('Group Processing Notifications', () => { + it('should show notification when processing groups starts', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'processing_groups', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'Group parsing starting...', + loading: true, + autoClose: 2000, + icon: null, + }); + }); + }); + + it('should trigger multiple fetches when processing groups completes', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'processing_groups', + progress: 100, + status: 'completed', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(mockStreamsStore.fetchStreams).toHaveBeenCalled(); + expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled(); + expect(mockEPGsStore.fetchEPGData).toHaveBeenCalled(); + expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled(); + }); + }); + }); + + describe('VOD Refresh Notifications', () => { + it('should show notification when VOD refresh starts', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'vod_refresh', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'VOD content refresh starting...', + loading: true, + autoClose: 2000, + icon: null, + }); + }); + }); + + it('should trigger VOD-specific fetches when VOD refresh completes', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'vod_refresh', + progress: 100, + status: 'completed', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled(); + expect(mockVODStore.fetchCategories).toHaveBeenCalled(); + }); + }); + }); + + describe('Pending Setup Status', () => { + it('should show setup notification and trigger fetches for pending_setup status', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + status: 'pending_setup', + message: 'Test setup message', + progress: 100, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Setup: Test Playlist', + message: expect.anything(), + color: 'orange.5', + autoClose: 5000, + }); + expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled(); + expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled(); + }); + }); + + it('should use default message when no message provided in pending_setup', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + status: 'pending_setup', + progress: 100, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalled(); + }); + }); + }); + + describe('Error Handling', () => { + it('should show error notification when status is error and progress is 100', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + status: 'error', + progress: 100, + error: 'Connection timeout', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'parsing failed: Connection timeout', + color: 'red', + autoClose: 5000, + }); + }); + }); + + it('should not show error notification when progress is not 100', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + status: 'error', + progress: 50, + error: 'Connection timeout', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + + it('should use default error message when error field is missing', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'downloading', + status: 'error', + progress: 100, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'downloading failed: Unknown error', + color: 'red', + autoClose: 5000, + }); + }); + }); + + it('should use default action when action field is missing in error', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + status: 'error', + progress: 100, + error: 'Test error', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith({ + title: 'M3U Processing: Test Playlist', + message: 'Processing failed: Test error', + color: 'red', + autoClose: 5000, + }); + }); + }); + + it('should not show further notifications after error status', async () => { + // First update with error + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + status: 'error', + progress: 100, + error: 'Test error', + }, + }; + + const { rerender } = renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledTimes(1); + }); + + vi.clearAllMocks(); + + // Second update with success + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + status: 'completed', + progress: 100, + }, + }; + + rerender( + + + + ); + + // Should not show notification due to previous error + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + + describe('Playlist Validation', () => { + it('should not show notification if playlist not found', async () => { + mockPlaylistsStore.playlists = []; + mockPlaylistsStore.refreshProgress = { + 999: { + account: 999, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + + it('should handle multiple playlists correctly', async () => { + const secondPlaylist = { id: 2, name: 'Second Playlist' }; + mockPlaylistsStore.playlists = [mockPlaylist, secondPlaylist]; + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + 2: { + account: 2, + action: 'downloading', + progress: 100, + status: 'completed', + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledTimes(2); + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'M3U Processing: Test Playlist', + }) + ); + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'M3U Processing: Second Playlist', + }) + ); + }); + }); + }); + + describe('Notification Deduplication', () => { + it('should not show duplicate notification for same status', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + const { rerender } = renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledTimes(1); + }); + + vi.clearAllMocks(); + + // Re-render with same data + rerender( + + + + ); + + expect(showNotification).not.toHaveBeenCalled(); + }); + + it('should show notification when status changes', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + const { rerender } = renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledTimes(1); + }); + + vi.clearAllMocks(); + + // Update with different progress + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 100, + status: 'completed', + }, + }; + + rerender( + + + + ); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('State Cleanup', () => { + it('should reset notification status when playlists change', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + const { rerender } = renderWithProviders(); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + + // Change playlists - remove existing playlist + mockPlaylistsStore.playlists = []; + mockPlaylistsStore.refreshProgress = { + 2: { + account: 2, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + rerender( + + + + ); + + // Should not show notification because playlist doesn't exist + expect(showNotification).not.toHaveBeenCalled(); + }); + + it('should handle empty playlists array', async () => { + mockPlaylistsStore.playlists = []; + mockPlaylistsStore.refreshProgress = {}; + + renderWithProviders(); + + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Effect Dependencies', () => { + it('should re-run effect when refreshProgress changes', async () => { + mockPlaylistsStore.refreshProgress = {}; + + const { rerender } = renderWithProviders(); + + expect(showNotification).not.toHaveBeenCalled(); + + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'parsing', + progress: 0, + status: 'in_progress', + }, + }; + + rerender( + + + + ); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalled(); + }); + }); + + it('should re-run effect when playlists change', async () => { + const { rerender } = renderWithProviders(); + + const newPlaylist = { id: 2, name: 'New Playlist' }; + mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist]; + + rerender( + + + + ); + }); + }); +}); diff --git a/frontend/src/components/__tests__/RecordingSynopsis.test.jsx b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx new file mode 100644 index 00000000..482d7e41 --- /dev/null +++ b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import RecordingSynopsis from '../RecordingSynopsis'; + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => { + return ( +
+ {children} +
+ ); + }, + }; +}); + +describe('RecordingSynopsis', () => { + let mockOnOpen; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnOpen = vi.fn(); + }); + + describe('Rendering', () => { + it('should render with short description', () => { + const shortDescription = 'This is a short description.'; + + render( + + ); + + const text = screen.getByText(shortDescription); + expect(text).toBeInTheDocument(); + }); + + it('should return null when description is undefined', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should return null when description is null', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should return null when description is empty string', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render without onOpen callback', () => { + const description = 'Test description'; + + render(); + + expect(screen.getByText(description)).toBeInTheDocument(); + }); + }); + + describe('Text Truncation', () => { + it('should not truncate description with exactly 140 characters', () => { + const exactLength = 'A'.repeat(140); + + render( + + ); + + expect(screen.getByText(exactLength)).toBeInTheDocument(); + expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument(); + }); + + it('should truncate description with 141 characters', () => { + const overLength = 'A'.repeat(141); + + render( + + ); + + const expectedPreview = `${overLength.slice(0, 140).trim()}...`; + expect(screen.getByText(expectedPreview)).toBeInTheDocument(); + }); + + it('should trim whitespace before adding ellipsis', () => { + const description = 'A'.repeat(135) + ' B'.repeat(10); + + render( + + ); + + const preview = screen.getByTestId('text').textContent; + expect(preview).toMatch(/\.\.\./); + expect(preview).not.toMatch(/\s+\.\.\./); + }); + }); + + describe('Click Interactions', () => { + it('should call onOpen when clicked', () => { + const description = 'Test description'; + + render( + + ); + + const text = screen.getByText(description); + fireEvent.click(text); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + }); + + it('should not throw error when clicked without onOpen callback', () => { + const description = 'Test description'; + + render(); + + const text = screen.getByText(description); + expect(() => fireEvent.click(text)).not.toThrow(); + }); + + it('should handle multiple clicks', () => { + const description = 'Test description'; + + render( + + ); + + const text = screen.getByText(description); + + fireEvent.click(text); + fireEvent.click(text); + fireEvent.click(text); + + expect(mockOnOpen).toHaveBeenCalledTimes(3); + }); + + it('should be clickable with truncated text', () => { + const longDescription = 'A'.repeat(200); + + render( + + ); + + const text = screen.getByTestId('text'); + fireEvent.click(text); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/__tests__/SeriesModal.test.jsx b/frontend/src/components/__tests__/SeriesModal.test.jsx new file mode 100644 index 00000000..acbd35c1 --- /dev/null +++ b/frontend/src/components/__tests__/SeriesModal.test.jsx @@ -0,0 +1,864 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import SeriesModal from '../SeriesModal'; +import useVODStore from '../../store/useVODStore'; +import useVideoStore from '../../store/useVideoStore'; +import useSettingsStore from '../../store/settings'; +import { copyToClipboard } from '../../utils'; + +// Mock stores +vi.mock('../../store/useVODStore', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/useVideoStore', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/settings', () => ({ + default: vi.fn(), +})); + +// Mock utils +vi.mock('../../utils', () => ({ + copyToClipboard: vi.fn(), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Play: () =>
, + Copy: () =>
, +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + const actual = await vi.importActual('@mantine/core'); + + return { + ...actual, + Modal: ({ opened, onClose, title, children, size, ...props }) => { + if (!opened) return null; + return ( +
+ +
{children}
+
+ ); + }, + Box: ({ children, ...props }) =>
{children}
, + Button: ({ children, onClick, disabled, ...props }) => ( + + ), + Flex: ({ children, ...props }) =>
{children}
, + Group: ({ children, ...props }) =>
{children}
, + Image: ({ src, alt, ...props }) => ( + {alt} + ), + Text: ({ children, ...props }) =>
{children}
, + Title: ({ children, order, ...props }) => ( +
{children}
+ ), + Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => ( +
+ +
+ ), + Badge: ({ children, ...props }) => {children}, + Loader: (props) =>
, + Stack: ({ children, ...props }) =>
{children}
, + ActionIcon: ({ children, onClick, disabled, ...props }) => ( + + ), + Tabs: ({ children, value, onChange, ...props }) => ( +
+
{ + const tab = e.target.closest('[data-tab-value]'); + if (tab) onChange?.(tab.dataset.tabValue); + }}> + {children} +
+
+ ), + TabsList: ({ children }) =>
{children}
, + TabsTab: ({ children, value }) => ( + + ), + TabsPanel: ({ children, value }) => ( +
{children}
+ ), + Table: ({ children, ...props }) => {children}
, + TableThead: ({ children }) => {children}, + TableTbody: ({ children }) => {children}, + TableTr: ({ children, onClick, ...props }) => ( + {children} + ), + TableTh: ({ children, ...props }) => {children}, + TableTd: ({ children, ...props }) => {children}, + Divider: (props) =>
, + }; +}); + +describe('SeriesModal', () => { + let mockVODStore; + let mockVideoStore; + let mockSettingsStore; + + const mockSeries = { + id: 1, + name: 'Test Series', + series_image: 'https://example.com/cover.jpg', + genre: 'Drama, Action', + cast: 'Actor 1, Actor 2', + director: 'Director Name', + m3u_account: { name: 'Test Account' }, + rating: '8.5', + release_date: '2020-01-01', + youtube_trailer: 'dQw4w9WgXcQ', + }; + + const mockEpisode = { + id: 1, + uuid: 'episode-uuid-1', + series_id: 1, + season_number: 1, + episode_number: 1, + name: 'Pilot', + duration_secs: 3600, + rating: '8.0', + container_extension: 'mkv', + added: '2024-01-01T00:00:00Z', + }; + + const mockDetailedSeries = { + ...mockSeries, + episodesList: [mockEpisode], + tmdb_id: '12345', + imdb_id: 'tt1234567', + }; + + const mockProviders = [ + { + id: 1, + stream_id: 100, + account_id: 5, + m3u_account: { name: 'Provider 1' }, + stream_name: 'Test Series 1080p', + quality_info: { quality: '1080p' }, + }, + { + id: 2, + stream_id: 101, + account_id: 5, + m3u_account: { name: 'Provider 2' }, + stream_name: 'Test Series 720p', + quality_info: null, + } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + + mockVODStore = { + fetchSeriesInfo: vi.fn().mockResolvedValue(mockDetailedSeries), + fetchSeriesProviders: vi.fn().mockResolvedValue(mockProviders), + }; + + mockVideoStore = { + showVideo: vi.fn(), + }; + + mockSettingsStore = { + environment: { env_mode: 'prod' }, + }; + + useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore); + useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore); + useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore); + + copyToClipboard.mockResolvedValue(undefined); + }); + + describe('Rendering', () => { + it('should render nothing when series is null', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when modal is closed', () => { + render( + + ); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should render modal when opened with series', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + }); + + it('should display series name as title', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Test Series')).toBeInTheDocument(); + }); + }); + + it('should display cover image', async () => { + render( + + ); + + await waitFor(() => { + const image = screen.getByAltText('Test Series'); + expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg'); + }); + }); + + it('should show loader while fetching details', () => { + mockVODStore.fetchSeriesInfo = vi.fn(() => new Promise(() => {})); + + render( + + ); + + //expect multiple loader instances since we have one for details and one for providers + const loaders = screen.getAllByTestId('loader'); + expect(loaders.length).toBeGreaterThan(0); + }); + }); + + describe('Data Fetching', () => { + it('should fetch series info when opened', async () => { + render( + + ); + + await waitFor(() => { + expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1); + }); + }); + + it('should fetch series providers when opened', async () => { + render( + + ); + + await waitFor(() => { + expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1); + }); + }); + + it('should not fetch data when modal is closed', () => { + render( + + ); + + expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled(); + expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled(); + }); + + it('should reset state when modal closes', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled(); + }); + + rerender( + + ); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + describe('Series Information Display', () => { + it('should display genre', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Drama, Action/)).toBeInTheDocument(); + }); + }); + + it('should display rating', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/8\.5/)).toBeInTheDocument(); + }); + }); + + it('should display IMDB link when imdb_id exists', async () => { + render( + + ); + + await waitFor(() => { + const link = screen.getByText(/IMDB/i).closest('a'); + expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567'); + }); + }); + + it('should display TMDB link when tmdb_id exists', async () => { + render( + + ); + + await waitFor(() => { + const link = screen.getByText(/TMDB/i).closest('a'); + expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345'); + }); + }); + }); + + describe('Provider Selection', () => { + it('should display provider select with fetched providers', async () => { + render( + + ); + + await waitFor(() => { + const select = screen.getByTestId('select'); + expect(select).toBeInTheDocument(); + }); + }); + + it('should format provider label correctly with quality info', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument(); + }); + }); + + it('should handle provider selection', async () => { + render( + + ); + + let select; + await waitFor(() => { + select = screen.getByTestId('select').querySelector('select'); + fireEvent.change(select, { target: { value: '1' } }); + }); + + await waitFor(() => { + expect(select.value).toBe('1'); + }); + }); + + it('should show loader while fetching providers', () => { + mockVODStore.fetchSeriesProviders = vi.fn(() => new Promise(() => {})); + + render( + + ); + + // Should show loading text and loader + expect(screen.getByText('Stream Selection')).toBeInTheDocument(); + const loaders = screen.getAllByTestId('loader'); + expect(loaders.length).toBeGreaterThan(0); + }); + }); + + describe('Episodes Display', () => { + it('should display episodes grouped by season', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Season 1/i)).toBeInTheDocument(); + }); + }); + + it('should display episode information', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Pilot/)).toBeInTheDocument(); + }); + }); + + it('should format episode duration correctly', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/1h 0m/)).toBeInTheDocument(); + }); + }); + + it('should handle episodes with no duration', async () => { + const episodeNoDuration = { ...mockEpisode, duration_secs: null }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [episodeNoDuration], + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Pilot/)).toBeInTheDocument(); + }); + }); + + it('should sort episodes by episode number', async () => { + const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [episode2, mockEpisode], + }); + + render( + + ); + + await waitFor(() => { + const episodes = screen.getAllByTestId('table-tr'); + expect(episodes[1]).toHaveTextContent('Pilot'); + expect(episodes[2]).toHaveTextContent('Second Episode'); + }); + }); + }); + + describe('Episode Actions', () => { + it('should play episode when play button clicked', async () => { + render( + + ); + + await waitFor(() => { + const playButtons = screen.getAllByTestId('action-icon'); + fireEvent.click(playButtons[0]); + }); + + expect(mockVideoStore.showVideo).toHaveBeenCalledWith( + expect.stringContaining('/proxy/vod/episode/episode-uuid-1'), + 'vod', + mockEpisode + ); + }); + + it('should include provider stream_id in URL when provider selected', async () => { + render( + + ); + + await waitFor(() => { + const select = screen.getByTestId('select').querySelector('select'); + fireEvent.change(select, { target: { value: '1' } }); + }); + + await waitFor(() => { + const playButtons = screen.getAllByTestId('action-icon'); + fireEvent.click(playButtons[0]); + }); + + expect(mockVideoStore.showVideo).toHaveBeenCalledWith( + expect.stringContaining('stream_id=100'), + 'vod', + mockEpisode + ); + }); + + it('should use dev mode URL in development', async () => { + mockSettingsStore.environment.env_mode = 'dev'; + + render( + + ); + + await waitFor(() => { + const playButtons = screen.getAllByTestId('action-icon'); + fireEvent.click(playButtons[0]); + }); + + expect(mockVideoStore.showVideo).toHaveBeenCalledWith( + expect.stringContaining('localhost:5656'), + 'vod', + mockEpisode + ); + }); + + it('should copy episode link when copy button clicked', async () => { + render( + + ); + + await waitFor(() => { + const copyButtons = screen.getAllByTestId('action-icon'); + fireEvent.click(copyButtons[1]); + }); + + expect(copyToClipboard).toHaveBeenCalledWith( + expect.stringContaining('/proxy/vod/episode/episode-uuid-1'), + expect.objectContaining({ + successTitle: 'Link Copied!', + successMessage: 'Episode link copied to clipboard', + }) + ); + }); + + it('should expand episode details when row clicked', async () => { + render( + + ); + + await waitFor(() => { + const rows = screen.getAllByTestId('table-tr'); + fireEvent.click(rows[1]); + }); + + await waitFor(() => { + expect(screen.getByText(/8\.0/)).toBeInTheDocument(); + }); + }); + + it('should collapse episode details when clicked again', async () => { + render( + + ); + + await waitFor(() => { + const rows = screen.getAllByTestId('table-tr'); + fireEvent.click(rows[1]); + }); + + await waitFor(() => { + expect(screen.getByText(/8\.0/)).toBeInTheDocument(); + }); + + await waitFor(() => { + const rows = screen.getAllByTestId('table-tr'); + fireEvent.click(rows[1]); + }); + + await waitFor(() => { + expect(screen.queryByText(/8\.0/)).not.toBeInTheDocument(); + }); + }); + }); + + it('should show trailer button when youtube_trailer exists', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Watch Trailer')).toBeInTheDocument(); + }); + }); + + describe('Season Tabs', () => { + it('should create tabs for each season', async () => { + const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [mockEpisode, season2Episode], + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Season 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Season 2/i)).toBeInTheDocument(); + }); + }); + + it('should sort seasons in ascending order', async () => { + const season3Episode = { ...mockEpisode, id: 3, season_number: 3 }; + const season2Episode = { ...mockEpisode, id: 2, season_number: 2 }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [season3Episode, mockEpisode, season2Episode], + }); + + render( + + ); + + await waitFor(() => { + const tabs = screen.getAllByTestId('tabs-tab'); + expect(tabs[0]).toHaveTextContent('Season 1'); + expect(tabs[1]).toHaveTextContent('Season 2'); + expect(tabs[2]).toHaveTextContent('Season 3'); + }); + }); + + it('should activate first season tab by default', async () => { + render( + + ); + + await waitFor(() => { + const tabs = screen.getByTestId('tabs'); + expect(tabs).toHaveAttribute('data-value', 'season-1'); + }); + }); + }); + + describe('Quality Info Extraction', () => { + it('should extract quality from quality_info field', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument(); + }); + }); + + it('should extract quality from custom_properties.detailed_info.video', async () => { + const customProviders = [ + { + ...mockProviders[0], + quality_info: null, + custom_properties: { + detailed_info: { + video: { width: 1920, height: 1080 }, + }, + }, + }, + mockProviders[1], + ]; + mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders); + + render( + + ); + + + await waitFor(() => { + expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument(); + }); + }); + + it('should extract quality from stream_name as fallback', async () => { + const customProviders = [ + { + ...mockProviders[0], + quality_info: null, + stream_name: 'Test Series 720p', + }, + mockProviders[1], + ]; + mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Provider 1 - 720p/)).toBeInTheDocument(); + }); + }); + + it('should detect 4K quality', async () => { + const customProviders = [ + { + ...mockProviders[0], + quality_info: null, + custom_properties: { + detailed_info: { + video: { width: 3840, height: 2160 }, + }, + }, + }, + mockProviders[1], + ]; + mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Provider 1 - 4K/)).toBeInTheDocument(); + }); + }); + + it('should show resolution when standard quality not detected', async () => { + const customProviders = [ + { + ...mockProviders[0], + quality_info: null, + custom_properties: { + detailed_info: { + video: { width: 720, height: 480 }, + }, + }, + }, + mockProviders[1], + ]; + mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/720x480/)).toBeInTheDocument(); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle series with no episodes', async () => { + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [], + }); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('table')).not.toBeInTheDocument(); + }); + }); + + it('should handle fetch errors gracefully', async () => { + mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed')); + + render( + + ); + + await waitFor(() => { + expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled(); + }); + }); + + it('should handle episodes with null season_number', async () => { + const episodeNoSeason = { ...mockEpisode, season_number: null }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [episodeNoSeason], + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText(/Season 1/i)).toBeInTheDocument(); + }); + }); + + it('should handle empty provider list', async () => { + mockVODStore.fetchSeriesProviders.mockResolvedValue([]); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Test Account')).toBeInTheDocument() + }); + }); + }); + + describe('Modal Close', () => { + it('should call onClose when close button clicked', async () => { + const onClose = vi.fn(); + + render( + + ); + + await waitFor(() => { + const closeButton = screen.getByTestId('modal-close'); + fireEvent.click(closeButton); + }); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe('Helper Functions', () => { + it('should format duration with hours and minutes', () => { + const duration = 7265; // 2h 1m 5s + // This tests the formatDuration function indirectly through episode display + const episode = { + ...mockEpisode, + info: { ...mockEpisode.info, duration }, + }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [episode], + }); + + render( + + ); + + waitFor(() => { + expect(screen.getByText(/2h 1m/)).toBeInTheDocument(); + }); + }); + + it('should format duration with minutes and seconds when under an hour', () => { + const duration = 125; // 2m 5s + const episode = { + ...mockEpisode, + info: { ...mockEpisode.info, duration }, + }; + mockVODStore.fetchSeriesInfo.mockResolvedValue({ + ...mockDetailedSeries, + episodesList: [episode], + }); + + render( + + ); + + waitFor(() => { + expect(screen.getByText(/2m 5s/)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx new file mode 100644 index 00000000..341d0c32 --- /dev/null +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -0,0 +1,455 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import Sidebar from '../Sidebar'; +import useChannelsStore from '../../store/channels'; +import useSettingsStore from '../../store/settings'; +import useAuthStore from '../../store/auth'; +import { copyToClipboard } from '../../utils'; +import { USER_LEVELS } from '../../constants'; + +// Mock stores +vi.mock('../../store/channels'); +vi.mock('../../store/settings'); +vi.mock('../../store/auth'); +vi.mock('../../utils', () => ({ + copyToClipboard: vi.fn(), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + ListOrdered: ({ onClick }) =>
, + Play: ({ onClick }) =>
, + Database: ({ onClick }) =>
, + LayoutGrid: ({ onClick }) =>
, + Settings: ({ onClick }) =>
, + Copy: ({ onClick }) =>
, + ChartLine: ({ onClick }) =>
, + Video: ({ onClick }) =>
, + PlugZap: ({ onClick }) =>
, + LogOut: ({ onClick }) =>
, + User: ({ onClick }) =>
, + FileImage: ({ onClick }) =>
, +})); + +// Mock UserForm component +vi.mock('../forms/User', () => ({ + default: ({ isOpen, onClose, user }) => ( + isOpen ? ( +
+ User Form for {user?.username} + +
+ ) : null + ), +})); + +vi.mock('@mantine/core', async () => { + return { + Avatar: ({ children }) =>
{children}
, + Group: ({ children, onClick, ...props }) => ( +
{children}
+ ), + Stack: ({ children }) =>
{children}
, + Box: ({ children }) =>
{children}
, + Text: ({ children }) =>
{children}
, + UnstyledButton: ({ children, onClick, component, to, className }) => { + const Component = component || 'button'; + return ( + + {children} + + ); + }, + TextInput: ({ value, onChange, leftSection, rightSection, label }) => ( +
+ {label && } + {leftSection} + + {rightSection} +
+ ), + ActionIcon: ({ children, onClick, ...props }) => ( + + ), + AppShellNavbar: ({ children, style, width, ...props }) => ( + + ), + }; +}); + +const mockChannels = { + 'channel-1': { id: 'channel-1', name: 'Channel 1' }, + 'channel-2': { id: 'channel-2', name: 'Channel 2' }, + 'channel-3': { id: 'channel-3', name: 'Channel 3' }, +}; + +const mockEnvironment = { + public_ip: '192.168.1.1', + country_code: 'US', + country_name: 'United States', +}; + +const mockVersion = { + version: '1.2.3', + timestamp: '20240115', +}; + +const mockAdminUser = { + id: 1, + username: 'admin', + first_name: 'Admin', + user_level: USER_LEVELS.ADMIN, +}; + +const mockRegularUser = { + id: 2, + username: 'user', + first_name: 'John', + user_level: USER_LEVELS.USER, +}; + +const renderSidebar = (props = {}) => { + const defaultProps = { + collapsed: false, + toggleDrawer: vi.fn(), + drawerWidth: 250, + miniDrawerWidth: 80, + }; + + return render( + + + + ); +}; + +describe('Sidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + + useChannelsStore.mockReturnValue(mockChannels); + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: mockVersion, + }; + return selector(state); + }); + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockAdminUser, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + describe('Brand Section', () => { + it('should render logo and brand name when expanded', () => { + const { container } = renderSidebar(); + + expect(screen.getByText('Dispatcharr')).toBeInTheDocument(); + const logo = container.querySelectorAll('img[src="/src/images/logo.png"]'); + expect(logo).toHaveLength(1); + }); + + it('should hide brand name when collapsed', () => { + renderSidebar({ collapsed: true }); + expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument(); + }); + + it('should toggle drawer when brand is clicked', () => { + const toggleDrawer = vi.fn(); + renderSidebar({ toggleDrawer }); + + const brand = screen.getByText('Dispatcharr').closest('div'); + fireEvent.click(brand); + + expect(toggleDrawer).toHaveBeenCalledTimes(1); + }); + }); + + describe('Navigation Links - Admin User', () => { + it('should render all admin navigation items', () => { + renderSidebar(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('VODs')).toBeInTheDocument(); + expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('DVR')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + expect(screen.getByText('Plugins')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Logo Manager')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('should display channel count badge', () => { + renderSidebar(); + expect(screen.getByText('(3)')).toBeInTheDocument(); + }); + + it('should hide labels and badges when collapsed', () => { + renderSidebar({ collapsed: true }); + + expect(screen.queryByText('Channels')).not.toBeInTheDocument(); + expect(screen.queryByText('(3)')).not.toBeInTheDocument(); + }); + }); + + describe('Navigation Links - Regular User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockRegularUser, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + it('should render limited navigation items for regular user', () => { + renderSidebar(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + expect(screen.queryByText('VODs')).not.toBeInTheDocument(); + expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument(); + expect(screen.queryByText('DVR')).not.toBeInTheDocument(); + expect(screen.queryByText('Stats')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + }); + }); + + describe('Profile Section - Authenticated', () => { + it('should render public IP with country flag', () => { + renderSidebar(); + + const ipInput = screen.getByDisplayValue('192.168.1.1'); + expect(ipInput).toBeInTheDocument(); + + const flag = screen.getByAltText('United States'); + expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png'); + }); + + it('should copy public IP to clipboard when copy button is clicked', async () => { + copyToClipboard.mockResolvedValue(); + renderSidebar(); + + const copyButton = screen.getByTestId('copy-icon').closest('button'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', { + successTitle: 'Success', + successMessage: 'Public IP copied to clipboard', + }); + }); + }); + + it('should render user avatar and name', () => { + renderSidebar(); + + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('should fallback to username if first_name is not set', () => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: { ...mockAdminUser, first_name: null }, + logout: vi.fn(), + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('should open user form when username is clicked', () => { + renderSidebar(); + + const nameButton = screen.getByText('Admin'); + fireEvent.click(nameButton); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByText('User Form for admin')).toBeInTheDocument(); + }); + + it('should close user form when close button is clicked', () => { + renderSidebar(); + + const nameButton = screen.getByText('Admin'); + fireEvent.click(nameButton); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); + + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('should logout when logout button is clicked', () => { + const mockLogout = vi.fn(); + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockAdminUser, + logout: mockLogout, + }; + return selector(state); + }); + + renderSidebar(); + + const logoutIcon = screen.getByTestId('logout-icon'); + fireEvent.click(logoutIcon); + + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + + it('should hide profile details when collapsed', () => { + renderSidebar({ collapsed: true }); + + expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument(); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Profile Section - Not Authenticated', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: false, + user: null, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + it('should not render profile section when not authenticated', () => { + renderSidebar(); + + expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument(); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Version Display', () => { + it('should render version when expanded', () => { + renderSidebar(); + expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument(); + }); + + it('should render version without timestamp if not available', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: { version: '1.2.3' }, + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('v1.2.3')).toBeInTheDocument(); + }); + + it('should render default version if not loaded', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: null, + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + + it('should hide version when collapsed', () => { + renderSidebar({ collapsed: true }); + expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument(); + }); + }); + + describe('Collapsed State', () => { + it('should apply collapsed class to navigation links', () => { + renderSidebar({ collapsed: true }); + + const links = screen.getAllByRole('link'); + links.forEach(link => { + expect(link).toHaveClass('navlink-collapsed'); + }); + }); + + it('should adjust width based on collapsed state', () => { + const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 }); + const navbar = screen.getByRole('navigation'); + + expect(navbar).toHaveStyle({ width: '250px' }); + + rerender( + + + + ); + + expect(navbar).toHaveStyle({ width: '80px' }); + }); + }); + + describe('Environment Edge Cases', () => { + it('should handle missing country code gracefully', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: { public_ip: '192.168.1.1' }, + version: mockVersion, + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument(); + }); + + it('should use country code as alt text if country name is missing', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: { + public_ip: '192.168.1.1', + country_code: 'US', + }, + version: mockVersion, + }; + return selector(state); + }); + + renderSidebar(); + const flag = screen.getByAltText('US'); + expect(flag).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/SystemEvents.test.jsx b/frontend/src/components/__tests__/SystemEvents.test.jsx new file mode 100644 index 00000000..238d3fce --- /dev/null +++ b/frontend/src/components/__tests__/SystemEvents.test.jsx @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import SystemEvents from '../SystemEvents'; +import API from '../../api'; +import useLocalStorage from "../../hooks/useLocalStorage"; + +// Mock the API module +vi.mock('../../api', () => ({ + default: { + getSystemEvents: vi.fn(), + }, +})); + +// Mock the useLocalStorage hook +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn((key, defaultValue) => { + const mockSetters = { + 'events-refresh-interval': vi.fn(), + 'events-limit': vi.fn(), + 'date-format': vi.fn(), + }; + return [defaultValue, mockSetters[key] || vi.fn()]; + }), +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + ActionIcon: ({ children, onClick }) => ( + + ), + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick }) => ( + + ), + Card: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + NumberInput: ({ value, onChange, label }) => ( + onChange(Number(e.target.value))} + /> + ), + Pagination: ({ page, onChange, total }) => ( +
+ {Array.from({ length: Math.ceil(total / 100) }, (_, i) => ( + + ))} +
+ ), + Select: ({ value, onChange, data }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Text: ({ children }) =>
{children}
, + Title: ({ children }) =>

{children}

, + }; +}); + +// Mock the dateTimeUtils +vi.mock('../../utils/dateTimeUtils.js', () => ({ + format: vi.fn((timestamp, format) => '01/15 10:30:45'), +})); + +const mockEventsResponse = { + events: [ + { + id: 1, + event_type: 'channel_start', + event_type_display: 'Channel Started', + channel_name: 'Test Channel', + timestamp: '2024-01-15T10:30:45Z', + details: { bitrate: '5000kbps' }, + }, + { + id: 2, + event_type: 'login_success', + event_type_display: 'Login Successful', + timestamp: '2024-01-15T10:25:30Z', + details: {}, + }, + ], + total: 2, +}; + +describe('SystemEvents', () => { + beforeEach(() => { + vi.clearAllMocks(); + API.getSystemEvents.mockResolvedValue(mockEventsResponse); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + it('should render component with title', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('System Events')).toBeInTheDocument(); + }); + }); + + it('should fetch and display events on mount', async () => { + render(); + + await waitFor(() => { + expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0); + }); + }); + + it('should expand and show events when chevron is clicked', async () => { + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText('Channel Started')).toBeInTheDocument(); + expect(screen.getByText('Login Successful')).toBeInTheDocument(); + }); + }); + + it('should display "No events recorded yet" when events array is empty', async () => { + API.getSystemEvents.mockResolvedValue({ events: [], total: 0 }); + + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText('No events recorded yet')).toBeInTheDocument(); + }); + }); + + it('should call fetchEvents when refresh button is clicked', async () => { + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText('Refresh')).toBeInTheDocument(); + }); + + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(API.getSystemEvents).toHaveBeenCalledTimes(3) + }); + }); + + it('should update events limit when changed', async () => { + const mockSetEventsLimit = vi.fn(); + + // Update the mock to return the setter for this test + useLocalStorage.mockImplementation((key, defaultValue) => { + if (key === 'events-limit') { + return [100, mockSetEventsLimit]; + } + return [defaultValue, vi.fn()]; + }); + + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + const input = screen.getByLabelText('Events Per Page'); + fireEvent.change(input, { target: { value: '50' } }); + }); + + expect(mockSetEventsLimit).toHaveBeenCalled(); + }); + + it('should show pagination when total events exceed limit', async () => { + API.getSystemEvents.mockResolvedValue({ + events: mockEventsResponse.events, + total: 150, + }); + + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText(/Showing 1-100 of 150/)).toBeInTheDocument(); + }); + }); + + it('should handle API errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + API.getSystemEvents.mockRejectedValue(new Error('API Error')); + + render(); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching system events:', + expect.any(Error) + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('should display event details correctly', async () => { + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText('Test Channel')).toBeInTheDocument(); + expect(screen.getByText('bitrate: 5000kbps')).toBeInTheDocument(); + }); + }); + + it('should change page when pagination is clicked', async () => { + API.getSystemEvents.mockResolvedValue({ + events: mockEventsResponse.events, + total: 250, + }); + + render(); + + const expandButton = screen.getByRole('button', { name: '' }); + fireEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText(/Showing 1-100 of 250/)).toBeInTheDocument(); + }); + + // Note: Pagination interaction would require more specific selectors + // based on Mantine's Pagination component implementation + }); +}); diff --git a/frontend/src/components/__tests__/VODModal.test.jsx b/frontend/src/components/__tests__/VODModal.test.jsx new file mode 100644 index 00000000..7dc6ed40 --- /dev/null +++ b/frontend/src/components/__tests__/VODModal.test.jsx @@ -0,0 +1,439 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import VODModal from '../VODModal'; +import useVODStore from '../../store/useVODStore'; +import useVideoStore from '../../store/useVideoStore'; +import useSettingsStore from '../../store/settings'; + +// Mock stores +vi.mock('../../store/useVODStore'); +vi.mock('../../store/useVideoStore'); +vi.mock('../../store/settings'); + +// Mock utils +vi.mock('../../utils', () => ({ + copyToClipboard: vi.fn(() => Promise.resolve()), +})); + +vi.mock('../../utils/components/SeriesModalUtils.js', () => ({ + formatStreamLabel: vi.fn((provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`), + imdbUrl: vi.fn((id) => `https://www.imdb.com/title/${id}`), + tmdbUrl: vi.fn((id, type) => `https://www.themoviedb.org/${type}/${id}`), + formatDuration: vi.fn((secs) => `${Math.floor(secs / 60)} min`), + getYouTubeEmbedUrl: vi.fn((url) => `https://www.youtube.com/embed/${url}`), +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Box: ({ children, ...props }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Image: ({ src, alt }) => {alt}, + Text: ({ children, ...props }) => {children}, + Title: ({ children }) =>

{children}

, + Button: ({ children, onClick, disabled, leftSection }) => ( + + ), + Badge: ({ children, component, href, ...props }) => + component === 'a' ? ( + + {children} + + ) : ( + {children} + ), + Select: ({ data, value, onChange, placeholder, disabled }) => ( + + ), + Loader: () =>
Loading...
, + }; +}); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Play: () => Play Icon, + Copy: () => Copy Icon, +})); + +describe('VODModal', () => { + const mockShowVideo = vi.fn(); + const mockFetchMovieDetailsFromProvider = vi.fn(); + const mockFetchMovieProviders = vi.fn(); + const mockOnClose = vi.fn(); + + const mockVOD = { + id: 1, + uuid: 'test-uuid', + name: 'Test Movie', + o_name: 'Original Test Movie', + year: 2023, + duration_secs: 7200, + rating: '8.5', + age: 'PG-13', + imdb_id: 'tt1234567', + tmdb_id: '12345', + release_date: '2023-01-15', + genre: 'Action, Sci-Fi', + director: 'Test Director', + actors: 'Actor 1, Actor 2', + country: 'USA', + description: 'A test movie description', + youtube_trailer: 'test-trailer-id', + movie_image: 'https://example.com/poster.jpg', + backdrop_path: ['https://example.com/backdrop.jpg'], + bitrate: 5000, + m3u_account: { name: 'Test Account', id: 1 }, + }; + + const mockProvider = { + id: 1, + stream_id: 'stream-123', + m3u_account: { name: 'Test Provider', id: 1 }, + bitrate: 6000, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + useVideoStore.mockImplementation((selector) => { + const state = { showVideo: mockShowVideo }; + return selector ? selector(state) : state; + }); + + useVODStore.mockImplementation((selector) => { + const state = { + fetchMovieDetailsFromProvider: mockFetchMovieDetailsFromProvider, + fetchMovieProviders: mockFetchMovieProviders, + }; + return selector ? selector(state) : state; + }); + + useSettingsStore.mockImplementation((selector) => { + const state = { environment: { env_mode: 'prod' } }; + return selector ? selector(state) : state; + }); + + mockFetchMovieDetailsFromProvider.mockResolvedValue(mockVOD); + mockFetchMovieProviders.mockResolvedValue([mockProvider]); + }); + + it('should not render when vod is null', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should render modal when opened with vod', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie'); + }); + + it('should not render when closed', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should display movie details correctly', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument(); + }); + + expect(screen.getByText('2023')).toBeInTheDocument(); + expect(screen.getByText('8.5')).toBeInTheDocument(); + expect(screen.getByText('PG-13')).toBeInTheDocument(); + expect(screen.getByText('Action, Sci-Fi')).toBeInTheDocument(); + expect(screen.getByText(/Test Director/)).toBeInTheDocument(); + expect(screen.getByText(/Actor 1, Actor 2/)).toBeInTheDocument(); + expect(screen.getByText(/A test movie description/)).toBeInTheDocument(); + }); + + it('should fetch movie details on mount', async () => { + render(); + + await waitFor(() => { + expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id); + }); + }); + + it('should fetch movie providers on mount', async () => { + render(); + + await waitFor(() => { + expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id); + }); + }); + + it('should show loading state while fetching details', () => { + mockFetchMovieDetailsFromProvider.mockImplementation( + () => new Promise(() => {}) + ); + + render(); + + expect(screen.getByText('Loading additional details...')).toBeInTheDocument(); + }); + + it('should handle play button click', async () => { + render(); + + await waitFor(() => { + expect(mockFetchMovieProviders).toHaveBeenCalled(); + }); + + const playButton = screen.getByText('Play Movie'); + fireEvent.click(playButton); + + expect(mockShowVideo).toHaveBeenCalled(); + }); + + it('should disable play button when multiple providers and none selected', async () => { + mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Play Movie')).toBeInTheDocument(); + }); + + // Note: Testing for disabled state would require checking the button's disabled attribute + }); + + it('should handle provider selection', async () => { + const providers = [ + mockProvider, + { ...mockProvider, id: 2, stream_id: 'stream-456' }, + ]; + mockFetchMovieProviders.mockResolvedValue(providers); + + render(); + + await waitFor(() => { + const select = screen.getByTestId('provider-select'); + expect(select).toBeInTheDocument(); + }); + + const select = screen.getByTestId('provider-select'); + fireEvent.change(select, { target: { value: '2' } }); + + await waitFor(() => { + const select = screen.getByTestId('provider-select'); + expect(select).toHaveValue('2'); + }); + }); + + it('should display single provider as badge', async () => { + mockFetchMovieProviders.mockResolvedValue([mockProvider]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Test Provider')).toBeInTheDocument(); + }); + }); + + it('should handle fetch details error gracefully', async () => { + mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed')); + + render(); + + await waitFor(() => { + expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalled(); + }); + + // Should still display basic VOD info + expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie'); + }); + + it('should handle fetch providers error gracefully', async () => { + mockFetchMovieProviders.mockRejectedValue(new Error('Fetch failed')); + + render(); + + await waitFor(() => { + expect(mockFetchMovieProviders).toHaveBeenCalled(); + }); + + // Should still render without providers + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('should display technical details when available', async () => { + const vodWithTech = { + ...mockVOD, + bitrate: 5000, + video: { + codec_name: 'h264', + width: 1920, + height: 1080, + }, + audio: { + codec_name: 'aac', + channels: 2, + }, + }; + + mockFetchMovieDetailsFromProvider.mockResolvedValue(vodWithTech); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Technical Details:/)).toBeInTheDocument(); + }); + }); + + it('should render IMDb and TMDb badges with correct links', () => { + render(); + + const imdbLink = screen.getByText('IMDb'); + const tmdbLink = screen.getByText('TMDb'); + + expect(imdbLink).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567'); + expect(tmdbLink).toHaveAttribute('href', 'https://www.themoviedb.org/movie/12345'); + }); + + describe('Copy Link Functionality', () => { + it('should copy movie link when copy button clicked', async () => { + const { copyToClipboard } = await import('../../utils'); + + render(); + + await waitFor(() => { + const copyButton = screen.getByText('Copy Link'); + fireEvent.click(copyButton); + }); + + expect(copyToClipboard).toHaveBeenCalledWith( + expect.stringContaining('/proxy/vod/movie/test-uuid'), + expect.objectContaining({ + successTitle: expect.any(String), + successMessage: expect.any(String), + }) + ); + }); + + it('should include provider stream_id in copied URL when provider selected', async () => { + const { copyToClipboard } = await import('../../utils'); + + mockFetchMovieProviders.mockResolvedValue([ + { ...mockProvider, id: 1 }, + { ...mockProvider, id: 2, stream_id: 'stream-456' }, + ]); + + render(); + + await waitFor(() => { + const select = screen.getByTestId('provider-select'); + fireEvent.change(select, { target: { value: '2' } }); + }); + + await waitFor(() => { + const copyButton = screen.getByText('Copy Link') + fireEvent.click(copyButton); + }); + + expect(copyToClipboard).toHaveBeenCalledWith( + expect.stringContaining('stream_id=stream-456'), + expect.any(Object) + ); + }); + }); + + describe('URL Generation', () => { + it('should use dev mode URL in development', async () => { + useSettingsStore.mockImplementation((selector) => { + const state = { environment: { env_mode: 'dev' } }; + return selector ? selector(state) : state; + }); + + render(); + + await waitFor(() => { + const playButton = screen.getByText('Play Movie'); + fireEvent.click(playButton); + }); + + expect(mockShowVideo).toHaveBeenCalledWith( + expect.stringContaining('localhost:5656'), + 'vod', + expect.any(Object) + ); + }); + + it('should use production URL in production', async () => { + render(); + + await waitFor(() => { + const playButton = screen.getByText('Play Movie'); + fireEvent.click(playButton); + }); + + expect(mockShowVideo).toHaveBeenCalledWith( + expect.not.stringContaining('localhost:5656'), + 'vod', + expect.any(Object) + ); + }); + }); + + describe('Modal Interaction', () => { + it('should call onClose when close button clicked', async () => { + render(); + + await waitFor(() => { + const closeButton = screen.getByTestId('modal-close'); + fireEvent.click(closeButton); + }); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle VOD with no image', async () => { + const vodNoImage = { ...mockVOD, movie_image: null }; + render(); + + await waitFor(() => { + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + }); + + it('should handle VOD with missing optional fields', async () => { + const minimalVOD = { + id: 1, + uuid: 'test-uuid', + name: 'Test Movie', + m3u_account: { name: 'Test Account', id: 1 }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie'); + }); + }); + }); +}); diff --git a/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx new file mode 100644 index 00000000..da549949 --- /dev/null +++ b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { YouTubeTrailerModal } from '../YouTubeTrailerModal'; + +// Mock Mantine components +vi.mock('@mantine/core', () => ({ + Modal: ({ opened, onClose, title, children, size, centered }) => { + if (!opened) return null; + return ( +
+ +
{children}
+
+ ); + }, + Box: ({ children, ...props }) => ( +
{children}
+ ), +})); + +describe('YouTubeTrailerModal', () => { + const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ'; + const mockOnClose = vi.fn(); + + it('should not render when opened is false', () => { + render( + + ); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('should render modal when opened is true', () => { + render( + + ); + + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('should display correct modal title', () => { + render( + + ); + + const modal = screen.getByTestId('modal'); + expect(modal).toHaveAttribute('data-title', 'Trailer'); + }); + + it('should render iframe with correct trailerUrl', () => { + render( + + ); + + const iframe = screen.getByTitle('YouTube Trailer'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', mockTrailerUrl); + }); + + it('should not render iframe when trailerUrl is null', () => { + render( + + ); + + expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument(); + }); + + it('should call onClose when close button clicked', () => { + const mockClose = vi.fn(); + + render( + + ); + + const closeButton = screen.getByTestId('modal-close'); + closeButton.click(); + + expect(mockClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js new file mode 100644 index 00000000..8524a310 --- /dev/null +++ b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getLivePlayerErrorMessage, + getVODPlayerErrorMessage, + getClientCoordinates, + calculateNewDimensions, + applyConstraints, +} from '../FloatingVideoUtils'; + +describe('FloatingVideoUtils', () => { + describe('getLivePlayerErrorMessage', () => { + it('should return formatted error for non-MediaError types', () => { + expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe( + 'Error: NetworkError - Connection failed' + ); + }); + + it('should return error type only when no detail provided', () => { + expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError'); + }); + + it('should return audio codec message for audio-related errors', () => { + const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported'); + expect(result).toBe( + 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.' + ); + }); + + it('should return audio codec message for AC3 errors', () => { + const result = getLivePlayerErrorMessage('MediaError', 'AC3 codec issue'); + expect(result).toContain('Audio codec not supported'); + }); + + it('should return video codec message for video-related errors', () => { + const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed'); + expect(result).toBe( + 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.' + ); + }); + + it('should return MSE message for MSE-related errors', () => { + const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported'); + expect(result).toBe( + "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility." + ); + }); + + it('should return generic codec message for other MediaError cases', () => { + const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue'); + expect(result).toBe( + 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.' + ); + }); + + it('should handle null errorDetail for MediaError', () => { + const result = getLivePlayerErrorMessage('MediaError', null); + expect(result).toBe( + 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.' + ); + }); + }); + + describe('getVODPlayerErrorMessage', () => { + it('should return default message when error is null', () => { + expect(getVODPlayerErrorMessage(null)).toBe('Video playback error'); + }); + + it('should return aborted message for MEDIA_ERR_ABORTED', () => { + const error = { code: 1, MEDIA_ERR_ABORTED: 1 }; + expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted'); + }); + + it('should return network message for MEDIA_ERR_NETWORK', () => { + const error = { code: 2, MEDIA_ERR_NETWORK: 2 }; + expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video'); + }); + + it('should return codec message for MEDIA_ERR_DECODE', () => { + const error = { code: 3, MEDIA_ERR_DECODE: 3 }; + expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser'); + }); + + it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => { + const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 }; + expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser'); + }); + + it('should return error message for unknown error codes', () => { + const error = { code: 99, message: 'Custom error message' }; + expect(getVODPlayerErrorMessage(error)).toBe('Custom error message'); + }); + + it('should return default message for unknown error without message', () => { + const error = { code: 99 }; + expect(getVODPlayerErrorMessage(error)).toBe('Unknown video error'); + }); + }); + + describe('getClientCoordinates', () => { + it('should extract coordinates from mouse event', () => { + const event = { clientX: 100, clientY: 200 }; + expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 }); + }); + + it('should extract coordinates from touch event', () => { + const event = { touches: [{ clientX: 150, clientY: 250 }] }; + expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 }); + }); + + it('should prioritize touch coordinates over mouse coordinates', () => { + const event = { + touches: [{ clientX: 150, clientY: 250 }], + clientX: 100, + clientY: 200, + }; + expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 }); + }); + + it('should handle undefined coordinates', () => { + const event = {}; + expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined }); + }); + }); + + describe('calculateNewDimensions', () => { + const ratio = 16 / 9; + + it('should calculate dimensions based on horizontal drag', () => { + const handle = { xDir: 1, yDir: 0, isLeft: false, isTop: false }; + const result = calculateNewDimensions(100, 10, 400, 225, handle, ratio); + + expect(result.width).toBe(500); + expect(result.height).toBeCloseTo(281.25, 1); + }); + + it('should calculate dimensions based on vertical drag', () => { + const handle = { xDir: 0, yDir: 1, isLeft: false, isTop: false }; + const result = calculateNewDimensions(10, 100, 400, 225, handle, ratio); + + expect(result.height).toBe(325); + expect(result.width).toBeCloseTo(577.78, 1); + }); + + it('should use vertical-driven resize when vertical delta is larger', () => { + const handle = { xDir: 1, yDir: 1, isLeft: false, isTop: false }; + const result = calculateNewDimensions(20, 100, 400, 225, handle, ratio); + + expect(result.height).toBe(325); + expect(result.width).toBeCloseTo(577.78, 1); + }); + + it('should handle negative deltas', () => { + const handle = { xDir: -1, yDir: 0, isLeft: true, isTop: false }; + const result = calculateNewDimensions(-50, 0, 400, 225, handle, ratio); + + expect(result.width).toBe(450); + expect(result.height).toBeCloseTo(253.13, 1); + }); + }); + + describe('applyConstraints', () => { + const ratio = 16 / 9; + const minWidth = 200; + const minHeight = 112.5; + const visibleMargin = 50; + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 }); + Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 }); + }); + + it('should apply minimum width constraint', () => { + const handle = { isLeft: false, isTop: false }; + const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin); + + expect(result.width).toBe(minWidth); + expect(result.height).toBeCloseTo(minWidth / ratio, 1); + }); + + it('should apply minimum height constraint', () => { + const handle = { isLeft: false, isTop: false }; + const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin); + + expect(result.height).toBe(minHeight); + expect(result.width).toBeCloseTo(minHeight * ratio, 1); + }); + + it('should apply maximum width constraint based on viewport', () => { + const handle = { isLeft: false, isTop: false }; + const startPos = { x: 1200, y: 100 }; + const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin); + + const maxWidth = 1920 - 1200 - 50; // 670 + expect(result.width).toBe(maxWidth); + expect(result.height).toBeCloseTo(maxWidth / ratio, 1); + }); + + it('should apply maximum height constraint based on viewport', () => { + const handle = { isLeft: false, isTop: false }; + const startPos = { x: 100, y: 700 }; + const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin); + + const maxHeight = 1080 - 700 - 50; // 330 + expect(result.height).toBe(maxHeight); + expect(result.width).toBeCloseTo(maxHeight * ratio, 1); + }); + + + it('should not apply max width constraint for left handle', () => { + const handle = { isLeft: true, isTop: false }; + const startPos = { x: 1800, y: 100 }; + const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin); + + expect(result.width).toBe(500); + expect(result.height).toBeCloseTo(281.25, 1); + }); + + it('should not apply max height constraint for top handle', () => { + const handle = { isLeft: false, isTop: true }; + const startPos = { x: 100, y: 1000 }; + const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin); + + expect(result.width).toBe(500); + expect(result.height).toBe(400); + }); + + it('should handle null startPos', () => { + const handle = { isLeft: false, isTop: false }; + const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin); + + expect(result.width).toBe(300); + expect(result.height).toBeCloseTo(168.75, 1); + }); + }); +}); diff --git a/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js new file mode 100644 index 00000000..d986cfcc --- /dev/null +++ b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js @@ -0,0 +1,397 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + imdbUrl, + tmdbUrl, + formatDuration, + formatStreamLabel, + sortEpisodesList, + groupEpisodesBySeason, + sortBySeasonNumber, + getEpisodeStreamUrl, + getYouTubeEmbedUrl, + getEpisodeAirdate, + getTmdbUrlLink +} from '../SeriesModalUtils'; + +describe('SeriesModalUtils', () => { + describe('imdbUrl', () => { + it('should return IMDb URL with valid ID', () => { + expect(imdbUrl('tt1234567')).toBe('https://www.imdb.com/title/tt1234567'); + }); + + it('should return empty string when ID is null', () => { + expect(imdbUrl(null)).toBe(''); + }); + + it('should return empty string when ID is undefined', () => { + expect(imdbUrl(undefined)).toBe(''); + }); + }); + + describe('tmdbUrl', () => { + it('should return TMDB movie URL by default', () => { + expect(tmdbUrl('12345')).toBe('https://www.themoviedb.org/movie/12345'); + }); + + it('should return TMDB TV URL when type is tv', () => { + expect(tmdbUrl('12345', 'tv')).toBe('https://www.themoviedb.org/tv/12345'); + }); + + it('should return empty string when ID is null', () => { + expect(tmdbUrl(null)).toBe(''); + }); + }); + + describe('formatDuration', () => { + it('should format hours and minutes', () => { + expect(formatDuration(7200)).toBe('2h 0m'); + }); + + it('should format hours, minutes and seconds', () => { + expect(formatDuration(3665)).toBe('1h 1m'); + }); + + it('should format minutes and seconds when under an hour', () => { + expect(formatDuration(125)).toBe('2m 5s'); + }); + + it('should return empty string for 0 seconds', () => { + expect(formatDuration(0)).toBe(''); + }); + + it('should return empty string for null', () => { + expect(formatDuration(null)).toBe(''); + }); + }); + + describe('formatStreamLabel', () => { + const baseRelation = { + m3u_account: { name: 'Provider 1' }, + stream_id: 100 + }; + + it('should format label with quality from quality_info', () => { + const relation = { + ...baseRelation, + quality_info: { quality: '1080p' } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)'); + }); + + it('should format label with quality from resolution', () => { + const relation = { + ...baseRelation, + quality_info: { resolution: '4K' } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)'); + }); + + it('should format label with quality from bitrate', () => { + const relation = { + ...baseRelation, + quality_info: { bitrate: '8000kbps' } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 8000kbps (Stream 100)'); + }); + + it('should detect 4K from video dimensions', () => { + const relation = { + ...baseRelation, + custom_properties: { + detailed_info: { + video: { width: 3840, height: 2160 } + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)'); + }); + + it('should detect 1080p from video dimensions', () => { + const relation = { + ...baseRelation, + custom_properties: { + detailed_info: { + video: { width: 1920, height: 1080 } + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)'); + }); + + it('should detect 720p from video dimensions', () => { + const relation = { + ...baseRelation, + custom_properties: { + detailed_info: { + video: { width: 1280, height: 720 } + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)'); + }); + + it('should show custom dimensions for non-standard resolutions', () => { + const relation = { + ...baseRelation, + custom_properties: { + detailed_info: { + video: { width: 640, height: 480 } + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 640x480 (Stream 100)'); + }); + + it('should detect quality from detailed_info name field', () => { + const relation = { + ...baseRelation, + custom_properties: { + detailed_info: { + name: 'Stream 4K HDR' + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)'); + }); + + it('should detect quality from stream_name', () => { + const relation = { + ...baseRelation, + stream_name: 'Channel 1080p FHD' + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)'); + }); + + it('should detect 2160p as 4K', () => { + const relation = { + ...baseRelation, + stream_name: 'Stream 2160p' + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)'); + }); + + it('should detect FHD as 1080p', () => { + const relation = { + ...baseRelation, + stream_name: 'Stream FHD' + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)'); + }); + + it('should detect HD as 720p', () => { + const relation = { + ...baseRelation, + stream_name: 'Stream HD' + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)'); + }); + + it('should format without stream ID when not provided', () => { + const relation = { + m3u_account: { name: 'Provider 1' }, + quality_info: { quality: '1080p' } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p'); + }); + + it('should format without quality when not detected', () => { + const relation = { + m3u_account: { name: 'Provider 1' }, + stream_id: 100 + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 (Stream 100)'); + }); + + it('should prioritize quality_info over detailed_info', () => { + const relation = { + ...baseRelation, + quality_info: { quality: '4K' }, + custom_properties: { + detailed_info: { + video: { width: 1920, height: 1080 } + } + } + }; + expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)'); + }); + }); + + describe('sortEpisodesList', () => { + it('should sort episodes by season then episode number', () => { + const episodes = [ + { season_number: 2, episode_number: 1 }, + { season_number: 1, episode_number: 2 }, + { season_number: 1, episode_number: 1 } + ]; + const sorted = sortEpisodesList(episodes); + expect(sorted).toEqual([ + { season_number: 1, episode_number: 1 }, + { season_number: 1, episode_number: 2 }, + { season_number: 2, episode_number: 1 } + ]); + }); + + it('should handle missing season numbers as 0', () => { + const episodes = [ + { season_number: 1, episode_number: 1 }, + { episode_number: 1 } + ]; + const sorted = sortEpisodesList(episodes); + expect(sorted[0].season_number).toBeUndefined(); + }); + }); + + describe('groupEpisodesBySeason', () => { + it('should group episodes by season number', () => { + const episodes = [ + { season_number: 1, episode_number: 1 }, + { season_number: 1, episode_number: 2 }, + { season_number: 2, episode_number: 1 } + ]; + const grouped = groupEpisodesBySeason(episodes); + expect(grouped[1]).toHaveLength(2); + expect(grouped[2]).toHaveLength(1); + }); + + it('should default missing season numbers to 1', () => { + const episodes = [{ episode_number: 1 }]; + const grouped = groupEpisodesBySeason(episodes); + expect(grouped[1]).toHaveLength(1); + }); + }); + + describe('sortBySeasonNumber', () => { + it('should return season numbers in ascending order', () => { + const episodesBySeason = { + 3: [], + 1: [], + 2: [] + }; + const sorted = sortBySeasonNumber(episodesBySeason); + expect(sorted).toEqual([1, 2, 3]); + }); + }); + + describe('getEpisodeStreamUrl', () => { + beforeEach(() => { + delete window.location; + window.location = { + protocol: 'https:', + hostname: 'example.com', + origin: 'https://example.com' + }; + }); + + it('should generate stream URL without provider in production', () => { + const episode = { uuid: 'episode-123' }; + const url = getEpisodeStreamUrl(episode, null, 'prod'); + expect(url).toBe('https://example.com/proxy/vod/episode/episode-123'); + }); + + it('should generate stream URL with stream_id parameter', () => { + const episode = { uuid: 'episode-123' }; + const provider = { + stream_id: 'stream-456', + m3u_account: { id: 1 } + }; + const url = getEpisodeStreamUrl(episode, provider, 'prod'); + expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?stream_id=stream-456'); + }); + + it('should generate stream URL with m3u_account_id when no stream_id', () => { + const episode = { uuid: 'episode-123' }; + const provider = { + m3u_account: { id: 1 } + }; + const url = getEpisodeStreamUrl(episode, provider, 'prod'); + expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?m3u_account_id=1'); + }); + + it('should use dev port in dev mode', () => { + const episode = { uuid: 'episode-123' }; + const url = getEpisodeStreamUrl(episode, null, 'dev'); + expect(url).toBe('https://example.com:5656/proxy/vod/episode/episode-123'); + }); + + it('should encode special characters in stream_id', () => { + const episode = { uuid: 'episode-123' }; + const provider = { + stream_id: 'stream with spaces', + m3u_account: { id: 1 } + }; + const url = getEpisodeStreamUrl(episode, provider, 'prod'); + expect(url).toContain('stream%20with%20spaces'); + }); + }); + + describe('getYouTubeEmbedUrl', () => { + it('should convert youtube.com watch URL to embed URL', () => { + const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ'); + }); + + it('should convert youtu.be URL to embed URL', () => { + const url = 'https://youtu.be/dQw4w9WgXcQ'; + expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ'); + }); + + it('should handle bare video ID', () => { + const videoId = 'dQw4w9WgXcQ'; + expect(getYouTubeEmbedUrl(videoId)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ'); + }); + + it('should return empty string for null', () => { + expect(getYouTubeEmbedUrl(null)).toBe(''); + }); + + it('should return empty string for empty string', () => { + expect(getYouTubeEmbedUrl('')).toBe(''); + }); + }); + + describe('getEpisodeAirdate', () => { + it('should format valid air date', () => { + const episode = { air_date: '2024-01-15' }; + const formatted = getEpisodeAirdate(episode); + expect(formatted).toMatch(/1\/1[4|5]\/2024/); + }); + + it('should return N/A for missing air date', () => { + const episode = {}; + expect(getEpisodeAirdate(episode)).toBe('N/A'); + }); + + it('should return N/A for null air date', () => { + const episode = { air_date: null }; + expect(getEpisodeAirdate(episode)).toBe('N/A'); + }); + }); + + describe('getTmdbUrlLink', () => { + const displaySeries = { tmdb_id: '12345' }; + + it('should return TV show URL without episode details', () => { + const episode = {}; + const url = getTmdbUrlLink(displaySeries, episode); + expect(url).toBe('https://www.themoviedb.org/tv/12345'); + }); + + it('should return TV show URL with season and episode', () => { + const episode = { season_number: 2, episode_number: 5 }; + const url = getTmdbUrlLink(displaySeries, episode); + expect(url).toBe('https://www.themoviedb.org/tv/12345/season/2/episode/5'); + }); + + it('should not append episode details if only season is present', () => { + const episode = { season_number: 2 }; + const url = getTmdbUrlLink(displaySeries, episode); + expect(url).toBe('https://www.themoviedb.org/tv/12345'); + }); + + it('should not append episode details if only episode is present', () => { + const episode = { episode_number: 5 }; + const url = getTmdbUrlLink(displaySeries, episode); + expect(url).toBe('https://www.themoviedb.org/tv/12345'); + }); + }); +}); diff --git a/frontend/src/utils/components/__tests__/VODModalUtils.test.js b/frontend/src/utils/components/__tests__/VODModalUtils.test.js new file mode 100644 index 00000000..04a5edb4 --- /dev/null +++ b/frontend/src/utils/components/__tests__/VODModalUtils.test.js @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getTechnicalDetails, + getMovieStreamUrl, + formatVideoDetails, + formatAudioDetails, +} from '../VODModalUtils'; + +describe('VODModalUtils', () => { + describe('getTechnicalDetails', () => { + const defaultVOD = { + bitrate: '5000 kbps', + video: 'H.264', + audio: 'AAC', + }; + + it('should return defaultVOD details when no provider selected', () => { + const result = getTechnicalDetails(null, defaultVOD); + + expect(result).toEqual({ + bitrate: '5000 kbps', + video: 'H.264', + audio: 'AAC', + }); + }); + + it('should extract details from movie content', () => { + const provider = { + movie: { + bitrate: '8000 kbps', + video: 'H.265', + audio: 'AC3', + }, + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result).toEqual({ + bitrate: '8000 kbps', + video: 'H.265', + audio: 'AC3', + }); + }); + + it('should extract details from episode content', () => { + const provider = { + episode: { + bitrate: '6000 kbps', + video: 'AVC', + audio: 'DTS', + }, + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result).toEqual({ + bitrate: '6000 kbps', + video: 'AVC', + audio: 'DTS', + }); + }); + + it('should extract details from provider object directly', () => { + const provider = { + bitrate: '7000 kbps', + video: 'VP9', + audio: 'Opus', + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result).toEqual({ + bitrate: '7000 kbps', + video: 'VP9', + audio: 'Opus', + }); + }); + + it('should extract details from custom_properties.detailed_info', () => { + const provider = { + custom_properties: { + detailed_info: { + bitrate: '9000 kbps', + video: 'AV1', + audio: 'EAC3', + }, + }, + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result).toEqual({ + bitrate: '9000 kbps', + video: 'AV1', + audio: 'EAC3', + }); + }); + + it('should fallback to defaultVOD when provider has no valid details', () => { + const provider = { + custom_properties: {}, + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result).toEqual({ + bitrate: '5000 kbps', + video: 'H.264', + audio: 'AAC', + }); + }); + + it('should prioritize movie content over provider object', () => { + const provider = { + movie: { + bitrate: '8000 kbps', + }, + bitrate: '7000 kbps', + }; + + const result = getTechnicalDetails(provider, defaultVOD); + + expect(result.bitrate).toBe('8000 kbps'); + }); + + it('should handle undefined defaultVOD', () => { + const result = getTechnicalDetails(null, undefined); + + expect(result).toEqual({ + bitrate: undefined, + video: undefined, + audio: undefined, + }); + }); + }); + + describe('getMovieStreamUrl', () => { + const vod = { uuid: 'test-uuid-123' }; + const originalLocation = window.location; + + beforeEach(() => { + delete window.location; + window.location = { + protocol: 'https:', + hostname: 'example.com', + origin: 'https://example.com', + }; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('should generate basic stream URL without provider', () => { + const result = getMovieStreamUrl(vod, null, 'production'); + + expect(result).toBe('https://example.com/proxy/vod/movie/test-uuid-123'); + }); + + it('should include stream_id when available', () => { + const provider = { + stream_id: 'stream-123', + m3u_account: { id: 'account-456' }, + }; + + const result = getMovieStreamUrl(vod, provider, 'production'); + + expect(result).toBe( + 'https://example.com/proxy/vod/movie/test-uuid-123?stream_id=stream-123' + ); + }); + + it('should include m3u_account_id when stream_id not available', () => { + const provider = { + m3u_account: { id: 'account-456' }, + }; + + const result = getMovieStreamUrl(vod, provider, 'production'); + + expect(result).toBe( + 'https://example.com/proxy/vod/movie/test-uuid-123?m3u_account_id=account-456' + ); + }); + + it('should use dev port in dev mode', () => { + const result = getMovieStreamUrl(vod, null, 'dev'); + + expect(result).toBe( + 'https://example.com:5656/proxy/vod/movie/test-uuid-123' + ); + }); + + it('should encode stream_id', () => { + const provider = { + stream_id: 'stream with spaces', + }; + + const result = getMovieStreamUrl(vod, provider, 'production'); + + expect(result).toContain('stream_id=stream%20with%20spaces'); + }); + }); + + describe('formatVideoDetails', () => { + it('should format complete video details', () => { + const video = { + codec_name: 'h264', + codec_long_name: 'H.264 / AVC / MPEG-4 AVC', + profile: 'High', + width: 1920, + height: 1080, + display_aspect_ratio: '16:9', + bit_rate: '8000000', + r_frame_rate: '23.98', + tags: { encoder: 'x264' }, + }; + + const result = formatVideoDetails(video); + + expect(result).toBe( + 'H.264 / AVC / MPEG-4 AVC, (High), 1920x1080, Aspect Ratio: ' + + '16:9, Bitrate: 8000 kbps, Frame Rate: 23.98 fps, Encoder: x264' + ); + }); + + it('should use codec_name when codec_long_name is unknown', () => { + const video = { + codec_name: 'h264', + codec_long_name: 'unknown', + }; + + const result = formatVideoDetails(video); + + expect(result).toBe('h264'); + }); + + it('should use codec_name when codec_long_name is not available', () => { + const video = { + codec_name: 'h265', + }; + + const result = formatVideoDetails(video); + + expect(result).toBe('h265'); + }); + + it('should handle minimal video details', () => { + const video = { + codec_name: 'vp9', + }; + + const result = formatVideoDetails(video); + + expect(result).toBe('vp9'); + }); + + it('should format bitrate correctly', () => { + const video = { + codec_name: 'h264', + bit_rate: '5500000', + }; + + const result = formatVideoDetails(video); + + expect(result).toContain('Bitrate: 5500 kbps'); + }); + }); + + describe('formatAudioDetails', () => { + it('should format complete audio details', () => { + const audio = { + codec_name: 'aac', + codec_long_name: 'AAC (Advanced Audio Coding)', + profile: 'LC', + channel_layout: '5.1', + sample_rate: '48000', + bit_rate: '256000', + tags: { handler_name: 'SoundHandler' }, + }; + + const result = formatAudioDetails(audio); + + expect(result).toBe( + 'AAC (Advanced Audio Coding), (LC), Channels: 5.1, ' + + 'Sample Rate: 48000 Hz, Bitrate: 256 kbps, Handler: SoundHandler' + ); + }); + + it('should use codec_name when codec_long_name is unknown', () => { + const audio = { + codec_name: 'ac3', + codec_long_name: 'unknown', + }; + + const result = formatAudioDetails(audio); + + expect(result).toBe('ac3'); + }); + + it('should use channels count when channel_layout not available', () => { + const audio = { + codec_name: 'aac', + channels: '2', + }; + + const result = formatAudioDetails(audio); + + expect(result).toBe('aac, Channels: 2'); + }); + + it('should handle minimal audio details', () => { + const audio = { + codec_name: 'opus', + }; + + const result = formatAudioDetails(audio); + + expect(result).toBe('opus'); + }); + + it('should format bitrate correctly', () => { + const audio = { + codec_name: 'aac', + bit_rate: '192000', + }; + + const result = formatAudioDetails(audio); + + expect(result).toContain('Bitrate: 192 kbps'); + }); + + it('should prefer channel_layout over channels', () => { + const audio = { + codec_name: 'dts', + channel_layout: '7.1', + channels: '8', + }; + + const result = formatAudioDetails(audio); + + expect(result).toContain('Channels: 7.1'); + }); + }); +}); From b595e845bbcaeaa82ab96f6d2f9d198fcbb6cc87 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:41 -0800 Subject: [PATCH 025/154] Extracted utils --- .../utils/components/FloatingVideoUtils.js | 124 +++++++++++++ .../src/utils/components/SeriesModalUtils.js | 166 ++++++++++++++++++ .../src/utils/components/VODModalUtils.js | 124 +++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 frontend/src/utils/components/FloatingVideoUtils.js create mode 100644 frontend/src/utils/components/SeriesModalUtils.js create mode 100644 frontend/src/utils/components/VODModalUtils.js diff --git a/frontend/src/utils/components/FloatingVideoUtils.js b/frontend/src/utils/components/FloatingVideoUtils.js new file mode 100644 index 00000000..a14f511e --- /dev/null +++ b/frontend/src/utils/components/FloatingVideoUtils.js @@ -0,0 +1,124 @@ +export const getLivePlayerErrorMessage = (errorType, errorDetail) => { + if (errorType !== 'MediaError') { + return errorDetail + ? `Error: ${errorType} - ${errorDetail}` + : `Error: ${errorType}`; + } + + const errorString = errorDetail?.toLowerCase() || ''; + + if ( + errorString.includes('audio') || + errorString.includes('ac3') || + errorString.includes('ac-3') + ) { + return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'; + } + + if ( + errorString.includes('video') || + errorString.includes('h264') || + errorString.includes('h.264') + ) { + return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'; + } + + if (errorString.includes('mse')) { + return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."; + } + + return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'; +}; + +export const getVODPlayerErrorMessage = (error) => { + if (!error) return 'Video playback error'; + + switch (error.code) { + case error.MEDIA_ERR_ABORTED: + return 'Video playback was aborted'; + case error.MEDIA_ERR_NETWORK: + return 'Network error while loading video'; + case error.MEDIA_ERR_DECODE: + return 'Video codec not supported by your browser'; + case error.MEDIA_ERR_SRC_NOT_SUPPORTED: + return 'Video format not supported by your browser'; + default: + return error.message || 'Unknown video error'; + } +}; + +export const getClientCoordinates = (event) => ({ + clientX: event.touches?.[0]?.clientX ?? event.clientX, + clientY: event.touches?.[0]?.clientY ?? event.clientY, +}); + +export const calculateNewDimensions = ( + deltaX, + deltaY, + startWidth, + startHeight, + handle, + ratio +) => { + const widthDelta = deltaX * handle.xDir; + const heightDelta = deltaY * handle.yDir; + + let width = startWidth + widthDelta; + let height = width / ratio; + + // Use vertical-driven resize if user drags mostly vertically + if (Math.abs(deltaY) > Math.abs(deltaX)) { + height = startHeight + heightDelta; + width = height * ratio; + } + + return { width, height }; +}; + +export const applyConstraints = ( + width, + height, + ratio, + startPos, + handle, + minWidth, + minHeight, + visibleMargin +) => { + // Apply minimum constraints + if (width < minWidth) { + width = minWidth; + height = width / ratio; + } + if (height < minHeight) { + height = minHeight; + width = height * ratio; + } + + // Apply viewport constraints + const posX = startPos?.x ?? 0; + const posY = startPos?.y ?? 0; + const maxWidth = !handle.isLeft + ? Math.max(minWidth, window.innerWidth - posX - visibleMargin) + : null; + const maxHeight = !handle.isTop + ? Math.max(minHeight, window.innerHeight - posY - visibleMargin) + : null; + + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = width / ratio; + } + if (maxHeight && height > maxHeight) { + height = maxHeight; + width = height * ratio; + } + + // Final adjustment to maintain aspect ratio + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = width / ratio; + } + + return { width, height }; +}; diff --git a/frontend/src/utils/components/SeriesModalUtils.js b/frontend/src/utils/components/SeriesModalUtils.js new file mode 100644 index 00000000..11ab2697 --- /dev/null +++ b/frontend/src/utils/components/SeriesModalUtils.js @@ -0,0 +1,166 @@ +export const imdbUrl = (imdb_id) => + imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; + +export const tmdbUrl = (tmdb_id, type = 'movie') => + tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; + +export const formatDuration = (seconds) => { + if (!seconds) return ''; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; +}; + +export const formatStreamLabel = (relation) => { + const provider = relation.m3u_account.name; + const streamId = relation.stream_id; + const quality = extractQuality(relation); + + return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`; +}; + +const extractQuality = (relation) => { + // 1. Primary: Backend quality_info field + const fromQualityInfo = getQualityFromBackend(relation.quality_info); + if (fromQualityInfo) return fromQualityInfo; + + // 2. Secondary: Custom properties detailed_info + if (relation.custom_properties?.detailed_info) { + const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info); + if (fromDetailedInfo) return fromDetailedInfo; + } + + // 3. Fallback: Stream name + if (relation.stream_name) { + return getQualityFromStreamName(relation.stream_name); + } + + return ''; +}; + +const getQualityFromBackend = (qualityInfo) => { + if (!qualityInfo) return ''; + + if (qualityInfo.quality) { + return ` - ${qualityInfo.quality}`; + } else if (qualityInfo.resolution) { + return ` - ${qualityInfo.resolution}`; + } else if (qualityInfo.bitrate) { + return ` - ${qualityInfo.bitrate}`; + } + return ''; +}; + +const getQualityFromDetailedInfo = (detailedInfo) => { + // Check video dimensions first + if (detailedInfo.video?.width && detailedInfo.video?.height) { + return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height); + } + + // Check name field + if (detailedInfo.name) { + return parseQualityFromText(detailedInfo.name); + } + + return ''; +}; + +const getQualityFromStreamName = (streamName) => { + return parseQualityFromText(streamName); +}; + +const parseQualityFromText = (text) => { + if (text.includes('4K') || text.includes('2160p')) return ' - 4K'; + if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p'; + if (text.includes('720p') || text.includes('HD')) return ' - 720p'; + if (text.includes('480p')) return ' - 480p'; + return ''; +}; + +const getQualityInfoFromDimensions = (width, height) => { + // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) + if (width >= 3840) { + return ' - 4K'; + } else if (width >= 1920) { + return ' - 1080p'; + } else if (width >= 1280) { + return ' - 720p'; + } else if (width >= 854) { + return ' - 480p'; + } else { + return ` - ${width}x${height}`; + } +} + +export const sortEpisodesList = (episodesList) => { + return episodesList.sort((a, b) => { + if (a.season_number !== b.season_number) { + return (a.season_number || 0) - (b.season_number || 0); + } + return (a.episode_number || 0) - (b.episode_number || 0); + }); +}; + +export const groupEpisodesBySeason = (seriesEpisodes) => { + const grouped = {}; + seriesEpisodes.forEach((episode) => { + const season = episode.season_number || 1; + if (!grouped[season]) { + grouped[season] = []; + } + grouped[season].push(episode); + }); + return grouped; +}; + +export const sortBySeasonNumber = (episodesBySeason) => { + return Object.keys(episodesBySeason) + .map(Number) + .sort((a, b) => a - b); +}; + +export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => { + let streamUrl = `/proxy/vod/episode/${episode.uuid}`; + + // Add selected provider as query parameter if available + if (selectedProvider) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + } + + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + } else { + streamUrl = `${window.location.origin}${streamUrl}`; + } + return streamUrl; +}; + +// Helper to get embeddable YouTube URL +export const getYouTubeEmbedUrl = (url) => { + if (!url) return ''; + // Accepts full YouTube URLs or just IDs + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); + const videoId = match ? match[1] : url; + return `https://www.youtube.com/embed/${videoId}`; +}; + +export const getEpisodeAirdate = (episode) => { + return episode.air_date + ? new Date(episode.air_date).toLocaleDateString() + : 'N/A'; +}; + +export const getTmdbUrlLink = (displaySeries, episode) => { + return ( + tmdbUrl(displaySeries.tmdb_id, 'tv') + + (episode.season_number && episode.episode_number + ? `/season/${episode.season_number}/episode/${episode.episode_number}` + : '') + ); +}; diff --git a/frontend/src/utils/components/VODModalUtils.js b/frontend/src/utils/components/VODModalUtils.js new file mode 100644 index 00000000..111fd477 --- /dev/null +++ b/frontend/src/utils/components/VODModalUtils.js @@ -0,0 +1,124 @@ +const hasValidTechnicalDetails = (obj) => { + return obj?.bitrate || obj?.video || obj?.audio; +}; + +const extractFromDetailedInfo = (customProperties) => { + const detailedInfo = customProperties?.detailed_info; + if (!detailedInfo) return null; + + return { + bitrate: detailedInfo.bitrate || null, + video: detailedInfo.video || null, + audio: detailedInfo.audio || null, + }; +}; + +export const getTechnicalDetails = (selectedProvider, defaultVOD) => { + if (!selectedProvider) { + return { + bitrate: defaultVOD?.bitrate, + video: defaultVOD?.video, + audio: defaultVOD?.audio, + }; + } + + // Try movie/episode content first + const content = selectedProvider.movie || selectedProvider.episode; + if (content && hasValidTechnicalDetails(content)) { + return { + bitrate: content.bitrate, + video: content.video, + audio: content.audio, + }; + } + + // Try provider object directly + if (hasValidTechnicalDetails(selectedProvider)) { + return { + bitrate: selectedProvider.bitrate, + video: selectedProvider.video, + audio: selectedProvider.audio, + }; + } + + // Try custom_properties.detailed_info + const detailedInfo = extractFromDetailedInfo( + selectedProvider.custom_properties + ); + if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) { + return detailedInfo; + } + + // Fallback to defaultVOD + return { + bitrate: defaultVOD?.bitrate, + video: defaultVOD?.video, + audio: defaultVOD?.audio, + }; +}; + +export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => { + let streamUrl = `/proxy/vod/movie/${vod.uuid}`; + + // Add selected provider as query parameter if available + if (selectedProvider) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } + } + + if (env_mode === 'dev') { + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + } else { + streamUrl = `${window.location.origin}${streamUrl}`; + } + return streamUrl; +}; + +export const formatVideoDetails = (video) => { + const parts = []; + + const codec = + video.codec_long_name && video.codec_long_name !== 'unknown' + ? video.codec_long_name + : video.codec_name; + parts.push(codec); + + if (video.profile) parts.push(`(${video.profile})`); + if (video.width && video.height) parts.push(`${video.width}x${video.height}`); + if (video.display_aspect_ratio) + parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`); + if (video.bit_rate) + parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`); + if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`); + if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`); + + return parts.join(', '); +}; + +export const formatAudioDetails = (audio) => { + const parts = []; + + const codec = + audio.codec_long_name && audio.codec_long_name !== 'unknown' + ? audio.codec_long_name + : audio.codec_name; + parts.push(codec); + + if (audio.profile) parts.push(`(${audio.profile})`); + + const channels = + audio.channel_layout || (audio.channels ? `${audio.channels}` : null); + if (channels) parts.push(`Channels: ${channels}`); + + if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`); + if (audio.bit_rate) + parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`); + if (audio.tags?.handler_name) + parts.push(`Handler: ${audio.tags.handler_name}`); + + return parts.join(', '); +}; From 8f11ef417b52ceec566d5a3c0db2f3af6a24c2c4 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:59 -0800 Subject: [PATCH 026/154] Extracted YouTubeTrailerModal component --- .../components/modals/YouTubeTrailerModal.jsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/components/modals/YouTubeTrailerModal.jsx diff --git a/frontend/src/components/modals/YouTubeTrailerModal.jsx b/frontend/src/components/modals/YouTubeTrailerModal.jsx new file mode 100644 index 00000000..d873fea0 --- /dev/null +++ b/frontend/src/components/modals/YouTubeTrailerModal.jsx @@ -0,0 +1,29 @@ +import { Box, Modal } from '@mantine/core'; +import React from 'react'; + +export const YouTubeTrailerModal = ({ opened, onClose, trailerUrl }) => { + return ( + + + {trailerUrl && ( +