diff --git a/CHANGELOG.md b/CHANGELOG.md index c071d05a..e2c85a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: + - `/hdhr/output_profile//lineup.json` - output profile only + - `/hdhr//output_profile//lineup.json` - channel profile + output profile + - Bare `/hdhr/lineup.json` - existing behavior, uses the new system default (see below) + - The profile ID is resolved against active `OutputProfile` rows; if the ID is inactive or does not exist, a warning is logged and the stream is served without transcoding. +- **HDHR default output profile setting.** A new "HDHR Default Output Profile" select in Settings → Stream Settings lets admins pick an output profile that is applied to all HDHR stream URLs that do not specify one in the path. When cleared, streams are served as-is (pass-through). - **fMP4 streaming support.** Dispatcharr can now serve live channels as fragmented MP4 in addition to MPEG-TS. A dedicated `FMP4RemuxManager` runs a single FFmpeg remux process per active channel, muxes incoming TS data into fMP4 fragments, and stores them in a Redis-backed ring buffer. Multiple clients requesting the same channel in fMP4 all read from the same shared buffer, so only one FFmpeg process runs regardless of viewer count. Clients that request MPEG-TS continue to be served as before. The output format is selected via `?output_format=mpegts|fmp4` on the stream URL, or by the per-user default configured by an admin on the user account, or by the server-wide default in Stream Settings. - **Output profiles.** Admins can define named transcode profiles under Settings → Output Profiles. Each profile specifies an executable (e.g. `ffmpeg`) and a parameter string that reads raw TS from `pipe:0` and writes the transcoded output to `pipe:1`. When a client requests a profile, one FFmpeg transcode process runs per active (channel, profile) pair and all requesting clients share the resulting output buffer. Common use case: a profile that converts AC3 audio to AAC for browser and mobile clients while the native stream (AC3 intact) continues to serve Plex/Emby/Jellyfin. Profiles are applied via `?output_profile=` on the stream URL, or by per-user and server-wide defaults. Two locked built-in profiles are seeded on first run: **Media Server (AC3 Audio)** (copies video, re-encodes audio to AC3 384k) and **Web Player (AAC Audio)** (copies video, re-encodes audio to AAC 192k). (Closes #407) - **Container format and output profile shown in Stats client rows.** The expanded client row on the Stats page now displays the container format (`mpegts` or `fmp4`) and, when an output profile is active, the profile name. @@ -24,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs. +- **Stream Settings descriptions added.** Default User Agent, Default Stream Profile, Default Output Format, M3U Hash Key, and HDHR Default Output Profile all now have inline description text below their labels explaining their purpose and effect. +- **System Settings descriptions added.** Maximum System Events, Preferred Region, and Auto-Import Mapped Files now have inline description text. The redundant description paragraph that duplicated the accordion header text has been removed. - **`get_client_ip()` in `dispatcharr/utils.py` cleaned up.** Removed dead `.split(',')[0]` call and misleading variable name left over from a copy-paste of the `X-Forwarded-For` pattern. `X-Real-IP` is always a single IP (set by nginx from `$remote_addr`), so no splitting is needed. Behavior is unchanged. - **`ts_proxy` module refactored and renamed to `live_proxy`.** The live-streaming proxy was reorganized into a structured package (`apps/proxy/live_proxy/`) with explicit submodules for each stage of the pipeline: `input/` (HTTP streamer, stream manager, TS ring buffer), `output/ts/` (MPEG-TS client generator), `output/fmp4/` (fMP4 remux manager, Redis buffer, client generator), and `output/profile/` (output profile transcode manager). A dedicated `redis_keys.py` module centralizes all Redis key name construction for the proxy. Existing behavior for MPEG-TS clients is unchanged. - **XC API `allowed_output_formats` now includes `mp4`.** The `user_info` block returned by `player_api.php` and `get.php` previously advertised only `["ts"]`. It now advertises `["ts", "mp4"]` for all users, enabling XC-compatible clients that support fMP4 to request `.mp4` stream URLs which are proxied as fMP4. @@ -63,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Frontend unit tests added for form components.** `GroupManager`, `LiveGroupFilter`, `LoginForm`, and `Logo` now have Vitest + Testing Library test suites covering rendering, user interactions, API integration, and edge cases (144 tests total). Business logic that was extracted into utility modules (`ChannelGroupUtils`, `LiveGroupFilterUtils`, `LogoUtils`, `M3uUtils`, `M3uFilterUtils`, `M3uGroupFilterUtils`, `M3uProfileUtils`, `AutoSyncAdvancedUtils`, `AutoSyncBasicUtils`) is also exercised through these tests. — Thanks [@nick4810](https://github.com/nick4810) - **Global Network Access settings use tag-style inputs**: the IP/CIDR range fields in the Network Access settings panel now use tag-style chip inputs instead of a plain text field, making it easier to add, review, and remove individual addresses or ranges. — Thanks [@sethwv](https://github.com/sethwv) - **Auto-sync overhaul** sync and channel behavior changes for the new override system. — Thanks [@CodeBormen](https://github.com/CodeBormen) - **`Channel.channel_number` is now nullable.** Auto-sync may produce channels without an assigned number (for example, a sync run where the configured range was exhausted before this stream could be slotted, or a hidden channel in compact mode whose slot was released for visible channels). Existing channels are unchanged. HDHR / M3U / EPG / XC output endpoints filter out channels with `null` channel_number, so a number-less row is invisible to clients but visible in the channels page where the user can assign one. The 0037 auto-sync overhaul migration includes a reverse-direction backfill on the `channel_number` AlterField step so a rollback that re-imposes NOT NULL still succeeds even if NULLs are present. @@ -73,12 +83,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810) +- **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810) +- **Login loading spinner not cleared on successful login.** `setIsLoading(false)` was inside the `catch` block only, so a successful login that immediately navigated away left the loading state as `true` if the component re-mounted. Moved to a `finally` block so it always resets. — Thanks [@nick4810](https://github.com/nick4810) + - **Bulk channel edit silently failed on API rejection.** The bulk-edit submit handler in `ChannelBatch.jsx` only wrote `console.error` when the PATCH was rejected; the user saw the spinner stop but received no notification, leading them to assume the save succeeded. The catch block now surfaces a red toast with the server-provided detail (or a generic fallback), and the form stays open with the in-progress selection intact so the user can correct and retry. — Thanks [@CodeBormen](https://github.com/CodeBormen) - **Recurring rule edit modal showed a blank Channel field.** The `RecurringRuleModal` read channel data from a Zustand store slice (`channels`) that nothing populates - the channels page and all other consumers switched to on-demand summary fetches in a prior release. Opening the edit modal from the DVR page always produced an empty Channel select. The modal now fetches channels via `API.getChannelsSummary()` when it opens, matching the lightweight approach used by the one-time recording form and other modals. - **DVR section count badges appeared at the far right of the screen.** The "Currently Recording", "Upcoming Recordings", and "Previously Recorded" section headers wrapped the title and badge in a `Group justify="space-between"`, which pushed the badge to the opposite edge of the full-width container. Changed to `Group gap="xs" align="center"` so each badge sits inline with its heading. ### Performance +- **`AutoSyncAdvanced` and `LogoForm` are now lazy-loaded in the M3U group filter.** Both components are large and only needed when the user opens the gear modal or logo upload modal. Wrapping them in `React.lazy` + `Suspense` removes them from the initial bundle and defers their parse/execute cost until first use. — Thanks [@nick4810](https://github.com/nick4810) - **Auto-sync at scale**: the new override-aware sync flow is more capable than the prior path but the implementation choices below keep it viable on libraries with thousands of channels. — Thanks [@CodeBormen](https://github.com/CodeBormen) - **Bulk writes throughout `sync_auto_channels`.** Per-row `Channel.objects.create()` and `.save()` calls were replaced with `bulk_create()` and `bulk_update()` paths that batch the entire group's create + update sets into single round-trips. The renumber pass collects all dirty channels into one list and flushes with a single `bulk_update` at the end of the loop. `ChannelStream.order` writes were similarly consolidated into a single `bulk_update`. - **Single-pass collision detection via a global `used_numbers` set.** Choosing a free channel number was previously an `O(N)` DB query per stream. The new path seeds a `set()` of all reserved numbers (existing channels + override pins) once per run; `_next_available_number()` is `O(cluster size)` against that set. The seed deliberately excludes only this account's visible auto-created channels (the rows about to be reassigned), so cross-account and hidden-channel reservations are honored without extra queries. diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 41c1a3d9..b4b91bd0 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -63,28 +63,28 @@ class DiscoverAPIView(APIView): @extend_schema( description="Retrieve HDHomeRun device discovery information", ) - def get(self, request, profile=None): + def get(self, request, channel_profile=None, output_profile_id=None): blocked = _hdhr_network_check(request) if blocked is not None: return blocked uri_parts = ["hdhr"] - if profile is not None: - uri_parts.append(profile) + if channel_profile is not None: + uri_parts.append(channel_profile) + if output_profile_id is not None: + uri_parts.append("output_profile") + uri_parts.append(str(output_profile_id)) base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/") device = HDHRDevice.objects.first() - # Calculate tuner count using centralized function from apps.m3u.utils import calculate_tuner_count tuner_count = calculate_tuner_count(minimum=1, unlimited_default=10) - # Create a unique DeviceID for the HDHomeRun device based on profile ID or a default value - device_ID = "12345678" # Default DeviceID - friendly_name = "Dispatcharr HDHomeRun" - if profile is not None: - device_ID = f"dispatcharr-hdhr-{profile}" - friendly_name = f"Dispatcharr HDHomeRun - {profile}" + slug_parts = [p for p in [channel_profile, str(output_profile_id) if output_profile_id is not None else None] if p] + device_ID = f"dispatcharr-hdhr-{'-'.join(slug_parts)}" if slug_parts else "12345678" + friendly_name = f"Dispatcharr HDHomeRun - {' / '.join(slug_parts)}" if slug_parts else "Dispatcharr HDHomeRun" + if not device: data = { "FriendlyName": friendly_name, @@ -112,6 +112,24 @@ class DiscoverAPIView(APIView): return JsonResponse(data) +def _resolve_hdhr_output_profile_id(output_profile_id): + """Return a validated output profile ID for HDHR lineup stream URLs. + + Priority: URL path segment -> system default -> None (pass-through). + """ + from core.models import OutputProfile, CoreSettings + candidate = output_profile_id if output_profile_id is not None else CoreSettings.get_hdhr_output_profile_id() + if candidate is None: + return None + try: + OutputProfile.objects.get(id=candidate, is_active=True) + return candidate + except OutputProfile.DoesNotExist: + source = "URL" if output_profile_id is not None else "system default" + logger.warning("HDHR output profile id=%s (%s) not found or inactive - serving without transcoding", candidate, source) + return None + + # 🔹 3) Lineup API class LineupAPIView(APIView): """Returns available channel lineup""" @@ -120,7 +138,7 @@ class LineupAPIView(APIView): @extend_schema( description="Retrieve the available channel lineup", ) - def get(self, request, profile=None): + def get(self, request, channel_profile=None, output_profile_id=None): blocked = _hdhr_network_check(request) if blocked is not None: return blocked @@ -128,10 +146,13 @@ class LineupAPIView(APIView): from apps.channels.managers import with_effective_values from apps.channels.utils import format_channel_number - if profile is not None: - channel_profile = ChannelProfile.objects.get(name=profile) + if channel_profile is not None: + try: + cp = ChannelProfile.objects.get(name=channel_profile) + except ChannelProfile.DoesNotExist: + return JsonResponse([], safe=False) base_qs = Channel.objects.filter( - channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__channel_profile=cp, channelprofilemembership__enabled=True, ) else: @@ -143,21 +164,24 @@ class LineupAPIView(APIView): .order_by("effective_channel_number") ) + resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id) + lineup = [] for ch in channels: - # HDHR clients reject lineup entries with empty/non-numeric - # GuideNumber and may drop the whole lineup. With nullable - # channel_number, skip rows that have no usable number. formatted = format_channel_number(ch.effective_channel_number, empty=None) if formatted is None: continue formatted_channel_number = str(formatted) + stream_url = request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}") + if resolved_output_profile_id is not None: + stream_url += f"?output_profile={resolved_output_profile_id}" + lineup.append( { "GuideNumber": formatted_channel_number, "GuideName": ch.effective_name, - "URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"), + "URL": stream_url, "Guide_ID": formatted_channel_number, "Station": formatted_channel_number, } @@ -173,7 +197,7 @@ class LineupStatusAPIView(APIView): @extend_schema( description="Retrieve the HDHomeRun lineup status", ) - def get(self, request, profile=None): + def get(self, request, channel_profile=None, output_profile_id=None): blocked = _hdhr_network_check(request) if blocked is not None: return blocked diff --git a/apps/hdhr/urls.py b/apps/hdhr/urls.py index 2659cd7b..f1f0f6f5 100644 --- a/apps/hdhr/urls.py +++ b/apps/hdhr/urls.py @@ -10,12 +10,27 @@ router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device') urlpatterns = [ path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'), path('', hdhr_dashboard_view, name='hdhr_dashboard'), - path('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + + # channel_profile + output_profile_id (/hdhr//output_profile//...) + path('/output_profile//discover.json', DiscoverAPIView.as_view(), name='discover_with_profile_and_output'), + path('/output_profile//lineup.json', LineupAPIView.as_view(), name='lineup_with_profile_and_output'), + path('/output_profile//lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile_and_output'), + + # output_profile_id only (/hdhr/output_profile//...) + path('output_profile//discover.json', DiscoverAPIView.as_view(), name='discover_with_output'), + path('output_profile//lineup.json', LineupAPIView.as_view(), name='lineup_with_output'), + path('output_profile//lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_output'), + + # channel_profile only + path('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), + path('/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'), + + # bare endpoints path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'), - path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'), - path('/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'), path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_no_profile'), + path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'), ] diff --git a/apps/proxy/live_proxy/output/ts/generator.py b/apps/proxy/live_proxy/output/ts/generator.py index 15596b7c..dcff594f 100644 --- a/apps/proxy/live_proxy/output/ts/generator.py +++ b/apps/proxy/live_proxy/output/ts/generator.py @@ -624,6 +624,14 @@ class StreamGenerator: # If no clients left and we're the owner, schedule shutdown using the config value if local_clients == 0 and proxy_server.am_i_owner(self.channel_id): + # When output/profile managers are present, remove_client already spawned + # handle_client_disconnect which will stop them and then stop the channel. + # Spawning a second shutdown greenlet here causes a redundant concurrent + # stop_channel call that can race against the first. + if (proxy_server.output_managers.get(self.channel_id) or + proxy_server.profile_managers.get(self.channel_id)): + return + logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown") def delayed_shutdown(): diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index 53958c21..6ccb42b8 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -219,8 +219,9 @@ class ProxyServer: client_id = data.get("client_id") worker_id = data.get("worker_id") logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}, client {client_id} from worker {worker_id}") - # Delegate to dedicated method - self.handle_client_disconnect(channel_id) + # Spawn to avoid blocking the pubsub listener thread + # during the full shutdown path (thread joins, Redis cleanup). + gevent.spawn(self.handle_client_disconnect, channel_id) elif event_type == EventType.STREAM_SWITCH: @@ -1336,6 +1337,14 @@ class ProxyServer: except KeyError: logger.debug(f"Client manager for channel {channel_id} already removed") + # Clean up profile managers and buffers. Owner workers clean profile_managers + # (and their profile_buffers) via stop_all_output_profiles above. Non-owner + # workers only populate profile_buffers (not profile_managers), and that block + # is skipped for them, so we always clean both here to avoid stale entries on + # the next connect. + self.profile_managers.pop(channel_id, None) + self.profile_buffers.pop(channel_id, None) + # Clean up Redis keys self._clean_redis_keys(channel_id) diff --git a/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py b/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py new file mode 100644 index 00000000..7a8a8f84 --- /dev/null +++ b/core/migrations/0025_move_preferred_region_and_auto_import_to_system_settings.py @@ -0,0 +1,72 @@ +from django.db import migrations + + +def move_to_system_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + stream_obj = CoreSettings.objects.get(key="stream_settings") + except CoreSettings.DoesNotExist: + stream_obj = None + + system_obj, _ = CoreSettings.objects.get_or_create( + key="system_settings", + defaults={"name": "System Settings", "value": {}}, + ) + system_value = system_obj.value if isinstance(system_obj.value, dict) else {} + + if stream_obj: + stream_value = stream_obj.value if isinstance(stream_obj.value, dict) else {} + + for field in ("preferred_region", "auto_import_mapped_files"): + if field in stream_value: + # Only migrate to system if not already explicitly set there + if field not in system_value or system_value[field] is None: + system_value[field] = stream_value.pop(field) + else: + stream_value.pop(field) + + stream_obj.value = stream_value + stream_obj.save() + + # Ensure sensible defaults if the fields are still absent + system_value.setdefault("preferred_region", None) + system_value.setdefault("auto_import_mapped_files", True) + system_obj.value = system_value + system_obj.save() + + +def reverse_move(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + system_obj = CoreSettings.objects.get(key="system_settings") + except CoreSettings.DoesNotExist: + return + + stream_obj, _ = CoreSettings.objects.get_or_create( + key="stream_settings", + defaults={"name": "Stream Settings", "value": {}}, + ) + + system_value = system_obj.value if isinstance(system_obj.value, dict) else {} + stream_value = stream_obj.value if isinstance(stream_obj.value, dict) else {} + + for field in ("preferred_region", "auto_import_mapped_files"): + if field in system_value: + stream_value[field] = system_value.pop(field) + + system_obj.value = system_value + system_obj.save() + stream_obj.value = stream_value + stream_obj.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0024_outputprofile"), + ] + + operations = [ + migrations.RunPython(move_to_system_settings, reverse_move), + ] diff --git a/core/models.py b/core/models.py index 5cae7e09..293a40f3 100644 --- a/core/models.py +++ b/core/models.py @@ -243,9 +243,8 @@ class CoreSettings(models.Model): "default_user_agent": None, "default_stream_profile": None, "m3u_hash_key": "", - "preferred_region": None, - "auto_import_mapped_files": None, "default_output_format": "mpegts", + "hdhr_output_profile_id": None, }) @classmethod @@ -266,11 +265,11 @@ class CoreSettings(models.Model): @classmethod def get_preferred_region(cls): - return cls.get_stream_settings().get("preferred_region") + return cls.get_system_settings().get("preferred_region") @classmethod def get_auto_import_mapped_files(cls): - return cls.get_stream_settings().get("auto_import_mapped_files") + return cls.get_system_settings().get("auto_import_mapped_files") # EPG Settings @classmethod @@ -394,6 +393,8 @@ class CoreSettings(models.Model): return cls._get_group(SYSTEM_SETTINGS_KEY, { "time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC", "max_system_events": 100, + "preferred_region": None, + "auto_import_mapped_files": True, }) @classmethod @@ -406,6 +407,14 @@ class CoreSettings(models.Model): cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value}) return value + @classmethod + def get_hdhr_output_profile_id(cls): + raw = cls.get_stream_settings().get("hdhr_output_profile_id") + try: + return int(raw) if raw is not None else None + except (ValueError, TypeError): + return None + @classmethod def get_user_limits_settings(cls): return cls._get_group(USER_LIMITS_SETTINGS_KEY, { diff --git a/frontend/src/components/forms/AutoSyncAdvanced.jsx b/frontend/src/components/forms/AutoSyncAdvanced.jsx new file mode 100644 index 00000000..abe101fd --- /dev/null +++ b/frontend/src/components/forms/AutoSyncAdvanced.jsx @@ -0,0 +1,756 @@ +// Shared preview box for include and exclude filters. The marker and +// color reflect whether matched names are kept (teal check) or dropped +// (red x); empty/loading/error states mirror the find preview. +import { + Box, + Button, + Center, + Checkbox, + Divider, + Flex, + Group, + MultiSelect, + Popover, + PopoverDropdown, + PopoverTarget, + ScrollArea, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { getRegexOptions } from '../../utils/forms/LiveGroupFilterUtils.js'; +import React from 'react'; +import useChannelsStore from '../../store/channels.jsx'; +import { FixedSizeList as List } from 'react-window'; +import logo from '../../images/logo.png'; +import LazyLogo from '../LazyLogo.jsx'; +import { RefreshCw } from 'lucide-react'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + formatPreviewSummary, + getEpgSourceData, + getEpgSourceValue, + repackGroupChannels, +} from '../../utils/forms/AutoSyncAdvancedUtils.js'; + +const RegexPreviewBox = ({ group, kind, regexPreviewState }) => { + const pattern = + kind === 'exclude' + ? group.custom_properties?.name_match_exclude_regex || '' + : kind === 'include' + ? group.custom_properties?.name_match_regex || '' + : group.custom_properties?.name_regex_pattern || ''; + if (!pattern) return null; + const state = regexPreviewState[group.channel_group] || {}; + const result = + kind === 'exclude' + ? state.excludeResult + : kind === 'include' + ? state.filterResult + : state.findResult; + const loading = state.loading; + const summaryLabel = + kind === 'exclude' ? 'exclude' : kind === 'include' ? 'filter' : 'rename'; + const placeholderLabel = + kind === 'exclude' + ? 'Exclude preview' + : kind === 'include' + ? 'Filter preview' + : 'Preview'; + const markerChar = kind === 'exclude' ? '✗' : '✓'; + const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4'; + const emptyText = + kind === 'exclude' + ? 'No streams matched this pattern (nothing would be excluded).' + : 'No streams matched this pattern.'; + return ( + + + {result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel} + + {result?.error && ( + + Invalid regex: {result.error} + + )} + {loading && !result && ( + + Scanning streams... + + )} + {result && !result.error && result.matches?.length === 0 && ( + + {result.total_in_group === 0 + ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' + : emptyText} + + )} + {result?.matches?.map((row, idx) => + kind === 'find' ? ( + + + {row.before} + + + {' -> '} + + + {row.after} + + + ) : ( + + + {markerChar} + + + {row.name} + + + ) + )} + + ); +}; + +// Advanced Options form rendered inside the gear modal. A field's +// presence in custom_properties activates it; blanking returns the +// group to default behavior. +const AutoSyncAdvanced = ({ + group, + epgSources, + channelGroups, + streamProfiles, + regexPreviewState, + onApplyGroupChange, + onScheduleRegexPreview, + onOpenLogoUpload, + channelLogos, + playlist, + logosLoading, + ensureLogosLoaded, +}) => { + const cp = group.custom_properties || {}; + const profiles = useChannelsStore((s) => s.profiles); + + const setCp = (patch, clears = []) => { + const next = { ...cp, ...patch }; + clears.forEach((k) => delete next[k]); + onApplyGroupChange({ ...group, custom_properties: next }); + }; + + // --- Name Transforms --- + + const findValue = cp.name_regex_pattern ?? ''; + const replaceValue = cp.name_replace_pattern ?? ''; + const filterValue = cp.name_match_regex ?? ''; + const excludeValue = cp.name_match_exclude_regex ?? ''; + const updateFind = (val) => { + if (!val && !replaceValue) { + setCp({}, ['name_regex_pattern', 'name_replace_pattern']); + } else { + setCp({ + name_regex_pattern: val, + name_replace_pattern: replaceValue, + }); + } + onScheduleRegexPreview( + group, + getRegexOptions(val, replaceValue, filterValue, excludeValue) + ); + }; + const updateReplace = (val) => { + if (!val && !findValue) { + setCp({}, ['name_regex_pattern', 'name_replace_pattern']); + } else { + setCp({ + name_regex_pattern: findValue, + name_replace_pattern: val, + }); + } + onScheduleRegexPreview( + group, + getRegexOptions(findValue, val, filterValue, excludeValue) + ); + }; + const updateFilter = (val) => { + if (!val) setCp({}, ['name_match_regex']); + else setCp({ name_match_regex: val }); + onScheduleRegexPreview( + group, + getRegexOptions(findValue, replaceValue, val, excludeValue) + ); + }; + const updateExclude = (val) => { + if (!val) setCp({}, ['name_match_exclude_regex']); + else setCp({ name_match_exclude_regex: val }); + onScheduleRegexPreview( + group, + getRegexOptions(findValue, replaceValue, filterValue, val) + ); + }; + + // --- EPG --- + + const epgValue = getEpgSourceValue(cp); + const updateEpg = (value) => { + const next = { ...cp }; + delete next.custom_epg_id; + delete next.force_dummy_epg; + delete next.force_epg_selected; + if (value === '0') { + next.force_dummy_epg = true; + } else if (value) { + next.custom_epg_id = parseInt(value); + } + onApplyGroupChange({ ...group, custom_properties: next }); + }; + + // --- Channel Assignment --- + + const groupOverrideValue = cp.group_override + ? cp.group_override.toString() + : ''; + const updateGroupOverride = (value) => { + if (!value) setCp({}, ['group_override']); + else setCp({ group_override: parseInt(value) }); + }; + + const profileValue = cp.channel_profile_ids ?? []; + const updateProfiles = (value) => { + if (!value || value.length === 0) { + setCp({}, ['channel_profile_ids']); + } else { + setCp({ channel_profile_ids: value }); + } + }; + + const streamProfileValue = cp.stream_profile_id + ? cp.stream_profile_id.toString() + : ''; + const updateStreamProfile = (value) => { + if (!value) setCp({}, ['stream_profile_id']); + else setCp({ stream_profile_id: parseInt(value) }); + }; + + const sortOrderValue = cp.channel_sort_order ?? '__default__'; + const sortReverseEnabled = cp.channel_sort_order !== undefined; + const updateSortOrder = (value) => { + if (!value || value === '__default__') { + setCp({}, ['channel_sort_order', 'channel_sort_reverse']); + } else { + setCp({ + channel_sort_order: value, + channel_sort_reverse: cp.channel_sort_reverse ?? false, + }); + } + }; + const updateSortReverse = (checked) => { + setCp({ channel_sort_reverse: checked }); + }; + + // --- Custom Logo --- + + const logoValue = cp.custom_logo_id; + + return ( + + + + Name Transforms + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + + updateFind(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + updateReplace(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + + {findValue && ( + + )} + + + + + + updateFilter(e.currentTarget.value)} + size="xs" + /> + {filterValue && ( + + )} + + + + + updateExclude(e.currentTarget.value)} + size="xs" + /> + {excludeValue && ( + + )} + + + + + + + + EPG & Logo + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + ({ + value: profile.id.toString(), + label: profile.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + + + + {sortReverseEnabled && ( + + updateSortReverse(event.currentTarget.checked) + } + size="xs" + mt="xs" + /> + )} + + + + + + + { + if (event.currentTarget.checked) { + setCp({ compact_numbering: true }); + } else { + setCp({}, ['compact_numbering']); + } + }} + size="xs" + /> + + + + + + + + + ); +}; + +export default AutoSyncAdvanced; diff --git a/frontend/src/components/forms/AutoSyncBasic.jsx b/frontend/src/components/forms/AutoSyncBasic.jsx new file mode 100644 index 00000000..6cab8ea7 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncBasic.jsx @@ -0,0 +1,181 @@ +// Inline Start/End range inputs for Fixed and Provider modes. Start +// writes to auto_sync_channel_start (Fixed) or +// custom_properties.channel_numbering_fallback (Provider); End always +// writes to auto_sync_channel_end. Next Available shows a one-line +// explanation because a range contradicts pack-anywhere semantics. +import { Flex, NumberInput, Stack, Text, Tooltip } from '@mantine/core'; +import { AlertTriangle } from 'lucide-react'; +import React from 'react'; +import { + clampChannelNumber, + computeRangeOverlapsFor, +} from '../../utils/forms/AutoSyncBasicUtils.js'; + +const AutoSyncBasic = ({ + group, + groupStates, + groupConflicts, + onApplyGroupChange, +}) => { + const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; + if (mode === 'next_available') { + return ( + + Channels receive the lowest available numbers starting at 1. + + ); + } + const startValue = + mode === 'provider' + ? (group.custom_properties?.channel_numbering_fallback ?? 1) + : (group.auto_sync_channel_start ?? 1); + const endValue = group.auto_sync_channel_end; + // Caps pathological pasted values like "1e308" at the input layer. + const updateStart = (value) => { + const normalized = + value === '' || value === null || value === undefined + ? 1 + : clampChannelNumber(value); + if (mode === 'provider') { + onApplyGroupChange({ + ...group, + custom_properties: { + ...(group.custom_properties || {}), + channel_numbering_fallback: normalized, + }, + }); + } else { + // If End is set and the new Start exceeds it, drop End so the user + // is not left holding an invalid range silently. + const next = { ...group, auto_sync_channel_start: normalized }; + if ( + endValue !== null && + endValue !== undefined && + normalized > endValue + ) { + next.auto_sync_channel_end = null; + } + onApplyGroupChange(next); + } + // Sweep effect picks up the state change and dispatches the scan. + }; + const updateEnd = (value) => { + const normalized = + value === '' || value === null || value === undefined + ? null + : clampChannelNumber(value); + onApplyGroupChange({ + ...group, + auto_sync_channel_end: normalized, + }); + }; + + const streamCount = + typeof group.stream_count === 'number' ? group.stream_count : null; + const metaParts = []; + if (endValue) { + metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`); + } + if (streamCount !== null) { + metaParts.push(`Streams: ${streamCount}`); + } + const metaText = metaParts.join(' · '); + + const conflict = groupConflicts[group.channel_group]; + const hasChannelConflict = !!conflict?.hasChannelConflict; + const overlaps = computeRangeOverlapsFor(group, groupStates); + + // Channel-level conflicts get a generic Channels-page pointer (count + // can be large); range-level overlaps stay specific to the modal. + const tooltipSections = []; + if (hasChannelConflict) { + tooltipSections.push( + 'Range conflicts with configured channels.\nView the Channels page to inspect.' + ); + } + if (overlaps.length > 0) { + const overlapLines = overlaps + .map((o) => `${o.name} (${o.start}-${o.end})`) + .join('\n '); + tooltipSections.push( + overlaps.length === 1 + ? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})` + : `Range overlaps with:\n ${overlapLines}` + ); + } + const showWarning = tooltipSections.length > 0; + const tooltipBody = tooltipSections.join('\n\n'); + + return ( + + + + + + + + + + + + {metaText} + + {showWarning && ( + + + + )} + + + ); +}; + +export default AutoSyncBasic; diff --git a/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx new file mode 100644 index 00000000..52150e30 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; +import { updatePlaylist } from '../../utils/forms/M3uUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { Box, Group, SegmentedControl, Text, Tooltip } from '@mantine/core'; + +const OrphanCleanupControl = ({ playlist }) => { + // Local state mirrors the persisted mode so the SegmentedControl + // reflects clicks immediately even when the parent's playlist prop is + // a stale snapshot from before the PATCH lands. + const [orphanCleanupMode, setOrphanCleanupMode] = useState( + (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' + ); + + useEffect(() => { + setOrphanCleanupMode( + (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' + ); + }, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]); + + const handleOrphanCleanupChange = async (mode) => { + if (!playlist?.id) return; + const previousMode = orphanCleanupMode; + setOrphanCleanupMode(mode); + const nextProps = { + ...(playlist.custom_properties || {}), + orphan_channel_cleanup: mode, + }; + try { + await updatePlaylist(playlist, { custom_properties: nextProps }); + } catch (err) { + setOrphanCleanupMode(previousMode); + showNotification({ + title: 'Failed to update cleanup mode', + message: err?.body?.detail || err?.message || 'Please try again.', + color: 'red', + }); + } + }; + + return ( + + + + + Auto-sync orphan cleanup + + + + + + {orphanCleanupMode === 'always' && + 'Removes any auto-synced channel whose source stream is gone from this provider.'} + {orphanCleanupMode === 'preserve_customized' && + 'Removes orphaned auto-synced channels except those with active overrides.'} + {orphanCleanupMode === 'never' && + 'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'} + + + ); +}; + +export default OrphanCleanupControl; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 90b95a42..ee8c4e91 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -931,7 +931,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => { label="Hidden" checked={watch('hidden_from_output')} onChange={(event) => - setValue('hidden_from_output', event.currentTarget.checked) + setValue( + 'hidden_from_output', + event.currentTarget.checked + ) } size="md" /> diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 5e5ddfd3..101928af 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,20 +1,13 @@ // Modal.js import React from 'react'; -import API from '../../api'; -import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { Alert, Button, Flex, Modal, TextInput } from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; import { showNotification } from '../../utils/notificationUtils.js'; - -const updateChannelGroup = (channelGroup, values) => { - return API.updateChannelGroup({ - id: channelGroup.id, - ...values, - }); -}; -const addChannelGroup = (values) => { - return API.addChannelGroup(values); -}; +import { + addChannelGroup, + updateChannelGroup, +} from '../../utils/forms/ChannelGroupUtils.js'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 453d9494..22694406 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -1,44 +1,49 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Modal, - Stack, + ActionIcon, + Alert, + Badge, + Box, + Button, + Chip, + Divider, + Flex, Group, + Modal, + ScrollArea, + Stack, Text, TextInput, - Button, - ActionIcon, - Flex, - Badge, - Alert, - Divider, - ScrollArea, useMantineTheme, - Chip, - Box, } from '@mantine/core'; import { - SquarePlus, - SquarePen, - SquareMinus, - Check, - X, AlertCircle, + Check, Database, - Tv, - Trash, Filter, + SquareMinus, + SquarePen, + SquarePlus, + Trash, + Tv, + X, } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; -import API from '../../api'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + addChannelGroup, + cleanupUnusedChannelGroups, + deleteChannelGroup, + updateChannelGroup, +} from '../../utils/forms/ChannelGroupUtils.js'; // Move GroupItem outside to prevent recreation on every render const GroupItem = React.memo( ({ group, - editingGroup, + editingGroupId, editName, onEditNameChange, onSaveEdit, @@ -90,7 +95,7 @@ const GroupItem = React.memo( border: '1px solid #444', borderRadius: '8px', backgroundColor: - editingGroup === group.id + editingGroupId === group.id ? '#3f3f46' : group.enabled ? '#2A2A2E' @@ -101,7 +106,7 @@ const GroupItem = React.memo( > - {editingGroup === group.id ? ( + {editingGroupId === group.id ? ( - {editingGroup === group.id ? ( + {editingGroupId === group.id ? ( <> @@ -167,7 +172,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); - const [editingGroup, setEditingGroup] = useState(null); + const [editingGroupId, setEditingGroupId] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); const [isCreating, setIsCreating] = useState(false); @@ -197,83 +202,44 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); - // Filter groups based on search term and chip filters - const filteredGroups = useMemo(() => { - let filtered = sortedGroups; + const { filteredGroups, filterCounts } = useMemo(() => { + const counts = { channels: 0, m3u: 0, unused: 0 }; + const filtered = []; - // Apply search filter - if (searchTerm.trim()) { - filtered = filtered.filter((group) => - group.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - } - - // Apply chip filters - filtered = filtered.filter((group) => { + for (const group of sortedGroups) { const usage = groupUsage[group.id]; - if (!usage) return false; + if (!usage) continue; - const hasChannels = usage.hasChannels; - const hasM3U = usage.hasM3UAccounts; + const { hasChannels, hasM3UAccounts: hasM3U } = usage; const isUnused = !hasChannels && !hasM3U; - // If group is unused, only show if unused groups are enabled - if (isUnused) { - return showUnusedGroups; + if (hasChannels) counts.channels++; + if (hasM3U) counts.m3u++; + if (isUnused) counts.unused++; + + const matchesSearch = + !searchTerm.trim() || + group.name.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesFilter = isUnused + ? showUnusedGroups + : (hasChannels && showChannelGroups) || (hasM3U && showM3UGroups); + + if (matchesSearch && matchesFilter) { + filtered.push(group); } + } - // For groups with channels and/or M3U, show if either filter is enabled - let shouldShow = false; - if (hasChannels && showChannelGroups) shouldShow = true; - if (hasM3U && showM3UGroups) shouldShow = true; - - return shouldShow; - }); - - return filtered; + return { filteredGroups: filtered, filterCounts: counts }; }, [ sortedGroups, + groupUsage, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, - groupUsage, ]); - // Calculate filter counts - const filterCounts = useMemo(() => { - const counts = { - channels: 0, - m3u: 0, - unused: 0, - }; - - sortedGroups.forEach((group) => { - const usage = groupUsage[group.id]; - if (usage) { - const hasChannels = usage.hasChannels; - const hasM3U = usage.hasM3UAccounts; - - // Count groups with channels (including those with both) - if (hasChannels) { - counts.channels++; - } - - // Count groups with M3U (including those with both) - if (hasM3U) { - counts.m3u++; - } - - // Count truly unused groups - if (!hasChannels && !hasM3U) { - counts.unused++; - } - } - }); - - return counts; - }, [sortedGroups, groupUsage]); - const fetchGroupUsage = useCallback(async () => { setLoading(true); try { @@ -305,13 +271,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }, [isOpen, fetchGroupUsage]); const handleEdit = useCallback((group) => { - setEditingGroup(group.id); + setEditingGroupId(group.id); setEditName(group.name); }, []); const handleSaveEdit = useCallback(async () => { if (!editName.trim()) { - notifications.show({ + showNotification({ title: 'Error', message: 'Group name cannot be empty', color: 'red', @@ -320,37 +286,36 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } try { - await API.updateChannelGroup({ - id: editingGroup, + await updateChannelGroup(channelGroups[editingGroupId], { name: editName.trim(), }); - notifications.show({ + showNotification({ title: 'Success', message: 'Group updated successfully', color: 'green', }); - setEditingGroup(null); + setEditingGroupId(null); setEditName(''); await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to update group', color: 'red', }); } - }, [editName, editingGroup, fetchGroupUsage]); + }, [editName, editingGroupId, fetchGroupUsage]); const handleCancelEdit = useCallback(() => { - setEditingGroup(null); + setEditingGroupId(null); setEditName(''); }, []); const handleCreate = useCallback(async () => { if (!newGroupName.trim()) { - notifications.show({ + showNotification({ title: 'Error', message: 'Group name cannot be empty', color: 'red', @@ -359,11 +324,11 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } try { - await API.addChannelGroup({ + await addChannelGroup({ name: newGroupName.trim(), }); - notifications.show({ + showNotification({ title: 'Success', message: 'Group created successfully', color: 'green', @@ -373,7 +338,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setIsCreating(false); await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to create group', color: 'red', @@ -385,9 +350,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { async (group) => { setDeletingGroup(true); try { - await API.deleteChannelGroup(group.id); + await deleteChannelGroup(group); - notifications.show({ + showNotification({ title: 'Success', message: 'Group deleted successfully', color: 'green', @@ -395,7 +360,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to delete group', color: 'red', @@ -416,7 +381,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts) ) { - notifications.show({ + showNotification({ title: 'Cannot Delete', message: 'This group is associated with channels or M3U accounts and cannot be deleted', @@ -441,9 +406,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const executeCleanup = useCallback(async () => { setIsCleaningUp(true); try { - const result = await API.cleanupUnusedChannelGroups(); + const result = await cleanupUnusedChannelGroups(); - notifications.show({ + showNotification({ title: 'Cleanup Complete', message: `Successfully deleted ${result.deleted_count} unused groups`, color: 'green', @@ -452,7 +417,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { await fetchGroupUsage(); // Refresh usage data setConfirmCleanupOpen(false); } catch (error) { - notifications.show({ + showNotification({ title: 'Cleanup Failed', message: 'Failed to cleanup unused groups', color: 'red', @@ -653,7 +618,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { import('./AutoSyncAdvanced.jsx')); +const LogoForm = React.lazy(() => import('./Logo.jsx')); +import { + abortTimers, + computeAutoSyncStart, + getChannelsInRange, + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isExpectedOccupantForGroup, + isGroupVisible, + rangeFor, +} from '../../utils/forms/LiveGroupFilterUtils.js'; const LiveGroupFilter = ({ playlist, @@ -50,7 +46,6 @@ const LiveGroupFilter = ({ setAutoEnableNewGroupsLive, }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); - const profiles = useChannelsStore((s) => s.profiles); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles); const [groupFilter, setGroupFilter] = useState(''); @@ -107,31 +102,6 @@ const LiveGroupFilter = ({ ); }; - // "Expected" occupants are this group's own auto-sync output: - // auto_created, in this group on this account, no channel_number - // override. Channels from any other provider, group, or with a user - // pin all surface as a warning so the user is aware their range - // overlaps with existing assignments. Sync still merges shared - // ranges across providers, so the warning is informational rather - // than blocking. - const isExpectedOccupantForGroup = (occupant, groupChannelGroupId) => { - if (!occupant) return false; - if (!occupant.auto_created) return false; - if (occupant.has_channel_number_override) return false; - if ( - occupant.channel_group_id !== undefined && - occupant.channel_group_id !== groupChannelGroupId - ) - return false; - if ( - occupant.auto_created_by_account_id !== undefined && - playlist?.id !== undefined && - occupant.auto_created_by_account_id !== playlist.id - ) - return false; - return true; - }; - // Update one source ('occupant' or 'form') of a group's conflict // tracking and re-merge into the public `groupConflicts` state. const setConflictSource = (groupId, source, value) => { @@ -181,14 +151,12 @@ const LiveGroupFilter = ({ const controller = new AbortController(); conflictAbortRef.current[groupId] = controller; try { - const result = await API.getChannelsInRange(start, end, { - signal: controller.signal, - }); + const result = await getChannelsInRange(start, end, controller); const occupants = Array.isArray(result?.occupants) ? result.occupants : []; const unexpected = occupants.filter( - (o) => !isExpectedOccupantForGroup(o, groupId) + (o) => !isExpectedOccupantForGroup(o, groupId, playlist) ); setConflictSource(groupId, 'occupant', unexpected.length > 0); } catch (e) { @@ -203,16 +171,7 @@ const LiveGroupFilter = ({ // Clear pending timers and abort in-flight conflict-scan requests on // unmount so a late response cannot setState on an unmounted component. return () => { - Object.values(conflictTimersRef.current).forEach((t) => clearTimeout(t)); - conflictTimersRef.current = {}; - Object.values(conflictAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - conflictAbortRef.current = {}; + abortTimers(conflictTimersRef, conflictAbortRef); }; }, []); @@ -220,24 +179,6 @@ const LiveGroupFilter = ({ // (cheap). The HTTP-bound DB scan only runs for groups whose own // range fields changed since the last sweep. useEffect(() => { - const rangeFor = (g) => { - if (!g.enabled || !g.auto_channel_sync) return null; - const mode = g.custom_properties?.channel_numbering_mode || 'fixed'; - if (mode === 'next_available') return null; - const startRaw = - mode === 'provider' - ? (g.custom_properties?.channel_numbering_fallback ?? 1) - : (g.auto_sync_channel_start ?? 1); - const start = Number(startRaw); - if (!Number.isFinite(start)) return null; - const endRaw = g.auto_sync_channel_end; - const end = - endRaw === null || endRaw === undefined || endRaw === '' - ? start - : Number(endRaw); - return { start, end, startRaw }; - }; - const ranges = new Map(); for (const g of groupStates) { const r = rangeFor(g); @@ -360,15 +301,15 @@ const LiveGroupFilter = ({ regexPreviewAbortRef.current[groupId] = controller; let response; try { - response = await API.getStreamsRegexPreview(group.name, { - find: find || undefined, - replace: find ? replace : undefined, - match: match || undefined, - exclude: exclude || undefined, - limit: 10, - signal: controller.signal, - m3uAccountId: playlist?.id, - }); + response = await getStreamsRegexPreview( + group, + find, + replace, + match, + exclude, + controller, + playlist + ); } catch (e) { if (e?.name === 'AbortError') return; throw e; @@ -401,18 +342,7 @@ const LiveGroupFilter = ({ useEffect(() => { return () => { - Object.values(regexPreviewTimersRef.current).forEach((t) => - clearTimeout(t) - ); - regexPreviewTimersRef.current = {}; - Object.values(regexPreviewAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - regexPreviewAbortRef.current = {}; + abortTimers(regexPreviewTimersRef, regexPreviewAbortRef); }; }, []); @@ -423,12 +353,15 @@ const LiveGroupFilter = ({ useEffect(() => { if (!configuringGroup) return; const cp = configuringGroup.custom_properties || {}; - scheduleRegexPreview(configuringGroup, { - find: cp.name_regex_pattern || '', - replace: cp.name_replace_pattern ?? '', - match: cp.name_match_regex || '', - exclude: cp.name_match_exclude_regex || '', - }); + scheduleRegexPreview( + configuringGroup, + getRegexOptions( + cp.name_regex_pattern || '', + cp.name_replace_pattern ?? '', + cp.name_match_regex || '', + cp.name_match_exclude_regex || '' + ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [configuringGroup?.channel_group]); @@ -448,7 +381,7 @@ const LiveGroupFilter = ({ useEffect(() => { const fetchEPGSources = async () => { try { - const sources = await API.getEPGs(); + const sources = await getEPGs(); setEpgSources(sources || []); } catch (error) { console.error('Failed to fetch EPG sources:', error); @@ -526,29 +459,7 @@ const LiveGroupFilter = ({ const currentStart = state.auto_sync_channel_start; if (currentStart && currentStart > 1) return next; - let proposedStart = 1; - for (const other of prev) { - if (other.channel_group == id) continue; - if (!other.enabled || !other.auto_channel_sync) continue; - const otherMode = - other.custom_properties?.channel_numbering_mode || 'fixed'; - if (otherMode === 'next_available') continue; - const otherStart = Number( - otherMode === 'provider' - ? (other.custom_properties?.channel_numbering_fallback ?? 1) - : (other.auto_sync_channel_start ?? 1) - ); - if (!Number.isFinite(otherStart)) continue; - const otherEnd = - other.auto_sync_channel_end === null || - other.auto_sync_channel_end === undefined || - other.auto_sync_channel_end === '' - ? otherStart - : Number(other.auto_sync_channel_end); - const upper = Math.max(otherStart, otherEnd); - if (upper + 1 > proposedStart) proposedStart = upper + 1; - } - next.auto_sync_channel_start = proposedStart; + next.auto_sync_channel_start = computeAutoSyncStart(prev, id); return next; }) ); @@ -577,22 +488,13 @@ const LiveGroupFilter = ({ setCurrentEditingGroupId(null); }; - const isVisible = (group) => { - const matchesText = group.name - .toLowerCase() - .includes(groupFilter.toLowerCase()); - const matchesStatus = - statusFilter === 'all' || - (statusFilter === 'enabled' && group.enabled) || - (statusFilter === 'disabled' && !group.enabled); - return matchesText && matchesStatus; - }; - const selectAll = () => { setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? true : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? true + : state.enabled, })) ); }; @@ -601,1009 +503,13 @@ const LiveGroupFilter = ({ setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? false : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? false + : state.enabled, })) ); }; - // Returns {name, start, end}[] for groups whose declared ranges - // intersect this group's range, or [] when there is no overlap. - const computeRangeOverlapsFor = (group) => { - const myReservation = getGroupReservation(group); - if (!myReservation) return []; - const [myStart, myEnd] = myReservation; - const overlaps = []; - for (const other of groupStates) { - if (other.channel_group === group.channel_group) continue; - const otherReservation = getGroupReservation(other); - if (!otherReservation) continue; - const [oStart, oEnd] = otherReservation; - if (myStart <= oEnd && oStart <= myEnd) { - overlaps.push({ name: other.name, start: oStart, end: oEnd }); - } - } - return overlaps; - }; - - // Inline Start/End range inputs for Fixed and Provider modes. Start - // writes to auto_sync_channel_start (Fixed) or - // custom_properties.channel_numbering_fallback (Provider); End always - // writes to auto_sync_channel_end. Next Available shows a one-line - // explanation because a range contradicts pack-anywhere semantics. - const renderNumberingRange = (group) => { - const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; - if (mode === 'next_available') { - return ( - - Channels receive the lowest available numbers starting at 1. - - ); - } - const startValue = - mode === 'provider' - ? (group.custom_properties?.channel_numbering_fallback ?? 1) - : (group.auto_sync_channel_start ?? 1); - const endValue = group.auto_sync_channel_end; - // Caps pathological pasted values like "1e308" at the input layer. - const MAX_CHANNEL_NUMBER = 999999; - const clampChannelNumber = (n) => - Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1))); - const updateStart = (value) => { - const normalized = - value === '' || value === null || value === undefined - ? 1 - : clampChannelNumber(value); - if (mode === 'provider') { - applyGroupChange({ - ...group, - custom_properties: { - ...(group.custom_properties || {}), - channel_numbering_fallback: normalized, - }, - }); - } else { - // If End is set and the new Start exceeds it, drop End so the user - // is not left holding an invalid range silently. - const next = { ...group, auto_sync_channel_start: normalized }; - if ( - endValue !== null && - endValue !== undefined && - normalized > endValue - ) { - next.auto_sync_channel_end = null; - } - applyGroupChange(next); - } - // Sweep effect picks up the state change and dispatches the scan. - }; - const updateEnd = (value) => { - const normalized = - value === '' || value === null || value === undefined - ? null - : clampChannelNumber(value); - applyGroupChange({ - ...group, - auto_sync_channel_end: normalized, - }); - }; - - const streamCount = - typeof group.stream_count === 'number' ? group.stream_count : null; - const metaParts = []; - if (endValue) { - metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`); - } - if (streamCount !== null) { - metaParts.push(`Streams: ${streamCount}`); - } - const metaText = metaParts.join(' · '); - - const conflict = groupConflicts[group.channel_group]; - const hasChannelConflict = !!conflict?.hasChannelConflict; - const overlaps = computeRangeOverlapsFor(group); - - // Channel-level conflicts get a generic Channels-page pointer (count - // can be large); range-level overlaps stay specific to the modal. - const tooltipSections = []; - if (hasChannelConflict) { - tooltipSections.push( - 'Range conflicts with configured channels.\nView the Channels page to inspect.' - ); - } - if (overlaps.length > 0) { - const overlapLines = overlaps - .map((o) => `${o.name} (${o.start}-${o.end})`) - .join('\n '); - tooltipSections.push( - overlaps.length === 1 - ? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})` - : `Range overlaps with:\n ${overlapLines}` - ); - } - const showWarning = tooltipSections.length > 0; - const tooltipBody = tooltipSections.join('\n\n'); - - return ( - - - - - - - - - - - - {metaText} - - {showWarning && ( - - - - )} - - - ); - }; - - // Header line for the preview box. Adds a scan-cap suffix when the - // backend only scanned the first SCAN_CAP streams of the group. - const formatPreviewSummary = (label, result) => { - if (!result) return null; - const { match_count, total_in_group, total_scanned, scan_limit_hit } = - result; - const matchWord = `match${match_count === 1 ? '' : 'es'}`; - if (scan_limit_hit) { - return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`; - } - return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`; - }; - - // Find/replace regex preview backed by /streams/regex-preview/, so - // counts reflect the whole group (or up to SCAN_CAP) rather than a - // small client-side sample. - const renderRegexPreview = (group) => { - const find = group.custom_properties?.name_regex_pattern || ''; - if (!find) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = state.findResult; - const loading = state.loading; - return ( - - - {result ? formatPreviewSummary('rename', result) : 'Preview'} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : 'No streams matched this pattern.'} - - )} - {result?.matches?.map((row, idx) => ( - - - {row.before} - - - {' -> '} - - - {row.after} - - - ))} - - ); - }; - - // Shared preview box for include and exclude filters. The marker and - // color reflect whether matched names are kept (teal check) or dropped - // (red x); empty/loading/error states mirror the find preview. - const renderFilterPreview = (group, kind) => { - const pattern = - kind === 'exclude' - ? group.custom_properties?.name_match_exclude_regex || '' - : group.custom_properties?.name_match_regex || ''; - if (!pattern) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = - kind === 'exclude' ? state.excludeResult : state.filterResult; - const loading = state.loading; - const summaryLabel = kind === 'exclude' ? 'exclude' : 'filter'; - const placeholderLabel = - kind === 'exclude' ? 'Exclude preview' : 'Filter preview'; - const markerChar = kind === 'exclude' ? '✗' : '✓'; - const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4'; - const emptyText = - kind === 'exclude' - ? 'No streams matched this pattern (nothing would be excluded).' - : 'No streams matched this pattern.'; - return ( - - - {result - ? formatPreviewSummary(summaryLabel, result) - : placeholderLabel} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : emptyText} - - )} - {result?.matches?.map((row, idx) => ( - - - {markerChar} - - - {row.name} - - - ))} - - ); - }; - - const renderMatchPreview = (group) => renderFilterPreview(group, 'include'); - const renderExcludePreview = (group) => renderFilterPreview(group, 'exclude'); - - // Advanced Options form rendered inside the gear modal. A field's - // presence in custom_properties activates it; blanking returns the - // group to default behavior. - const renderAdvancedOptions = (group) => { - const cp = group.custom_properties || {}; - const setCp = (patch, clears = []) => { - const next = { ...cp, ...patch }; - clears.forEach((k) => delete next[k]); - applyGroupChange({ ...group, custom_properties: next }); - }; - - // --- Name Transforms --- - - const findValue = cp.name_regex_pattern ?? ''; - const replaceValue = cp.name_replace_pattern ?? ''; - const filterValue = cp.name_match_regex ?? ''; - const excludeValue = cp.name_match_exclude_regex ?? ''; - const updateFind = (val) => { - if (!val && !replaceValue) { - setCp({}, ['name_regex_pattern', 'name_replace_pattern']); - } else { - setCp({ - name_regex_pattern: val, - name_replace_pattern: replaceValue, - }); - } - scheduleRegexPreview(group, { - find: val, - replace: replaceValue, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateReplace = (val) => { - if (!val && !findValue) { - setCp({}, ['name_regex_pattern', 'name_replace_pattern']); - } else { - setCp({ - name_regex_pattern: findValue, - name_replace_pattern: val, - }); - } - scheduleRegexPreview(group, { - find: findValue, - replace: val, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateFilter = (val) => { - if (!val) setCp({}, ['name_match_regex']); - else setCp({ name_match_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: val, - exclude: excludeValue, - }); - }; - const updateExclude = (val) => { - if (!val) setCp({}, ['name_match_exclude_regex']); - else setCp({ name_match_exclude_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: filterValue, - exclude: val, - }); - }; - - // --- EPG --- - - const epgValue = (() => { - if (cp.custom_epg_id !== undefined && cp.custom_epg_id !== null) { - return cp.custom_epg_id.toString(); - } - if (cp.force_dummy_epg) return '0'; - return ''; - })(); - const updateEpg = (value) => { - const next = { ...cp }; - delete next.custom_epg_id; - delete next.force_dummy_epg; - delete next.force_epg_selected; - if (value === '0') { - next.force_dummy_epg = true; - } else if (value) { - next.custom_epg_id = parseInt(value); - } - applyGroupChange({ ...group, custom_properties: next }); - }; - - // --- Channel Assignment --- - - const groupOverrideValue = cp.group_override - ? cp.group_override.toString() - : ''; - const updateGroupOverride = (value) => { - if (!value) setCp({}, ['group_override']); - else setCp({ group_override: parseInt(value) }); - }; - - const profileValue = cp.channel_profile_ids ?? []; - const updateProfiles = (value) => { - if (!value || value.length === 0) { - setCp({}, ['channel_profile_ids']); - } else { - setCp({ channel_profile_ids: value }); - } - }; - - const streamProfileValue = cp.stream_profile_id - ? cp.stream_profile_id.toString() - : ''; - const updateStreamProfile = (value) => { - if (!value) setCp({}, ['stream_profile_id']); - else setCp({ stream_profile_id: parseInt(value) }); - }; - - const sortOrderValue = cp.channel_sort_order ?? '__default__'; - const sortReverseEnabled = cp.channel_sort_order !== undefined; - const updateSortOrder = (value) => { - if (!value || value === '__default__') { - setCp({}, ['channel_sort_order', 'channel_sort_reverse']); - } else { - setCp({ - channel_sort_order: value, - channel_sort_reverse: cp.channel_sort_reverse ?? false, - }); - } - }; - const updateSortReverse = (checked) => { - setCp({ channel_sort_reverse: checked }); - }; - - // --- Custom Logo --- - - const logoValue = cp.custom_logo_id; - - return ( - - - - Name Transforms - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - - updateFind(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - updateReplace(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - - {findValue && renderRegexPreview(group)} - - - - - - updateFilter(e.currentTarget.value)} - size="xs" - /> - {filterValue && renderMatchPreview(group)} - - - - - updateExclude(e.currentTarget.value)} - size="xs" - /> - {excludeValue && renderExcludePreview(group)} - - - - - - - - EPG & Logo - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - ({ - value: g.id.toString(), - label: g.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - ({ - value: profile.id.toString(), - label: profile.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - - - - {sortReverseEnabled && ( - - updateSortReverse(event.currentTarget.checked) - } - size="xs" - mt="xs" - /> - )} - - - - - - - { - if (event.currentTarget.checked) { - setCp({ compact_numbering: true }); - } else { - setCp({}, ['compact_numbering']); - } - }} - size="xs" - /> - - - - - - - - - ); - }; - - // Local state mirrors the persisted mode so the SegmentedControl - // reflects clicks immediately even when the parent's playlist prop is - // a stale snapshot from before the PATCH lands. - const [orphanCleanupMode, setOrphanCleanupMode] = useState( - (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' - ); - - useEffect(() => { - setOrphanCleanupMode( - (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' - ); - }, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]); - - const handleOrphanCleanupChange = async (mode) => { - if (!playlist?.id) return; - const previousMode = orphanCleanupMode; - setOrphanCleanupMode(mode); - const nextProps = { - ...(playlist.custom_properties || {}), - orphan_channel_cleanup: mode, - }; - try { - await API.updatePlaylist({ - id: playlist.id, - custom_properties: nextProps, - }); - } catch (err) { - setOrphanCleanupMode(previousMode); - notifications.show({ - title: 'Failed to update cleanup mode', - message: err?.body?.detail || err?.message || 'Please try again.', - color: 'red', - }); - } - }; - return ( } color="blue" variant="light"> @@ -1625,39 +531,7 @@ const LiveGroupFilter = ({ description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later." /> - - - - - Auto-sync orphan cleanup - - - - - - {orphanCleanupMode === 'always' && - 'Removes any auto-synced channel whose source stream is gone from this provider.'} - {orphanCleanupMode === 'preserve_customized' && - 'Removes orphaned auto-synced channels except those with active overrides.'} - {orphanCleanupMode === 'never' && - 'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'} - - + {groupStates - .filter((group) => isVisible(group)) + .filter((group) => isGroupVisible(group, groupFilter, statusFilter)) .sort((a, b) => a.name.localeCompare(b.name)) .map((group) => ( )} @@ -1894,18 +773,46 @@ const LiveGroupFilter = ({ }} group={configuringGroup} > - {configuringGroup && renderAdvancedOptions(configuringGroup)} + {configuringGroup && ( + + }> + { + setCurrentEditingGroupId(groupId); + setLogoModalOpen(true); + }} + channelLogos={channelLogos} + playlist={playlist} + logosLoading={logosLoading} + ensureLogosLoaded={ensureLogosLoaded} + /> + + + )} {/* Logo Upload Modal */} - { - setLogoModalOpen(false); - setCurrentEditingGroupId(null); - }} - onSuccess={handleLogoSuccess} - /> + {logoModalOpen && ( + + }> + { + setLogoModalOpen(false); + setCurrentEditingGroupId(null); + }} + onSuccess={handleLogoSuccess} + /> + + + )} ); }; diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index d190cba2..f9245731 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { Paper, Title, @@ -20,6 +19,7 @@ import { Checkbox, } from '@mantine/core'; import logo from '../../assets/logo.png'; +import { showNotification } from '../../utils/notificationUtils.js'; const LoginForm = () => { const login = useAuthStore((s) => s.login); @@ -132,7 +132,7 @@ const LoginForm = () => { } catch (e) { console.log(`Failed to login: ${e}`); if (e?.message === 'Unauthorized') { - notifications.show({ + showNotification({ title: 'Web UI Access Denied', message: 'This account is a Streamer account and cannot log into the web UI. ' + @@ -142,6 +142,7 @@ const LoginForm = () => { }); } await logout(); + } finally { setIsLoading(false); } }; diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 7f10a01b..d1f04595 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -1,45 +1,36 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; import { - Modal, - TextInput, - Button, - Group, - Stack, - Image, - Text, - Center, Box, + Button, + Center, Divider, + Group, + Image, + Modal, + Stack, + Text, + TextInput, } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import { Upload, FileImage, X } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; -import API from '../../api'; - -const schema = Yup.object({ - name: Yup.string().required('Name is required'), - url: Yup.string() - .required('URL is required') - .test( - 'valid-url-or-path', - 'Must be a valid URL or local file path', - (value) => { - if (!value) return false; - // Allow local file paths starting with /data/logos/ - if (value.startsWith('/data/logos/')) return true; - // Allow valid URLs - try { - new URL(value); - return true; - } catch { - return false; - } - } - ), -}); +import { + Dropzone, + DropzoneAccept, + DropzoneIdle, + DropzoneReject, +} from '@mantine/dropzone'; +import { FileImage, Upload, X } from 'lucide-react'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + createLogo, + getFilenameWithoutExtension, + getResolver, + getUpdateLogoErrorMessage, + getUploadErrorMessage, + releaseUrl, + updateLogo, + uploadLogo, + validateFileSize, +} from '../../utils/forms/LogoUtils.js'; const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const [logoPreview, setLogoPreview] = useState(null); @@ -63,7 +54,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { @@ -74,27 +65,14 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // If we have a selected file, upload it first if (selectedFile) { try { - uploadResponse = await API.uploadLogo(selectedFile, values.name); + uploadResponse = await uploadLogo(selectedFile, values); // Use the uploaded file data instead of form values values.name = uploadResponse.name; values.url = uploadResponse.url; } catch (uploadError) { - let errorMessage = 'Failed to upload logo file'; - - if ( - uploadError.code === 'NETWORK_ERROR' || - uploadError.message?.includes('timeout') - ) { - errorMessage = 'Upload timed out. Please try again.'; - } else if (uploadError.status === 413) { - errorMessage = 'File too large. Please choose a smaller file.'; - } else if (uploadError.body?.error) { - errorMessage = uploadError.body.error; - } - - notifications.show({ + showNotification({ title: 'Upload Error', - message: errorMessage, + message: getUploadErrorMessage(uploadError), color: 'red', }); return; // Don't proceed with creation if upload fails @@ -104,8 +82,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Now create or update the logo with the final values // Only proceed if we don't already have a logo from file upload if (logo) { - const updatedLogo = await API.updateLogo(logo.id, values); - notifications.show({ + const updatedLogo = await updateLogo(logo, values); + showNotification({ title: 'Success', message: 'Logo updated successfully', color: 'green', @@ -114,8 +92,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { } else if (!selectedFile) { // Only create a new logo entry if we're not uploading a file // (file upload already created the logo entry) - const newLogo = await API.createLogo(values); - notifications.show({ + const newLogo = await createLogo(values); + showNotification({ title: 'Success', message: 'Logo created successfully', color: 'green', @@ -123,7 +101,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates } else { // File was uploaded and logo was already created - notifications.show({ + showNotification({ title: 'Success', message: 'Logo uploaded successfully', color: 'green', @@ -132,23 +110,9 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { } onClose(); } catch (error) { - let errorMessage = logo - ? 'Failed to update logo' - : 'Failed to create logo'; - - // Handle specific timeout errors - if ( - error.code === 'NETWORK_ERROR' || - error.message?.includes('timeout') - ) { - errorMessage = 'Request timed out. Please try again.'; - } else if (error.response?.data?.error) { - errorMessage = error.response.data.error; - } - - notifications.show({ + showNotification({ title: 'Error', - message: errorMessage, + message: getUpdateLogoErrorMessage(logo, error), color: 'red', }); } finally { @@ -163,14 +127,17 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { }, [defaultValues, logo, reset]); const handleFileSelect = (files) => { - if (files.length === 0) return; + if (files.length === 0) { + console.log('No files selected'); + return; + } const file = files[0]; // Validate file size on frontend first - if (file.size > 5 * 1024 * 1024) { + if (!validateFileSize(file)) { // 5MB - notifications.show({ + showNotification({ title: 'Error', message: 'File too large. Maximum size is 5MB.', color: 'red', @@ -188,8 +155,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Auto-fill the name field if empty const currentName = watch('name'); if (!currentName) { - const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, ''); - setValue('name', nameWithoutExtension); + setValue('name', getFilenameWithoutExtension(file.name)); } // Set a placeholder URL (will be replaced after upload) @@ -204,9 +170,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { if (selectedFile) { setSelectedFile(null); // Revoke the object URL to free memory - if (logoPreview && logoPreview.startsWith('blob:')) { - URL.revokeObjectURL(logoPreview); - } + releaseUrl(logoPreview); } // Update preview for remote URLs @@ -224,7 +188,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const url = new URL(urlValue); const pathname = url.pathname; const filename = pathname.substring(pathname.lastIndexOf('/') + 1); - const nameWithoutExtension = filename.replace(/\.[^/.]+$/, ''); + const nameWithoutExtension = getFilenameWithoutExtension(filename); if (nameWithoutExtension) { setValue('name', nameWithoutExtension); } @@ -237,11 +201,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Clean up object URLs when component unmounts or preview changes useEffect(() => { - return () => { - if (logoPreview && logoPreview.startsWith('blob:')) { - URL.revokeObjectURL(logoPreview); - } - }; + return () => releaseUrl(logoPreview); }, [logoPreview]); return ( @@ -317,15 +277,15 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { mih={120} style={{ pointerEvents: 'none' }} > - + - - + + - - + + - +
diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 288151b5..e27570db 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -1,35 +1,40 @@ // Modal.js -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import useUserAgentsStore from '../../store/userAgents'; import M3UProfiles from './M3UProfiles'; import { - LoadingOverlay, - TextInput, + Box, Button, Checkbox, - Modal, - Flex, - Select, - FileInput, - NumberInput, Divider, - Stack, + FileInput, + Flex, Group, - Switch, - Box, + LoadingOverlay, + Modal, + NumberInput, PasswordInput, - Tooltip, + Select, + Stack, + Switch, + TextInput, } from '@mantine/core'; import M3UGroupFilter from './M3UGroupFilter'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; import useEPGsStore from '../../store/epgs'; import useVODStore from '../../store/useVODStore'; import M3UFilters from './M3UFilters'; import ScheduleInput from './ScheduleInput'; import { DateTimePicker } from '@mantine/dates'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { addEPG } from '../../utils/forms/DummyEpgUtils.js'; +import { + addPlaylist, + getPlaylist, + prepareSubmitValues, + updatePlaylist, +} from '../../utils/forms/M3uUtils.js'; const M3U = ({ m3uAccount = null, @@ -48,8 +53,6 @@ const M3U = ({ const [profileModalOpen, setProfileModalOpen] = useState(false); const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false); const [filterModalOpen, setFilterModalOpen] = useState(false); - const [loadingText, setLoadingText] = useState(''); - const [showCredentialFields, setShowCredentialFields] = useState(false); const [scheduleType, setScheduleType] = useState('interval'); const form = useForm({ @@ -110,12 +113,6 @@ const M3U = ({ ? 'cron' : 'interval' ); - - if (m3uAccount.account_type == 'XC') { - setShowCredentialFields(true); - } else { - setShowCredentialFields(false); - } } else { setPlaylist(null); form.reset(); @@ -124,107 +121,53 @@ const M3U = ({ } }, [m3uAccount]); - useEffect(() => { - if (form.values.account_type == 'XC') { - setShowCredentialFields(true); - } - }, [form.values.account_type]); - - const onSubmit = async () => { - const { create_epg, ...values } = form.getValues(); - - // Convert exp_date (from controlled state) to ISO string for the API - if (values.account_type === 'XC') { - // XC accounts have exp_date auto-managed server-side; don't send it - delete values.exp_date; - } else if (expDate instanceof Date) { - values.exp_date = expDate.toISOString(); - } else { - values.exp_date = null; - } - - // Determine which schedule type is active based on field values - const hasCronExpression = - values.cron_expression && values.cron_expression.trim() !== ''; - - // Clear the field that isn't active based on actual field values - if (hasCronExpression) { - values.refresh_interval = 0; - } else { - values.cron_expression = ''; - } - - if (values.account_type == 'XC' && values.password == '') { - // If account XC and no password input, assuming no password change - // from previously stored value. - delete values.password; - } - - if (values.user_agent == '0') { - values.user_agent = null; - } - - let newPlaylist; - if (playlist?.id) { - await API.updatePlaylist({ - id: playlist.id, - ...values, - file, + const handleNewPlaylist = async (newPlaylist, values, create_epg) => { + if (create_epg) { + addEPG({ + name: values.name, + source_type: 'xmltv', + url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`, + api_key: '', + is_active: true, + refresh_interval: 24, }); - } else { - newPlaylist = await API.addPlaylist({ - ...values, - file, + } + + if (values.account_type != 'XC') { + showNotification({ + title: 'Fetching M3U Groups', + message: + 'Configure group filters and auto sync settings once complete.', }); - - if (create_epg) { - API.addEPG({ - name: values.name, - source_type: 'xmltv', - url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`, - api_key: '', - is_active: true, - refresh_interval: 24, - }); - } - - if (values.account_type != 'XC') { - notifications.show({ - title: 'Fetching M3U Groups', - message: - 'Configure group filters and auto sync settings once complete.', - }); - - // Don't prompt for group filters, but keeping this here - // in case we want to revive it - newPlaylist = null; - close(); - return; - } - - // Fetch the updated playlist details (this also updates the store via API) - const updatedPlaylist = await API.getPlaylist(newPlaylist.id); - - // Note: We don't call fetchPlaylists() here because API.addPlaylist() - // already added the playlist to the store. Calling fetchPlaylists() creates - // a race condition where the store is temporarily cleared/replaced while - // websocket updates for the new playlist's refresh task are arriving. - await Promise.all([fetchChannelGroups(), fetchEPGs()]); - - // If this is an XC account with VOD enabled, also fetch VOD categories - if (values.account_type === 'XC' && values.enable_vod) { - fetchCategories(); - } - - console.log('opening group options'); - setPlaylist(updatedPlaylist); - setGroupFilterModalOpen(true); + close(); return; } - form.reset(); - setFile(null); - onClose(newPlaylist); + const updatedPlaylist = await getPlaylist(newPlaylist); + await Promise.all([fetchChannelGroups(), fetchEPGs()]); + + if (values.enable_vod) { + fetchCategories(); + } + + setPlaylist(updatedPlaylist); + setGroupFilterModalOpen(true); + }; + + const onSubmit = async () => { + const { create_epg, ...rawValues } = form.getValues(); + const values = prepareSubmitValues(rawValues, expDate); + + if (playlist?.id) { + await updatePlaylist(playlist, values, file); + form.reset(); + setFile(null); + onClose(); + return; + } + + const newPlaylist = await addPlaylist(values, file); + await handleNewPlaylist(newPlaylist, values, create_epg); }; const close = () => { @@ -270,15 +213,11 @@ const M3U = ({ trapFocus={false} yOffset="2vh" > - +
- + { const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); @@ -59,9 +62,9 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => { if (!filter) { // By default, new rule will go at the end values.order = m3u.filters.length; - await API.addM3UFilter(m3u.id, values); + await addM3UFilter(m3u, values); } else { - await API.updateM3UFilter(m3u.id, filter.id, values); + await updateM3UFilter(m3u, filter, values); } const updatedPlaylist = await fetchPlaylist(m3u.id); diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index acd0c446..fa4e065f 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -1,19 +1,18 @@ -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../../store/playlists'; import ConfirmationDialog from '../ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { - Flex, - Modal, - Button, - Box, ActionIcon, + Alert, + Box, + Button, + Center, + Flex, + Group, + Modal, Text, useMantineTheme, - Center, - Group, - Alert, } from '@mantine/core'; import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react'; import M3UFilter from './M3UFilter'; @@ -36,6 +35,10 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + deleteM3UFilter, + updateM3UFilter, +} from '../../utils/forms/M3uFilterUtils.js'; const RowDragHandleCell = ({ rowId }) => { const { attributes, listeners, setNodeRef } = useDraggable({ @@ -143,8 +146,6 @@ const DraggableRow = ({ filter, editFilter, onDelete }) => { }; const M3UFilters = ({ playlist, isOpen, onClose }) => { - const theme = useMantineTheme(); - const [editorOpen, setEditorOpen] = useState(false); const [filter, setFilter] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); @@ -195,7 +196,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { if (!playlist || !playlist.id) return; setDeleting(true); try { - await API.deleteM3UFilter(playlist.id, id); + await deleteM3UFilter(playlist, id); fetchPlaylist(playlist.id); setFilters(filters.filter((f) => f.id !== id)); } catch (error) { @@ -239,7 +240,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { try { await Promise.all( changedFilters.map((f) => - API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder }) + updateM3UFilter(playlist, f, { ...f, order: f.newOrder }) ) ); await fetchPlaylist(playlist.id); @@ -330,8 +331,8 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => {
{`Are you sure you want to delete the following filter? -Type: ${filterToDelete.type} -Patter: ${filterToDelete.regex_pattern} +Type: ${filterToDelete.filter_type} +Pattern: ${filterToDelete.regex_pattern} This action cannot be undone.`}
diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 4ee3b31b..5c7762ed 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -1,24 +1,31 @@ // Modal.js -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import { - LoadingOverlay, Button, - Modal, Flex, + LoadingOverlay, + Modal, Stack, Tabs, + TabsList, + TabsPanel, + TabsTab, } from '@mantine/core'; import useChannelsStore from '../../store/channels'; import useVODStore from '../../store/useVODStore'; -import { notifications } from '@mantine/notifications'; import LiveGroupFilter from './LiveGroupFilter'; import VODCategoryFilter from './VODCategoryFilter'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildGroupStates, + saveAndRefreshPlaylist, +} from '../../utils/forms/M3uGroupFilterUtils.js'; import { detectGroupReservationOverlaps } from '../../utils/forms/GroupSyncUtils'; const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const fetchCategories = useVODStore((s) => s.fetchCategories); + const [groupStates, setGroupStates] = useState([]); const [isLoading, setIsLoading] = useState(false); const [movieCategoryStates, setMovieCategoryStates] = useState([]); @@ -40,36 +47,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { }, [playlist]); useEffect(() => { - if (Object.keys(channelGroups).length === 0) { - return; - } - - setGroupStates( - playlist.channel_groups - .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist - .map((group) => { - // Parse custom_properties if present - let customProps = {}; - if (group.custom_properties) { - try { - customProps = - typeof group.custom_properties === 'string' - ? JSON.parse(group.custom_properties) - : group.custom_properties; - } catch { - customProps = {}; - } - } - return { - ...group, - name: channelGroups[group.channel_group].name, - auto_channel_sync: group.auto_channel_sync || false, - auto_sync_channel_start: group.auto_sync_channel_start || 1.0, - auto_sync_channel_end: group.auto_sync_channel_end ?? null, - custom_properties: customProps, - }; - }) - ); + if (Object.keys(channelGroups).length === 0) return; + setGroupStates(buildGroupStates(channelGroups, playlist.channel_groups)); }, [playlist, channelGroups]); // Fetch VOD categories when modal opens for XC accounts with VOD enabled @@ -92,7 +71,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { // names on hover, so the toast just confirms the save proceeded. const overlaps = detectGroupReservationOverlaps(groupStates); if (overlaps.length > 0) { - notifications.show({ + showNotification({ title: 'Overlapping channel number ranges', message: `Saved with ${overlaps.length} overlapping range pair${overlaps.length === 1 ? '' : 's'}. Hover the warning icon on each group for details. Sync will assign whichever numbers are free at run time.`, color: 'yellow', @@ -102,48 +81,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { setIsLoading(true); try { - // Prepare groupStates for API - // Send ALL group states like the original code did, don't filter by enabled changes - const groupSettings = groupStates.map((state) => ({ - ...state, - custom_properties: state.custom_properties || undefined, - })); - - const categorySettings = movieCategoryStates - .concat(seriesCategoryStates) - .map((state) => ({ - ...state, - custom_properties: state.custom_properties || undefined, - })) - .filter((state) => state.enabled !== state.original_enabled); - - // Update account-level settings via the proper account endpoint - await API.updatePlaylist({ - id: playlist.id, - auto_enable_new_groups_live: autoEnableNewGroupsLive, - auto_enable_new_groups_vod: autoEnableNewGroupsVod, - auto_enable_new_groups_series: autoEnableNewGroupsSeries, - }); - - // Update group settings via API endpoint - await API.updateM3UGroupSettings( - playlist.id, - groupSettings, - categorySettings + await saveAndRefreshPlaylist( + playlist, + groupStates, + movieCategoryStates, + seriesCategoryStates, + { + auto_enable_new_groups_live: autoEnableNewGroupsLive, + auto_enable_new_groups_vod: autoEnableNewGroupsVod, + auto_enable_new_groups_series: autoEnableNewGroupsSeries, + } ); - // Show notification about the refresh process - notifications.show({ + showNotification({ title: 'Group Settings Updated', message: 'Settings saved. Starting M3U refresh to apply changes...', color: 'green', autoClose: 3000, }); - // Refresh the playlist - this will handle channel sync automatically at the end - await API.refreshPlaylist(playlist.id); - - notifications.show({ + showNotification({ title: 'M3U Refresh Started', message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.', @@ -178,13 +135,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { - - Live - VOD - Movies - VOD - Series - + + Live + VOD - Movies + VOD - Series + - + { autoEnableNewGroupsLive={autoEnableNewGroupsLive} setAutoEnableNewGroupsLive={setAutoEnableNewGroupsLive} /> - + - + { autoEnableNewGroups={autoEnableNewGroupsVod} setAutoEnableNewGroups={setAutoEnableNewGroupsVod} /> - + - + { autoEnableNewGroups={autoEnableNewGroupsSeries} setAutoEnableNewGroups={setAutoEnableNewGroupsSeries} /> - + diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 1243a5ca..4fdd7e0f 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -1,26 +1,38 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Alert, - Flex, - Modal, - TextInput, - Button, - Title, - Text, - Paper, Badge, + Button, + Flex, Grid, - Textarea, + GridCol, + Modal, NumberInput, + Paper, SegmentedControl, + Text, + Textarea, + TextInput, + Title, } from '@mantine/core'; import { TriangleAlert } from 'lucide-react'; import { DateTimePicker } from '@mantine/dates'; import { useWebSocket } from '../../WebSocket'; +import { + addM3UProfile, + applyRegex, + applyXcSimplePatterns, + buildProfileSchema, + buildSubmitValues, + fetchFirstStreamUrl, + getDetectedMode, + prepareExpDate, + splitByPattern, + updateM3UProfile, + validateXcSimple, +} from '../../utils/forms/M3uProfileUtils.js'; const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const [websocketReady, sendMessage] = useWebSocket(); @@ -49,20 +61,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { [profile] ); - const schema = Yup.object({ - name: Yup.string().required('Name is required'), - search_pattern: Yup.string().when([], { - is: () => !isDefaultProfile && !isXC, - then: (schema) => schema.required('Search pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - replace_pattern: Yup.string().when([], { - is: () => !isDefaultProfile && !isXC, - then: (schema) => schema.required('Replace pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - notes: Yup.string(), // Optional field - }); + const getResolver = () => { + return yupResolver(buildProfileSchema(isDefaultProfile, isXC)); + }; const { register, @@ -74,38 +75,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setError, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - console.log('submiting'); + const expDate = prepareExpDate(values.exp_date, isXC); - // Convert exp_date for submission - let expDateValue = values.exp_date; - if (isXC) { - // XC accounts have exp_date auto-managed; don't send it - expDateValue = undefined; - } else if (expDateValue instanceof Date) { - expDateValue = expDateValue.toISOString(); - } else if (!expDateValue) { - expDateValue = null; - } - - // For XC simple mode: validate simple inputs and build patterns from credentials if (isXC && xcMode === 'simple' && !isDefaultProfile) { - const errs = {}; - if (!newUsername.trim()) errs.newUsername = 'New username is required'; - if (!newPassword.trim()) errs.newPassword = 'New password is required'; + const errs = validateXcSimple(newUsername, newPassword); if (Object.keys(errs).length > 0) { setSimpleErrors(errs); return; } setSimpleErrors({}); - values.search_pattern = `${m3u?.username || ''}/${m3u?.password || ''}`; - values.replace_pattern = `${newUsername.trim()}/${newPassword.trim()}`; + values = applyXcSimplePatterns(values, m3u, newUsername, newPassword); } - // For XC advanced mode: validate regex pattern fields if (isXC && xcMode === 'advanced' && !isDefaultProfile) { if (!searchPattern.trim()) { setError('search_pattern', { message: 'Search pattern is required' }); @@ -117,76 +102,36 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { } } - // Build submit values - let submitValues; - if (isDefaultProfile) { - submitValues = { - name: values.name, - search_pattern: searchPattern || '', - replace_pattern: replacePattern || '', - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - }, - }; - } else { - // For regular profiles, send all fields - submitValues = { - name: values.name, - max_streams: values.max_streams, - search_pattern: values.search_pattern, - replace_pattern: values.replace_pattern, - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - ...(isXC ? { xcMode } : {}), - }, - }; - } + const submitValues = buildSubmitValues( + values, + profile, + isDefaultProfile, + isXC, + xcMode + ); + if (expDate !== undefined) submitValues.exp_date = expDate; - // Add exp_date for non-XC accounts - if (expDateValue !== undefined) { - submitValues.exp_date = expDateValue; - } - - if (profile?.id) { - await API.updateM3UProfile(m3u.id, { - id: profile.id, - ...submitValues, - }); - } else { - await API.addM3UProfile(m3u.id, submitValues); - } + profile?.id + ? await updateM3UProfile(m3u.id, { ...submitValues, id: profile.id }) + : await addM3UProfile(m3u.id, submitValues); reset(); - // Reset local state to sync with form reset setSearchPattern(''); setReplacePattern(''); onClose(); }; useEffect(() => { - async function fetchStreamUrl() { - try { - if (!m3u?.id) return; + if (!m3u?.id) return; - const params = new URLSearchParams(); - params.append('page', 1); - params.append('page_size', 1); - params.append('m3u_account', m3u.id); - const response = await API.queryStreams(params); - - if (response?.results?.length > 0) { - setStreamUrl(response.results[0].url); - setSampleInput(response.results[0].url); // Initialize sample input with a real stream URL + fetchFirstStreamUrl(m3u.id) + .then((url) => { + if (url) { + setStreamUrl(url); + setSampleInput(url); } - } catch (error) { - console.error('Error fetching stream URL:', error); - } - } - fetchStreamUrl(); + }) + .catch((error) => console.error('Error fetching stream URL:', error)); }, [m3u]); useEffect(() => { @@ -239,19 +184,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setReplacePattern(profile?.replace_pattern || ''); if (isXC && !isDefaultProfile) { const storedMode = profile?.custom_properties?.xcMode; - let detectedMode; - if (storedMode) { - detectedMode = storedMode; - } else if ( - profile?.search_pattern && - profile.search_pattern === `${m3u?.username}/${m3u?.password}` - ) { - detectedMode = 'simple'; - } else if (profile?.search_pattern) { - detectedMode = 'advanced'; - } else { - detectedMode = 'simple'; - } + const detectedMode = getDetectedMode(storedMode, profile, m3u); setXcMode(detectedMode); if (detectedMode === 'simple') { const rp = profile?.replace_pattern || ''; @@ -294,49 +227,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setXcMode(mode); }; - // Local regex for the live demo preview. Returns an array of strings and - // React nodes so user-supplied text is never interpolated into raw - // HTML (avoids self-XSS via dangerouslySetInnerHTML). const getHighlightedSearchText = () => { - if (!searchPattern || !sampleInput) return sampleInput; - try { - const regex = new RegExp(searchPattern, 'g'); - const parts = []; - let lastIndex = 0; - let m; - while ((m = regex.exec(sampleInput)) !== null) { - if (m.index > lastIndex) { - parts.push(sampleInput.slice(lastIndex, m.index)); - } - parts.push( - - {m[0]} - - ); - lastIndex = m.index + m[0].length; - if (m[0].length === 0) regex.lastIndex++; - } - if (lastIndex < sampleInput.length) { - parts.push(sampleInput.slice(lastIndex)); - } - return parts; - } catch { - return sampleInput; - } + const segments = splitByPattern(sampleInput, searchPattern); + if (!segments) return sampleInput; + return segments.map((seg, i) => + seg.matched ? ( + + {seg.text} + + ) : ( + seg.text + ) + ); }; - const getLocalReplaceResult = () => { - if (!searchPattern || !sampleInput) return sampleInput; - try { - const regex = new RegExp(searchPattern, 'g'); - return sampleInput.replace(regex, replacePattern); - } catch { - return sampleInput; - } - }; + const getLocalReplaceResult = () => + applyRegex(sampleInput, searchPattern, replacePattern); return ( { > These patterns are applied to every stream in this playlist. If - the search pattern doesn't match a stream URL, the original - URL is used as-is. + the search pattern doesn't match a stream URL, the original URL + is used as-is. { - + Matched Text{' '} @@ -554,9 +460,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { {getHighlightedSearchText()} - + - + Result After Replace @@ -568,7 +474,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { {getLocalReplaceResult()} - + )} diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index efbc1ed8..462c1655 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,32 +1,165 @@ -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import M3UProfile from './M3UProfile'; import AccountInfoModal from './AccountInfoModal'; import usePlaylistsStore from '../../store/playlists'; import ConfirmationDialog from '../ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { - Card, - Checkbox, - Flex, - Modal, - Button, - Box, ActionIcon, - Text, - NumberInput, - useMantineTheme, - Center, - Group, - Switch, Badge, + Button, + Card, + Flex, + Group, + Modal, + NumberInput, Stack, + Switch, + Text, + useMantineTheme, } from '@mantine/core'; -import { SquareMinus, SquarePen, Info } from 'lucide-react'; +import { Info, SquareMinus, SquarePen } from 'lucide-react'; +import { + deleteM3UProfile, + updateM3UProfile, +} from '../../utils/forms/M3uProfileUtils.js'; +import { + getExpirationInfo, + isAccountExpired, + profileSortComparator, +} from '../../utils/forms/M3uProfilesUtils.js'; + +const M3uProfileCard = ({ + item, + accountType, + onClickInfo, + onClickEdit, + onClickDelete, + onChangeMaxStreams, + onChangeActive, +}) => { + const theme = useMantineTheme(); + const accountStatus = item.custom_properties?.user_info?.status ?? null; + const expirationInfo = getExpirationInfo(item); + const expired = isAccountExpired(item); + + return ( + + + {/* Header with name and status badges */} + + + + {item.name} + {/* Show notes if they exist */} + {item.custom_properties?.notes && ( + + {item.custom_properties.notes} + + )} + + {accountType === 'XC' && item.custom_properties && ( + + {/* Account status badge */} + {accountStatus && ( + + {accountStatus} + + )} + {/* Expiration badge */} + {expirationInfo && ( + + {expirationInfo.text} + + )} + {/* Info button next to badges */} + + + + + )} + + + + {/* Max Streams and Actions */} + + + + + {/* Toggle switch */} + + + {/* Always show edit button, but limit what can be edited for default profiles */} + + + + + {!item.is_default && ( + <> + + + + + )} + + + + + ); +}; const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { - const theme = useMantineTheme(); - const allProfiles = usePlaylistsStore((s) => s.profiles); const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -91,7 +224,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { if (!playlist || !playlist.id) return; setDeletingProfile(true); try { - await API.deleteM3UProfile(playlist.id, id); + await deleteM3UProfile(playlist.id, id); } catch (error) { console.error('Error deleting profile:', error); } finally { @@ -103,7 +236,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const toggleActive = async (values) => { if (!playlist || !playlist.id) return; try { - await API.updateM3UProfile(playlist.id, { + await updateM3UProfile(playlist.id, { ...values, is_active: !values.is_active, }); @@ -115,7 +248,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const modifyMaxStreams = async (value, item) => { if (!playlist || !playlist.id) return; try { - await API.updateM3UProfile(playlist.id, { + await updateM3UProfile(playlist.id, { ...item, max_streams: value, }); @@ -142,49 +275,6 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { setAccountInfoOpen(false); }; - // Helper function to get account status from profile - const getAccountStatus = (profile) => { - if (!profile.custom_properties?.user_info) return null; - return profile.custom_properties.user_info.status; - }; - - // Helper function to check if account is expired - const isAccountExpired = (profile) => { - if (!profile.custom_properties?.user_info?.exp_date) return false; - try { - const expDate = new Date( - parseInt(profile.custom_properties.user_info.exp_date) * 1000 - ); - return expDate < new Date(); - } catch { - return false; - } - }; - - // Helper function to get account expiration info - const getExpirationInfo = (profile) => { - if (!profile.custom_properties?.user_info?.exp_date) return null; - try { - const expDate = new Date( - parseInt(profile.custom_properties.user_info.exp_date) * 1000 - ); - const now = new Date(); - const diffMs = expDate - now; - - if (diffMs <= 0) return { text: 'Expired', color: 'red' }; - - const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (days > 30) return { text: `${days} days`, color: 'green' }; - if (days > 7) return { text: `${days} days`, color: 'yellow' }; - if (days > 0) return { text: `${days} days`, color: 'orange' }; - - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - return { text: `${hours}h`, color: 'red' }; - } catch { - return null; - } - }; - // Don't render if modal is not open, or if playlist data is invalid if (!isOpen || !playlist || !playlist.id) { return <>; @@ -204,141 +294,20 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { withinPortal={true} yOffset="2vh" > - {profilesArray - .sort((a, b) => { - // Always put default profile first - if (a.is_default) return -1; - if (b.is_default) return 1; - // Sort remaining profiles alphabetically by name - return a.name.localeCompare(b.name); - }) - .map((item) => { - const accountStatus = getAccountStatus(item); - const expirationInfo = getExpirationInfo(item); - const expired = isAccountExpired(item); - - return ( - - - {/* Header with name and status badges */} - - - - {item.name} - {/* Show notes if they exist */} - {item.custom_properties?.notes && ( - - {item.custom_properties.notes} - - )} - - {playlist?.account_type === 'XC' && - item.custom_properties && ( - - {/* Account status badge */} - {accountStatus && ( - - {accountStatus} - - )} - {/* Expiration badge */} - {expirationInfo && ( - - {expirationInfo.text} - - )} - {/* Info button next to badges */} - showAccountInfo(item)} - title="View account information" - style={{ - backgroundColor: 'rgba(34, 139, 230, 0.1)', - color: '#228be6', - }} - > - - - - )} - - - - {/* Max Streams and Actions */} - - modifyMaxStreams(value, item)} - style={{ flex: 1 }} - /> - - - {/* Toggle switch */} - toggleActive(item)} - disabled={item.is_default} - label="Active" - labelPosition="left" - size="sm" - /> - - {/* Always show edit button, but limit what can be edited for default profiles */} - editProfile(item)} - title={ - item.is_default - ? 'Edit profile name and notes' - : 'Edit profile' - } - > - - - - {!item.is_default && ( - <> - deleteProfile(item.id)} - size="small" - variant="transparent" - title="Delete profile" - > - - - - )} - - - - - ); - })} + {profilesArray.sort(profileSortComparator).map((item) => { + return ( + showAccountInfo(item)} + onChangeMaxStreams={(value) => modifyMaxStreams(value, item)} + onChangeActive={() => toggleActive(item)} + onClickEdit={() => editProfile(item)} + onClickDelete={() => deleteProfile(item.id)} + /> + ); + })} + ), + Center: ({ children }) =>
{children}
, + Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => ( +
+ {label && } + +
+ ), + Popover: ({ children }) =>
{children}
, + PopoverDropdown: ({ children }) => ( +
{children}
+ ), + PopoverTarget: ({ children }) => ( +
{children}
+ ), + ScrollArea: ({ children }) =>
{children}
, + Select: ({ + value, + onChange, + label, + data, + 'data-testid': testId, + clearable, + }) => ( +
+ {label && } + +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + TextInput: ({ + value, + onChange, + label, + placeholder, + 'data-testid': testId, + }) => ( +
+ {label && } + onChange?.(e)} + /> +
+ ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import AutoSyncAdvanced from '../AutoSyncAdvanced'; +import useChannelsStore from '../../../store/channels.jsx'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { + getEpgSourceValue, + getEpgSourceData, + repackGroupChannels, + formatPreviewSummary, +} from '../../../utils/forms/AutoSyncAdvancedUtils.js'; +import { getRegexOptions } from '../../../utils/forms/LiveGroupFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + logo_url: null, + auto_sync_enabled: true, + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + custom_properties: {}, + ...overrides, +}); + +const makeEpgSource = (overrides = {}) => ({ + id: 1, + name: 'My EPG', + source_type: 'xmltv', + ...overrides, +}); + +const makePlaylist = (overrides = {}) => ({ + id: 5, + custom_properties: {}, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + group: makeGroup(), + epgSources: [makeEpgSource()], + channelGroups: [], + streamProfiles: [], + regexPreviewState: {}, + onApplyGroupChange: vi.fn(), + onScheduleRegexPreview: vi.fn(), + onOpenLogoUpload: vi.fn(), + channelLogos: [], + playlist: makePlaylist(), + logosLoading: false, + ensureLogosLoaded: vi.fn(), + ...overrides, +}); + +const renderComponent = (overrides = {}) => { + const props = defaultProps(overrides); + const utils = render(); + return { ...props, ...utils }; +}; + +const setupStore = () => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchGroups: vi.fn(), profiles: {} }) + ); +}; + +// ────────────────────────────────────────────────────────────────────────────── +describe('AutoSyncAdvanced', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getEpgSourceValue).mockReturnValue(null); + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'My EPG (xmltv)' }, + ]); + vi.mocked(repackGroupChannels).mockResolvedValue({}); + vi.mocked(formatPreviewSummary).mockReturnValue(null); + setupStore(); + }); + + // ── Basic rendering ──────────────────────────────────────────────────────── + describe('basic rendering', () => { + it('renders without crashing', () => { + renderComponent(); + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + + it('calls getEpgSourceData with epgSources on render', () => { + const epgSources = [ + makeEpgSource(), + makeEpgSource({ id: 2, name: 'EPG 2' }), + ]; + renderComponent({ epgSources }); + expect(getEpgSourceData).toHaveBeenCalledWith(epgSources); + }); + + it('calls getEpgSourceValue with group.custom_properties on render', () => { + const group = makeGroup(); + const epgSources = [makeEpgSource()]; + renderComponent({ group, epgSources }); + expect(getEpgSourceValue).toHaveBeenCalledWith({}); + }); + }); + + // ── RegexPreviewBox ──────────────────────────────────────────────────────── + describe('RegexPreviewBox', () => { + it('does not render preview box when no include regex is set', () => { + renderComponent({ group: makeGroup({ custom_properties: {} }) }); + expect(screen.queryByText(/matched/i)).not.toBeInTheDocument(); + }); + + it('renders include preview box when name_match_regex is set', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'ESPN' }, + }); + const regexPreviewState = { + 1: { + filterResult: { + match_count: 3, + total_scanned: 50, + scan_limit_hit: false, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '3 filter matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('3 filter matches in 50 streams') + ).toBeInTheDocument(); + }); + + it('renders exclude preview box when name_match_exclude_regex is set', () => { + const group = makeGroup({ + custom_properties: { name_match_exclude_regex: 'HD' }, + }); + const regexPreviewState = { + 1: { + excludeResult: { + match_count: 2, + total_scanned: 50, + scan_limit_hit: false, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '2 exclude matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('2 exclude matches in 50 streams') + ).toBeInTheDocument(); + }); + + it('renders find preview box when name_regex_pattern is set', () => { + const group = makeGroup({ + custom_properties: { + name_regex_pattern: 'News', + name_replace_pattern: '', + }, + }); + const regexPreviewState = { + 1: { + findResult: { + matches: [ + { before: 'BBC News', after: 'BBC' }, + { before: 'Sky News', after: 'Sky' }, + { before: 'CNN News', after: 'CNN' }, + { before: 'Fox News', after: 'Fox' }, + { before: 'Al Jazeera News', after: 'Al Jazeera' }, + ], + total_in_group: 50, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '5 regex matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('5 regex matches in 50 streams') + ).toBeInTheDocument(); + expect(formatPreviewSummary).toHaveBeenCalledWith( + 'rename', + regexPreviewState[1].findResult + ); + }); + + it('shows loading state in preview box when state.loading is true', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'Sports' }, + }); + const regexPreviewState = { 1: { loading: true } }; + renderComponent({ group, regexPreviewState }); + // No summary rendered during loading + expect(formatPreviewSummary).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ match_count: expect.any(Number) }) + ); + }); + + it('does not render include preview when name_match_regex is empty string', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: '' }, + }); + renderComponent({ group, regexPreviewState: {} }); + expect(formatPreviewSummary).not.toHaveBeenCalled(); + }); + + it('renders regex error message when result has an error', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'bad(' }, + }); + const regexPreviewState = { + 1: { filterResult: { error: 'Unterminated group' }, loading: false }, + }; + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText(/invalid regex.*unterminated group/i) + ).toBeInTheDocument(); + }); + + it('renders no-match message when filter result has empty matches', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'ZZZ' }, + }); + const regexPreviewState = { + 1: { + filterResult: { matches: [], total_in_group: 10, error: null }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '0 filter matches in 10 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText(/no streams matched this pattern/i) + ).toBeInTheDocument(); + }); + }); + + // ── EPG source select ────────────────────────────────────────────────────── + describe('EPG source select', () => { + it('passes EPG source data from getEpgSourceData to the Select', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'My EPG (xmltv)' }, + { value: '2', label: 'EPG 2 (m3u)' }, + ]); + renderComponent(); + const options = screen.getAllByRole('option'); + const labels = options.map((o) => o.textContent); + expect(labels).toEqual( + expect.arrayContaining(['My EPG (xmltv)', 'EPG 2 (m3u)']) + ); + }); + + it('pre-selects the value returned by getEpgSourceValue', () => { + vi.mocked(getEpgSourceValue).mockReturnValue('2'); + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'EPG 1' }, + { value: '2', label: 'EPG 2' }, + ]); + renderComponent(); + // The select with value '2' is present + const select = screen + .getAllByRole('combobox') + .find((s) => s.value === '2'); + expect(select).toBeTruthy(); + }); + + it('calls onApplyGroupChange when EPG source is changed', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'EPG 1' }, + { value: '2', label: 'EPG 2' }, + ]); + const { onApplyGroupChange } = renderComponent(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[0], { target: { value: '2' } }); + expect(onApplyGroupChange).toHaveBeenCalled(); + }); + }); + + // ── Regex text inputs ────────────────────────────────────────────────────── + describe('regex text inputs', () => { + it('renders the include regex input', () => { + renderComponent(); + // TextInput mocked with label as testid fallback + const inputs = screen.getAllByRole('textbox'); + expect(inputs.length).toBeGreaterThan(0); + }); + + it('calls onScheduleRegexPreview when find regex changes', () => { + const { onScheduleRegexPreview } = renderComponent(); + const findInput = screen.getByTestId('Find (Regex)'); + fireEvent.change(findInput, { target: { value: 'ESPN' } }); + expect(onScheduleRegexPreview).toHaveBeenCalled(); + }); + + it('calls onScheduleRegexPreview when include regex changes', () => { + const { onScheduleRegexPreview } = renderComponent(); + const includeInput = screen.getByTestId( + 'Include if name matches (Regex)' + ); + fireEvent.change(includeInput, { target: { value: 'ESPN' } }); + expect(onScheduleRegexPreview).toHaveBeenCalled(); + }); + }); + + // ── Repack channels ──────────────────────────────────────────────────────── + describe('repack channels button', () => { + it('calls repackGroupChannels with playlist and group objects', async () => { + vi.mocked(repackGroupChannels).mockResolvedValue({ + assigned: 5, + released: 2, + failed: 0, + }); + const group = makeGroup({ channel_group: 7 }); + const playlist = makePlaylist({ id: 3 }); + renderComponent({ group, playlist }); + + const repackBtn = screen.getByRole('button', { name: /renumber now/i }); + fireEvent.click(repackBtn); + await waitFor(() => { + expect(repackGroupChannels).toHaveBeenCalledWith(playlist, group); + }); + }); + + it('shows success notification after repack resolves', async () => { + vi.mocked(repackGroupChannels).mockResolvedValue({}); + renderComponent(); + const repackBtn = screen.queryByRole('button', { name: /repack/i }); + if (repackBtn) { + fireEvent.click(repackBtn); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: expect.stringMatching(/teal|green|blue/i), + }) + ); + }); + } + }); + + it('shows error notification when repack fails', async () => { + vi.mocked(repackGroupChannels).mockRejectedValue( + new Error('Server error') + ); + renderComponent(); + const repackBtn = screen.queryByRole('button', { name: /repack/i }); + if (repackBtn) { + fireEvent.click(repackBtn); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: expect.stringMatching(/red/i) }) + ); + }); + } + }); + }); + + // ── Logo upload ──────────────────────────────────────────────────────────── + describe('logo upload', () => { + it('renders logo-related controls', () => { + renderComponent(); + const logos = screen.queryAllByTestId('lazy-logo'); + expect(logos.length).toBeGreaterThanOrEqual(0); + }); + + it('calls onOpenLogoUpload when logo upload button is clicked', () => { + const { onOpenLogoUpload } = renderComponent(); + const uploadBtn = screen.queryByRole('button', { name: /upload|logo/i }); + if (uploadBtn) { + fireEvent.click(uploadBtn); + expect(onOpenLogoUpload).toHaveBeenCalled(); + } + }); + + it('passes channelLogos to the logo selector when provided', () => { + const channelLogos = [ + { id: 'logo1', url: '/logos/espn.png', name: 'ESPN' }, + { id: 'logo2', url: '/logos/cnn.png', name: 'CNN' }, + ]; + renderComponent({ channelLogos }); + // Just verify rendering doesn't throw with logos + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + }); + + // ── Stream profiles ──────────────────────────────────────────────────────── + describe('stream profiles', () => { + it('populates profile options from streamProfiles prop', () => { + const streamProfiles = [ + { id: 1, name: 'HD Profile' }, + { id: 2, name: 'SD Profile' }, + ]; + renderComponent({ streamProfiles }); + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + }); + + // ── Custom properties toggles ────────────────────────────────────────────── + describe('custom properties toggles', () => { + it('renders switches/checkboxes for advanced options', () => { + renderComponent(); + const switches = screen.queryAllByRole('switch'); + const checkboxes = screen.queryAllByRole('checkbox'); + expect(switches.length + checkboxes.length).toBeGreaterThan(0); + }); + + it('calls onApplyGroupChange with compact_numbering when switch is toggled on', () => { + const { onApplyGroupChange } = renderComponent(); + const compactSwitch = screen.getByTestId('Compact numbering'); + fireEvent.click(compactSwitch); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ + compact_numbering: true, + }), + }) + ); + }); + + it('selects force_dummy_epg option via EPG select', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '0', label: 'No EPG' }, + { value: '1', label: 'My EPG' }, + ]); + const { onApplyGroupChange } = renderComponent(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[0], { target: { value: '0' } }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ force_dummy_epg: true }), + }) + ); + }); + }); + + // ── Regex options (MultiSelect) ──────────────────────────────────────────── + describe('regex options MultiSelect', () => { + it('calls getRegexOptions when find input changes', () => { + renderComponent(); + const findInput = screen.getByTestId('Find (Regex)'); + fireEvent.change(findInput, { target: { value: 'ESPN' } }); + expect(getRegexOptions).toHaveBeenCalled(); + }); + }); + + // ── useEffect / prop sync ────────────────────────────────────────────────── + describe('prop sync', () => { + it('re-calls getEpgSourceValue when group prop changes', () => { + const { rerender } = render(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(1); + + const newGroup = makeGroup({ channel_group: 2, name: 'News' }); + rerender(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(2); + }); + + it('re-calls getEpgSourceData when epgSources prop changes', () => { + const { rerender } = render(); + const callCount = vi.mocked(getEpgSourceData).mock.calls.length; + + const newSources = [ + makeEpgSource(), + makeEpgSource({ id: 99, name: 'New' }), + ]; + rerender( + + ); + expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan( + callCount + ); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx new file mode 100644 index 00000000..67055a4a --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx @@ -0,0 +1,429 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/AutoSyncBasicUtils.js', () => ({ + clampChannelNumber: vi.fn((v) => + Math.min(999999, Math.max(1, Math.floor(Number(v) || 1))) + ), + computeRangeOverlapsFor: vi.fn(() => []), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Flex: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), + NumberInput: ({ + value, + onChange, + label, + 'data-testid': testId, + min, + max, + }) => ( +
+ {label && } + + onChange?.(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import AutoSyncBasic from '../AutoSyncBasic'; +import { + clampChannelNumber, + computeRangeOverlapsFor, +} from '../../../utils/forms/AutoSyncBasicUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + custom_properties: { channel_numbering_mode: 'fixed' }, + ...overrides, +}); + +const renderComponent = (groupOverrides = {}, props = {}) => { + const group = makeGroup(groupOverrides); + const onApplyGroupChange = vi.fn(); + const utils = render( + + ); + return { group, onApplyGroupChange, ...utils }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +describe('AutoSyncBasic', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(computeRangeOverlapsFor).mockReturnValue([]); + vi.mocked(clampChannelNumber).mockImplementation((v) => + Math.min(999999, Math.max(1, Math.floor(Number(v) || 1))) + ); + }); + + // ── next_available mode ──────────────────────────────────────────────────── + describe('next_available mode', () => { + it('renders the explanation text instead of inputs', () => { + renderComponent({ + custom_properties: { channel_numbering_mode: 'next_available' }, + }); + expect( + screen.getByText(/Channels receive the lowest available numbers/i) + ).toBeInTheDocument(); + }); + + it('does not render any NumberInput', () => { + renderComponent({ + custom_properties: { channel_numbering_mode: 'next_available' }, + }); + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + }); + }); + + // ── fixed mode rendering ─────────────────────────────────────────────────── + describe('fixed mode rendering', () => { + it('renders Start and End inputs', () => { + renderComponent(); + expect(screen.getByTestId(/start/i)).toBeInTheDocument(); + expect(screen.getByTestId(/end/i)).toBeInTheDocument(); + }); + + it('displays auto_sync_channel_start as the start value', () => { + renderComponent({ auto_sync_channel_start: 150 }); + expect(screen.getByTestId(/start/i)).toHaveValue(150); + }); + + it('defaults start to 1 when auto_sync_channel_start is undefined', () => { + renderComponent({ auto_sync_channel_start: undefined }); + expect(screen.getByTestId(/start/i)).toHaveValue(1); + }); + + it('displays auto_sync_channel_end as the end value', () => { + renderComponent({ auto_sync_channel_end: 250 }); + expect(screen.getByTestId(/end/i)).toHaveValue(250); + }); + + it('displays empty end when auto_sync_channel_end is undefined', () => { + renderComponent({ auto_sync_channel_end: undefined }); + expect(screen.getByTestId(/end/i)).toHaveValue(null); + }); + }); + + // ── provider mode rendering ──────────────────────────────────────────────── + describe('provider mode rendering', () => { + it('reads start from custom_properties.channel_numbering_fallback', () => { + renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 500, + }, + }); + expect(screen.getByTestId(/start/i)).toHaveValue(500); + }); + + it('defaults fallback start to 1 when channel_numbering_fallback is undefined', () => { + renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + }, + }); + expect(screen.getByTestId(/start/i)).toHaveValue(1); + }); + }); + + // ── defaults when no mode is set ────────────────────────────────────────── + describe('defaults when mode is absent', () => { + it('treats missing mode as fixed and renders inputs', () => { + renderComponent({ custom_properties: {} }); + expect(screen.getByTestId(/start/i)).toBeInTheDocument(); + expect(screen.getByTestId(/end/i)).toBeInTheDocument(); + }); + }); + + // ── updateStart — fixed mode ─────────────────────────────────────────────── + describe('updateStart in fixed mode', () => { + it('calls onApplyGroupChange with clamped start value', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '150' }, + }); + expect(clampChannelNumber).toHaveBeenCalledWith(150); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_start: expect.any(Number) }) + ); + }); + + it('normalizes empty string input to 1', () => { + const { onApplyGroupChange } = renderComponent(); + fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_start: 1 }) + ); + expect(clampChannelNumber).not.toHaveBeenCalled(); + }); + + it('drops auto_sync_channel_end when new start exceeds current end', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(300); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '300' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_end: null }) + ); + }); + + it('keeps auto_sync_channel_end when new start is below end', () => { + const { onApplyGroupChange, group } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(150); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '150' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + auto_sync_channel_end: group.auto_sync_channel_end, + }) + ); + }); + + it('keeps end when there is no existing end value', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: undefined, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(500); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '500' }, + }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.auto_sync_channel_end).toBeFalsy(); + }); + }); + + // ── updateStart — provider mode ──────────────────────────────────────────── + describe('updateStart in provider mode', () => { + it('writes to custom_properties.channel_numbering_fallback', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 100, + }, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(400); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '400' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ + channel_numbering_fallback: 400, + }), + }) + ); + }); + + it('merges existing custom_properties when updating fallback', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 100, + some_other_key: true, + }, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(200); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '200' }, + }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.custom_properties.some_other_key).toBe(true); + expect(call.custom_properties.channel_numbering_mode).toBe('provider'); + }); + + it('normalizes empty string to 1 in provider mode', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { channel_numbering_mode: 'provider' }, + }); + fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.custom_properties.channel_numbering_fallback).toBe(1); + }); + }); + + // ── updateEnd ────────────────────────────────────────────────────────────── + describe('updateEnd', () => { + it('calls onApplyGroupChange with clamped end value', () => { + const { onApplyGroupChange } = renderComponent(); + vi.mocked(clampChannelNumber).mockReturnValueOnce(300); + fireEvent.change(screen.getByTestId(/end/i), { + target: { value: '300' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_end: 300 }) + ); + }); + + it('normalizes empty end to null/falsy', () => { + const { onApplyGroupChange } = renderComponent(); + fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect( + call.auto_sync_channel_end == null || call.auto_sync_channel_end === '' + ).toBe(true); + }); + + it('does not call clampChannelNumber when end is cleared', () => { + renderComponent(); + fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } }); + expect(clampChannelNumber).not.toHaveBeenCalled(); + }); + }); + + // ── Meta text ────────────────────────────────────────────────────────────── + describe('meta text', () => { + it('shows range info when end is set', () => { + renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + expect(screen.getByTestId('text').textContent).toMatch(/100.*200|range/i); + }); + + it('shows stream count from group when available', () => { + const group = makeGroup({ auto_sync_channel_end: 200, stream_count: 42 }); + const groupStates = [{ channel_group: 1 }]; + render( + + ); + expect(screen.getByTestId('text').textContent).toMatch(/42/); + }); + }); + + // ── Overlap and conflict warnings ────────────────────────────────────────── + describe('warnings', () => { + it('does not render warning icon when no conflicts or overlaps', () => { + renderComponent({}, { groupConflicts: {} }); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + + it('renders warning icon when computeRangeOverlapsFor returns overlaps', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'Other Group', start: 150, end: 160 }, + ]); + renderComponent(); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('renders warning icon when group has a channel conflict', () => { + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('passes overlap group name to tooltip', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'News Group', start: 150, end: 160 }, + ]); + renderComponent(); + + const tooltip = screen + .getAllByTestId('tooltip') + .find((t) => t.getAttribute('data-label')?.includes('News Group')); + expect(tooltip).toBeDefined(); + }); + + it('passes channel conflict message to tooltip', () => { + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + + const tooltip = screen + .getAllByTestId('tooltip') + .find((t) => + t.getAttribute('data-label')?.toLowerCase().includes('channel') + ); + expect(tooltip).toBeDefined(); + }); + + it('includes both conflict and overlap info in tooltip when both present', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'Overlap Group', start: 110, end: 120 }, + ]); + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + + const tooltip = screen.getAllByTestId('tooltip').find((t) => { + const label = t.getAttribute('data-label')?.toLowerCase(); + return label?.includes('channel') && label.includes('overlap group'); + }); + expect(tooltip).toBeDefined(); + }); + + it('calls computeRangeOverlapsFor with the group and groupStates', () => { + const group = makeGroup(); + const groupStates = [makeGroup({ channel_group: 2, name: 'B' })]; + render( + + ); + expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx new file mode 100644 index 00000000..509f835f --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx @@ -0,0 +1,232 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module mocks ─────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uUtils.js', () => ({ + updatePlaylist: vi.fn(() => Promise.resolve({})), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + Tooltip: ({ children }) => <>{children}, + SegmentedControl: ({ value, onChange, data }) => ( +
+ {data.map((opt) => ( + + ))} +
+ ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import OrphanCleanupControl from '../AutoSyncOrphanCleanup'; +import { updatePlaylist } from '../../../utils/forms/M3uUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 7, + custom_properties: null, + ...overrides, +}); + +const renderControl = (playlistOverrides = {}) => + render(); + +const getControl = () => screen.getByTestId('segmented-control'); + +// ────────────────────────────────────────────────────────────────────────────── +describe('OrphanCleanupControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Initial state ────────────────────────────────────────────────────────── + describe('initial state', () => { + it('defaults to "always" when custom_properties is null', () => { + renderControl({ custom_properties: null }); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('defaults to "always" when orphan_channel_cleanup key is absent', () => { + renderControl({ custom_properties: { compact_numbering: true } }); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('reads persisted "preserve_customized" mode from custom_properties', () => { + renderControl({ + custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, + }); + expect(getControl()).toHaveAttribute('data-value', 'preserve_customized'); + }); + + it('reads persisted "never" mode from custom_properties', () => { + renderControl({ + custom_properties: { orphan_channel_cleanup: 'never' }, + }); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + }); + + // ── Optimistic update ────────────────────────────────────────────────────── + describe('on mode change', () => { + it('updates the displayed value immediately before the PATCH resolves', () => { + vi.mocked(updatePlaylist).mockReturnValue(new Promise(() => {})); // never resolves + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + + it('calls updatePlaylist with merged custom_properties', async () => { + renderControl({ custom_properties: { compact_numbering: true } }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(updatePlaylist).toHaveBeenCalledWith( + expect.objectContaining({ id: 7 }), + { + custom_properties: { + compact_numbering: true, + orphan_channel_cleanup: 'never', + }, + } + ); + }); + }); + + it('does not call updatePlaylist when playlist has no id', async () => { + renderControl({ id: undefined }); + fireEvent.click(screen.getByTestId('segmented-never')); + await Promise.resolve(); + expect(updatePlaylist).not.toHaveBeenCalled(); + }); + + it('preserves final "always" value after successful PATCH', async () => { + renderControl({ custom_properties: { orphan_channel_cleanup: 'never' } }); + fireEvent.click(screen.getByTestId('segmented-always')); + await waitFor(() => expect(updatePlaylist).toHaveBeenCalled()); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + }); + + // ── Error / revert ───────────────────────────────────────────────────────── + describe('on PATCH failure', () => { + it('reverts to the previous mode', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce( + new Error('Server error') + ); + renderControl({ + custom_properties: { orphan_channel_cleanup: 'always' }, + }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => expect(showNotification).toHaveBeenCalled()); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('shows a red error notification', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce( + new Error('Server error') + ); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + }); + + it('includes err.message in the notification when body.detail is absent', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce(new Error('Timeout')); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Timeout' }) + ); + }); + }); + + it('prefers err.body.detail over err.message', async () => { + const err = Object.assign(new Error('generic'), { + body: { detail: 'Specific detail from server' }, + }); + vi.mocked(updatePlaylist).mockRejectedValueOnce(err); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Specific detail from server' }) + ); + }); + }); + + it('falls back to "Please try again." when error has no message or body', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce({}); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please try again.' }) + ); + }); + }); + }); + + // ── useEffect sync ───────────────────────────────────────────────────────── + describe('useEffect sync on prop change', () => { + it('syncs displayed mode when playlist.custom_properties changes', () => { + const { rerender } = render( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + + it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => { + const { rerender } = render( + + ); + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx b/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx index b159a681..35c00b51 100644 --- a/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx +++ b/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx @@ -1,17 +1,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ChannelGroup from '../ChannelGroup'; -import API from '../../../api'; import useChannelsStore from '../../../store/channels'; import { showNotification } from '../../../utils/notificationUtils.js'; import { useForm } from '@mantine/form'; +import * as API from '../../../utils/forms/ChannelGroupUtils.js'; // ── API mock ─────────────────────────────────────────────────────────────────── -vi.mock('../../../api', () => ({ - default: { - addChannelGroup: vi.fn(), - updateChannelGroup: vi.fn(), - }, +vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({ + addChannelGroup: vi.fn(), + updateChannelGroup: vi.fn(), })); // ── Store mock ───────────────────────────────────────────────────────────────── @@ -265,8 +263,7 @@ describe('ChannelGroup', () => { fireEvent.submit(screen.getByTestId('modal').querySelector('form')); await waitFor(() => { - expect(API.updateChannelGroup).toHaveBeenCalledWith({ - id: 1, + expect(API.updateChannelGroup).toHaveBeenCalledWith(group, { name: 'Sports Updated', }); }); diff --git a/frontend/src/components/forms/__tests__/GroupManager.test.jsx b/frontend/src/components/forms/__tests__/GroupManager.test.jsx new file mode 100644 index 00000000..cff0c19b --- /dev/null +++ b/frontend/src/components/forms/__tests__/GroupManager.test.jsx @@ -0,0 +1,811 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import GroupManager from '../GroupManager'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({ + addChannelGroup: vi.fn(), + cleanupUnusedChannelGroups: vi.fn(), + deleteChannelGroup: vi.fn(), + updateChannelGroup: vi.fn(), +})); + +// ── ConfirmationDialog mock ──────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertCircle: () => , + Check: () => , + Database: () => , + Filter: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + Trash: () => , + Tv: () => , + X: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Alert: ({ children, title }) => ( +
+ {title &&
{title}
} + {children} +
+ ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, leftSection }) => ( + + ), + Chip: ({ children, checked, onChange }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + ScrollArea: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ value, onChange, placeholder, label, onKeyDown }) => ( + + ), + useMantineTheme: () => ({ + tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] }, + }), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useWarningsStore from '../../../store/warnings'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as ChannelGroupUtils from '../../../utils/forms/ChannelGroupUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + hasChannels: true, + hasM3UAccounts: false, + canEdit: true, + canDelete: true, + ...overrides, +}); + +const setupMocks = ({ groups = {}, isWarningSuppressed = false } = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelGroups: groups, + canEditChannelGroup: vi.fn().mockReturnValue(true), + canDeleteChannelGroup: vi.fn().mockReturnValue(true), + }) + ); + + const mockSuppressWarning = vi.fn(); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ + isWarningSuppressed: () => isWarningSuppressed, + suppressWarning: mockSuppressWarning, + }) + ); + + return { mockSuppressWarning }; +}; + +const renderGroupManager = (props = {}) => + render(); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('GroupManager', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders nothing when isOpen is false', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders the modal when isOpen is true', () => { + setupMocks(); + renderGroupManager(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('calls onClose when modal close button is clicked', () => { + setupMocks(); + const onClose = vi.fn(); + renderGroupManager({ onClose }); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Group rendering ──────────────────────────────────────────────────────── + + describe('group list rendering', () => { + it('renders a group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + + it('renders multiple groups sorted alphabetically', () => { + setupMocks({ + groups: { + 2: makeGroup({ id: 2, name: 'Zebra' }), + 1: makeGroup({ id: 1, name: 'Alpha' }), + }, + }); + renderGroupManager(); + const names = screen + .getAllByText(/Alpha|Zebra/) + .map((el) => el.textContent); + expect(names.indexOf('Alpha')).toBeLessThan(names.indexOf('Zebra')); + }); + + it('renders channel badge for groups with channels', () => { + setupMocks({ groups: { 1: makeGroup({ hasChannels: true }) } }); + renderGroupManager(); + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('renders M3U badge for groups with M3U accounts', () => { + setupMocks({ + groups: { 1: makeGroup({ hasChannels: false, hasM3UAccounts: true }) }, + }); + renderGroupManager(); + expect(screen.getByTestId('icon-database')).toBeInTheDocument(); + }); + + it('shows empty state when no groups match filter', () => { + setupMocks({ groups: {} }); + renderGroupManager(); + // No groups rendered in scroll area + expect(screen.queryByText('Group A')).not.toBeInTheDocument(); + }); + }); + + // ── Search filtering ─────────────────────────────────────────────────────── + + describe('search filtering', () => { + it('filters groups by search term', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('Movies')).not.toBeInTheDocument(); + }); + + it('shows all groups when search is cleared', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + fireEvent.change(searchInput, { target: { value: '' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + + it('is case-insensitive', () => { + setupMocks({ groups: { 1: makeGroup({ name: 'Sports' }) } }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + }); + + // ── Edit group ───────────────────────────────────────────────────────────── + + describe('edit group', () => { + it('shows edit input when edit button is clicked', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + expect(screen.getByDisplayValue('Group A')).toBeInTheDocument(); + }); + + it('calls updateChannelGroup with trimmed name on save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: ' Updated Name ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.updateChannelGroup).toHaveBeenCalledWith( + makeGroup(), + { name: 'Updated Name' } + ); + }); + }); + + it('shows success notification after save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty on save', async () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.updateChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when updateChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('cancels edit and restores group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: 'Changed' } }); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + + expect(screen.queryByDisplayValue('Changed')).not.toBeInTheDocument(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + }); + + // ── Create group ─────────────────────────────────────────────────────────── + + describe('create group', () => { + it('calls addChannelGroup with trimmed name', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: ' New Group ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({ + name: 'New Group', + }); + }); + }); + + it('shows success notification after create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty', async () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.addChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when addChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('clears new group name and hides form after successful create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText(/Enter group name/i) + ).not.toBeInTheDocument(); + }); + }); + + it('cancels creation form with X button', () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + expect( + screen.getByPlaceholderText(/Enter group name/i) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + expect( + screen.queryByPlaceholderText(/Enter group name/i) + ).not.toBeInTheDocument(); + }); + }); + + // ── Delete group ─────────────────────────────────────────────────────────── + + describe('delete group', () => { + it('opens confirmation dialog when delete is clicked', async () => { + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Group Deletion' + ); + }); + }); + + it('calls deleteChannelGroup when confirmed', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + const groupToDelete = { + ...makeGroup(), + hasChannels: false, + hasM3UAccounts: false, + }; + setupMocks({ groups: { 1: groupToDelete } }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith( + groupToDelete + ); + }); + }); + + it('shows success notification after delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when deleteChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('closes confirmation dialog on cancel', () => { + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('closes confirmation dialog after successful delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + + describe('cleanup unused groups', () => { + const successResponse = { deleted_count: 1 }; + + it('opens cleanup confirmation dialog when warning not suppressed', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup'); + }); + + it('calls cleanupUnusedChannelGroups directly when warning is suppressed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('calls cleanupUnusedChannelGroups when cleanup confirmed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + }); + + it('shows success notification after cleanup', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green' }) + ); + }); + }); + + it('shows error notification when cleanup throws', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockRejectedValue( + new Error('fail') + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + }); + + it('closes cleanup dialog on cancel', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + // ── Filter chips ─────────────────────────────────────────────────────────── + + describe('filter chips', () => { + const twoGroups = { + 1: makeGroup({ + id: 1, + name: 'Channel Group', + hasChannels: true, + hasM3UAccounts: false, + }), + 2: makeGroup({ + id: 2, + name: 'M3U Group', + hasChannels: false, + hasM3UAccounts: true, + }), + }; + + it('hides channel groups when Channels chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const channelChip = screen.getByLabelText(/channel groups/i); + fireEvent.click(channelChip); + + expect(screen.queryByText('Channel Group')).not.toBeInTheDocument(); + expect(screen.getByText('M3U Group')).toBeInTheDocument(); + }); + + it('hides M3U groups when M3U chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const m3uChip = screen.getByLabelText(/m3u groups/i); + fireEvent.click(m3uChip); + + expect(screen.getByText('Channel Group')).toBeInTheDocument(); + expect(screen.queryByText('M3U Group')).not.toBeInTheDocument(); + }); + + it('hides unused groups when Unused chip is unchecked', () => { + setupMocks({ + groups: { + 1: makeGroup({ + id: 1, + name: 'Unused Group', + hasChannels: false, + hasM3UAccounts: false, + }), + 2: makeGroup({ + id: 2, + name: 'Used Group', + hasChannels: true, + hasM3UAccounts: false, + }), + }, + }); + renderGroupManager(); + + const unusedChip = screen.getByLabelText(/unused/i); + fireEvent.click(unusedChip); + + expect(screen.queryByText('Unused Group')).not.toBeInTheDocument(); + expect(screen.getByText('Used Group')).toBeInTheDocument(); + }); + }); + + // ── fetchGroupUsage ──────────────────────────────────────────────────────── + + describe('fetchGroupUsage on open', () => { + it('populates group usage from channelGroups flags when modal opens', () => { + setupMocks({ + groups: { + 1: makeGroup({ hasChannels: true, hasM3UAccounts: false }), + }, + }); + renderGroupManager(); + // Group is visible (usage was populated), badge is shown + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('does not fetch when isOpen is false', () => { + setupMocks({ + groups: { 1: makeGroup() }, + }); + render(); + // Nothing should render + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx index 35549856..b96b6b4b 100644 --- a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx +++ b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx @@ -1,251 +1,913 @@ +import React, { useState } from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Targeted Vitest for the orphan-cleanup SegmentedControl that lives at -// the top of the M3U account's group-settings page. Covers default-mode -// resolution, click-to-PATCH, and optimistic-update / error-revert -// behavior. Other parts of LiveGroupFilter (per-group inline config, -// gear modal, regex preview, overlap warning) are not unit-tested here -// because they depend on external APIs and live data; they are exercised -// via the in-repo manual UI walkthrough. - -// ── Store mocks ──────────────────────────────────────────────────────────────── +// ── Store mocks ─────────────────────────────────────────────────────────────── vi.mock('../../../store/channels', () => ({ default: vi.fn() })); vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); -// ── Hook mocks ───────────────────────────────────────────────────────────────── +// ── Hook mocks ──────────────────────────────────────────────────────────────── vi.mock('../../../hooks/useSmartLogos', () => ({ - useChannelLogoSelection: vi.fn(() => ({ - logos: {}, - ensureLogosLoaded: vi.fn(), - isLoading: false, - })), + useChannelLogoSelection: vi.fn(), })); -// ── Module mocks ─────────────────────────────────────────────────────────────── -vi.mock('@mantine/notifications', () => ({ - notifications: { show: vi.fn() }, -})); - -vi.mock('../../../api', () => ({ - default: { - updatePlaylist: vi.fn(() => Promise.resolve({})), - getEPGs: vi.fn(() => Promise.resolve([])), - getChannelsInRange: vi.fn(() => Promise.resolve({ data: [] })), - getStreamsRegexPreview: vi.fn(() => Promise.resolve({ data: {} })), - repackGroupChannels: vi.fn(() => Promise.resolve({})), - }, -})); - -vi.mock('../../../utils/forms/GroupSyncUtils', () => ({ - getGroupReservation: vi.fn(() => null), -})); - -// ── Child component mocks ────────────────────────────────────────────────────── +// ── Child component mocks ───────────────────────────────────────────────────── vi.mock('../GroupConfigureModal', () => ({ - default: ({ children }) =>
{children}
, + default: ({ opened, onDone, onCancel, group, children }) => + opened ? ( +
+ {children} + + +
+ ) : null, })); -vi.mock('../Logo', () => ({ default: () => null })); -vi.mock('../../LazyLogo', () => ({ default: () => null })); -vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); +vi.mock('../AutoSyncOrphanCleanup.jsx', () => ({ + default: ({ playlist }) => ( +
+ ), +})); -vi.mock('react-window', () => ({ - FixedSizeList: ({ children, itemCount }) => ( -
- {Array.from({ length: itemCount }, (_, index) => - children({ index, style: {} }) - )} +vi.mock('../AutoSyncBasic.jsx', () => ({ + default: () =>
, +})); + +vi.mock('../../ErrorBoundary.jsx', () => ({ + default: ({ children }) => <>{children}, +})); + +vi.mock('../AutoSyncAdvanced.jsx', () => ({ + default: ({ + group, + onApplyGroupChange, + onScheduleRegexPreview, + onOpenLogoUpload, + }) => ( +
+ + +
), })); -vi.mock('lucide-react', () => ({ - Info: () => , - CircleCheck: () => , - CircleX: () => , - Settings: () => , - AlertTriangle: () => , - RefreshCw: () => , +vi.mock('../Logo.jsx', () => ({ + default: ({ isOpen, onClose, onSuccess }) => + isOpen ? ( +
+ + +
+ ) : null, })); -// ── @mantine/core minimal mocks ──────────────────────────────────────────────── -vi.mock('@mantine/core', () => ({ - TextInput: ({ value, onChange, placeholder }) => ( - +// ── Utility mocks ───────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({ + abortTimers: vi.fn(), + computeAutoSyncStart: vi.fn(() => 101), + getChannelsInRange: vi.fn().mockResolvedValue({ occupants: [] }), + getEPGs: vi.fn().mockResolvedValue([]), + getRegexOptions: vi.fn((find, replace, match, exclude) => ({ + find, + replace, + match, + exclude, + })), + getStreamsRegexPreview: vi.fn().mockResolvedValue(null), + isExpectedOccupantForGroup: vi.fn(() => true), + isGroupVisible: vi.fn((group, filter, status) => { + if (status === 'enabled') return group.enabled; + if (status === 'disabled') return !group.enabled; + if (filter) return group.name.toLowerCase().includes(filter.toLowerCase()); + return true; + }), + rangeFor: vi.fn(() => null), +})); + +// ── Mantine core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + ), - Button: ({ children, onClick }) => , - Checkbox: ({ label, checked, onChange }) => ( + Alert: ({ children }) =>
{children}
, + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, description, disabled }) => ( ), + Divider: () =>
, Flex: ({ children }) =>
{children}
, - Select: ({ value, onChange, data }) => ( - + Group: ({ children, style }) => ( +
+ {children} +
), - Stack: ({ children }) =>
{children}
, - Group: ({ children }) =>
{children}
, - SimpleGrid: ({ children }) =>
{children}
, - Text: ({ children }) => {children}, - NumberInput: ({ value, onChange }) => ( - onChange?.(Number(e.target.value))} - /> - ), - Divider: ({ label }) =>
, - Alert: ({ children }) =>
{children}
, - Box: ({ children }) =>
{children}
, - MultiSelect: ({ value, onChange }) => ( - - ), - Tooltip: ({ children }) => <>{children}, - Popover: ({ children }) =>
{children}
, - ScrollArea: ({ children }) =>
{children}
, - Center: ({ children }) =>
{children}
, - SegmentedControl: ({ value, onChange, data }) => ( -
- {data.map((opt) => ( + Loader: () =>
, + SegmentedControl: ({ onChange, data }) => ( +
+ {(data || []).map((item) => ( ))}
), - ActionIcon: ({ children, onClick }) => , - Switch: ({ label, checked, onChange }) => ( - + SimpleGrid: ({ children }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ placeholder, value, onChange }) => ( + ), + Tooltip: ({ children }) => <>{children}, })); -// ── Imports after mocks ──────────────────────────────────────────────────────── +// ── lucide-react mock ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleCheck: () => , + CircleX: () => , + Info: () => , + Settings: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── import LiveGroupFilter from '../LiveGroupFilter'; -import API from '../../../api'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../../store/channels'; import useStreamProfilesStore from '../../../store/streamProfiles'; +import { useChannelLogoSelection } from '../../../hooks/useSmartLogos'; +import { + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isGroupVisible, +} from '../../../utils/forms/LiveGroupFilterUtils.js'; -// ────────────────────────────────────────────────────────────────────────────── - -const makePlaylist = (overrides = {}) => ({ - id: 7, - channel_groups: [], - custom_properties: null, +// ── Factories ───────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + enabled: true, + auto_channel_sync: true, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + is_stale: false, + original_enabled: true, ...overrides, }); -const setupStores = () => { - vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelGroups: {}, profiles: [] }) - ); - vi.mocked(useStreamProfilesStore).mockImplementation((sel) => - sel({ profiles: [], fetchProfiles: vi.fn() }) +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + channel_groups: [], + ...overrides, +}); + +// ── Stateful wrapper ────────────────────────────────────────────────────────── +const Wrapper = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, +}) => { + const [groupStates, setGroupStates] = useState(initialGroups); + const [autoEnable, setAutoEnable] = useState(initialAutoEnable); + return ( + ); }; -const renderFilter = (playlistOverrides = {}) => - render( - - ); +// ── Test setup helpers ──────────────────────────────────────────────────────── +describe('LiveGroupFilter', () => { + let mockEnsureLogosLoaded; + let mockFetchProfiles; -// The orphan-cleanup SegmentedControl shares its mocked testid with the -// status-filter SegmentedControl that lives elsewhere in the form, so -// disambiguate by walking from a uniquely-testided child button up to -// its parent SegmentedControl element. -const findCleanupControl = () => - screen.getByTestId('segmented-always').closest('[data-testid="segmented-control"]'); - -describe('LiveGroupFilter orphan-cleanup SegmentedControl', () => { beforeEach(() => { vi.clearAllMocks(); - setupStores(); - }); + mockEnsureLogosLoaded = vi.fn(); + mockFetchProfiles = vi.fn(); - it('defaults to "always" when custom_properties is null', () => { - renderFilter({ custom_properties: null }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('defaults to "always" when the orphan_channel_cleanup key is absent', () => { - renderFilter({ custom_properties: { compact_numbering: true } }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('reads the persisted mode from custom_properties on mount', () => { - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, - }); - expect(findCleanupControl()).toHaveAttribute( - 'data-value', - 'preserve_customized' + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups: {} }) ); + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles: [], fetchProfiles: mockFetchProfiles }) + ); + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded: mockEnsureLogosLoaded, + isLoading: false, + }); + vi.mocked(getEPGs).mockResolvedValue([]); }); - it('PATCHes the playlist with merged custom_properties on click and updates the displayed value', async () => { - renderFilter({ - custom_properties: { compact_numbering: true }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); - - await waitFor(() => { - expect(API.updatePlaylist).toHaveBeenCalledWith({ - id: 7, - custom_properties: { - compact_numbering: true, - orphan_channel_cleanup: 'never', - }, + const renderWith = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, + channelGroups, + streamProfiles, + fetchProfiles, + ensureLogosLoaded, + } = {}) => { + if (channelGroups !== undefined) { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + } + if (streamProfiles !== undefined || fetchProfiles !== undefined) { + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ + profiles: streamProfiles ?? [], + fetchProfiles: fetchProfiles ?? mockFetchProfiles, + }) + ); + } + if (ensureLogosLoaded !== undefined) { + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded, + isLoading: false, }); - }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'never'); - }); + } - it('reverts to the previous mode and surfaces an error toast when the PATCH fails', async () => { - vi.mocked(API.updatePlaylist).mockRejectedValueOnce( - new Error('Server error') + return render( + ); - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'always' }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); + }; - await waitFor(() => { - expect(notifications.show).toHaveBeenCalledWith( - expect.objectContaining({ color: 'red' }) + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the info alert', () => { + renderWith(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText(/Auto Channel Sync/i)).toBeInTheDocument(); + }); + + it('renders the auto-enable new groups checkbox', () => { + renderWith(); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeInTheDocument(); + }); + + it('reflects initialAutoEnable=true on the checkbox', () => { + renderWith({ initialAutoEnable: true }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeChecked(); + }); + + it('reflects initialAutoEnable=false on the checkbox', () => { + renderWith({ initialAutoEnable: false }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).not.toBeChecked(); + }); + + it('renders OrphanCleanupControl with the playlist', () => { + renderWith({ playlist: makePlaylist({ id: 7 }) }); + expect(screen.getByTestId('orphan-cleanup')).toHaveAttribute( + 'data-playlist-id', + '7' ); }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); + + it('renders the group name filter input', () => { + renderWith(); + expect(screen.getByTestId('group-filter-input')).toBeInTheDocument(); + }); + + it('renders All / Enabled / Disabled status filter buttons', () => { + renderWith(); + expect(screen.getByTestId('seg-all')).toBeInTheDocument(); + expect(screen.getByTestId('seg-enabled')).toBeInTheDocument(); + expect(screen.getByTestId('seg-disabled')).toBeInTheDocument(); + }); + + it('renders Select Visible and Deselect Visible buttons', () => { + renderWith(); + expect(screen.getByText('Select Visible')).toBeInTheDocument(); + expect(screen.getByText('Deselect Visible')).toBeInTheDocument(); + }); + + it('renders a card for each group in groupStates', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('renders groups sorted alphabetically by name', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 3, name: 'Sports' }), + makeGroup({ channel_group: 1, name: 'Arts' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + const names = screen + .getAllByText(/Arts|News|Sports/) + .map((el) => el.textContent); + expect(names).toEqual(['Arts', 'News', 'Sports']); + }); + + it('renders no group cards when groupStates is empty', () => { + renderWith({ initialGroups: [] }); + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not render the configure modal on initial render', () => { + renderWith({ initialGroups: [makeGroup()] }); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Mount effects ────────────────────────────────────────────────────────── + + describe('mount effects', () => { + it('calls ensureLogosLoaded on mount', () => { + renderWith({ ensureLogosLoaded: mockEnsureLogosLoaded }); + expect(mockEnsureLogosLoaded).toHaveBeenCalled(); + }); + + it('calls fetchStreamProfiles when profiles array is empty', () => { + renderWith({ streamProfiles: [], fetchProfiles: mockFetchProfiles }); + expect(mockFetchProfiles).toHaveBeenCalled(); + }); + + it('does not call fetchStreamProfiles when profiles already exist', () => { + renderWith({ + streamProfiles: [{ id: 'p1', name: 'Default' }], + fetchProfiles: mockFetchProfiles, + }); + expect(mockFetchProfiles).not.toHaveBeenCalled(); + }); + + it('calls getEPGs on mount', async () => { + renderWith(); + await waitFor(() => { + expect(getEPGs).toHaveBeenCalled(); + }); + }); + + it('initializes groupStates from playlist.channel_groups when channelGroups are loaded', async () => { + const channelGroups = { 10: { id: 10, name: 'Movies' } }; + const playlist = makePlaylist({ + id: 2, + channel_groups: [ + { + channel_group: 10, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + }, + ], + }); + renderWith({ channelGroups, playlist, initialGroups: [] }); + await waitFor(() => { + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + }); + + it('skips groupStates initialization when channelGroups is empty', () => { + renderWith({ + channelGroups: {}, + playlist: makePlaylist({ + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + }, + ], + }), + initialGroups: [], + }); + // Init effect bails early — no group cards rendered + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not re-initialize groupStates for the same playlist/channelGroups key', async () => { + // Covers the lastInitKey guard preventing re-init on prop-reference changes + const channelGroups = { 1: { id: 1, name: 'Sports' } }; + const playlist = makePlaylist({ + id: 1, + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + custom_properties: {}, + }, + ], + }); + const { rerender } = renderWith({ channelGroups, playlist }); + await waitFor(() => + expect(screen.getByText('Sports')).toBeInTheDocument() + ); + + // Re-render with identical playlist reference — init must not fire again + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + rerender(); + // Still renders Sports; no duplicate init + expect(screen.getAllByText('Sports')).toHaveLength(1); + }); + }); + + // ── autoEnableNewGroupsLive checkbox ────────────────────────────────────── + + describe('autoEnableNewGroupsLive checkbox', () => { + it('toggles off when clicked while checked', () => { + renderWith({ initialAutoEnable: true }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).not.toBeChecked(); + }); + + it('toggles on when clicked while unchecked', () => { + renderWith({ initialAutoEnable: false }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).toBeChecked(); + }); + }); + + // ── Group text filter ────────────────────────────────────────────────────── + + describe('group text filter', () => { + it('shows groups matching the filter and hides non-matching groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows all groups when filter is cleared', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: '' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('calls isGroupVisible with the current filter text and status', () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sp' }, + }); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + 'sp', + 'all' + ); + }); + }); + + // ── Status filter ────────────────────────────────────────────────────────── + + describe('status filter', () => { + it('shows only enabled groups when Enabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows only disabled groups when Disabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(screen.queryByText('Sports')).not.toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('passes the active status value to isGroupVisible', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + '', + 'disabled' + ); + }); + + it('reverts to showing all groups when All filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByTestId('seg-all')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + }); + + // ── Select Visible / Deselect Visible ───────────────────────────────────── + + describe('Select Visible / Deselect Visible', () => { + it('Select Visible enables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: false }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByText('Select Visible')); + // Both groups remain visible (isGroupVisible still returns true) + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Deselect Visible disables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: true }), + ], + }); + // With status=all, isGroupVisible still returns true after deselect + fireEvent.click(screen.getByText('Deselect Visible')); + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Select Visible only applies to groups passing the current filter', () => { + // With Enabled filter active, only already-enabled groups are "visible" + // so disabling one and clicking Select Visible should not re-enable the hidden one + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByText('Select Visible')); + // News (disabled) is not visible in Enabled filter, so it stays hidden + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + }); + + // ── Configure modal ──────────────────────────────────────────────────────── + + describe('configure modal', () => { + it('opens when the Cog ActionIcon is clicked', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toBeInTheDocument(); + }); + + it('passes the correct group id to GroupConfigureModal', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 42 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toHaveAttribute( + 'data-group-id', + '42' + ); + }); + + it('closes on Done click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-done')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('closes on Cancel click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('renders AutoSyncAdvanced inside the modal with the correct group', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(screen.getByTestId('auto-sync-advanced')).toBeInTheDocument(); + expect(screen.getByTestId('auto-sync-advanced')).toHaveAttribute( + 'data-group-id', + '1' + ); + }); + }); + + it('restores the group state when Cancel is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + // Open modal — snapshot is taken + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + // Mutate the group via AutoSyncAdvanced + fireEvent.click(screen.getByTestId('trigger-apply-group')); + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + + // Cancel should revert to the snapshot + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect( + screen.queryByText('Modified By Advanced') + ).not.toBeInTheDocument(); + }); + + it('does not revert the group state when Done is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + fireEvent.click(screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('modal-done')); + + // The modified name should persist + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + }); + + it('triggers a regex preview when configuringGroup changes', async () => { + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { + name_regex_pattern: 'ESPN', + name_match_regex: '', + }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(getRegexOptions).toHaveBeenCalled(); + }); + }); + }); + + // ── Logo upload modal ────────────────────────────────────────────────────── + + describe('logo upload modal', () => { + const openLogoUpload = async () => { + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-logo-upload')); + fireEvent.click(screen.getByTestId('trigger-logo-upload')); + }; + + it('opens LogoForm when onOpenLogoUpload is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + await waitFor(() => + expect(screen.getByTestId('logo-form')).toBeInTheDocument() + ); + }); + + it('closes LogoForm when onClose is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + fireEvent.click(screen.getByTestId('logo-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('applies custom_logo_id and calls ensureLogosLoaded on logo success', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + + const callCountBefore = mockEnsureLogosLoaded.mock.calls.length; + fireEvent.click(screen.getByTestId('logo-success')); + + expect(mockEnsureLogosLoaded.mock.calls.length).toBeGreaterThan( + callCountBefore + ); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('does not apply logo when onSuccess provides no logo', async () => { + // Test the logo === null guard: LogoForm calls onSuccess({ logo: null }) + vi.mock('./Logo.jsx', () => ({ + default: ({ isOpen, onSuccess }) => + isOpen ? ( + + ) : null, + })); + // Without a valid logo object, ensureLogosLoaded should not be called again + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + // If the null-logo button renders, click it; otherwise the modal just closes + const nullBtn = screen.queryByTestId('logo-success-null'); + if (nullBtn) fireEvent.click(nullBtn); + // ensureLogosLoaded should only have been called once (on mount) + expect(mockEnsureLogosLoaded).toHaveBeenCalledTimes(1); + }); + }); + + // ── scheduleRegexPreview ─────────────────────────────────────────────────── + + describe('scheduleRegexPreview (via AutoSyncAdvanced)', () => { + it('passes onScheduleRegexPreview to AutoSyncAdvanced and it triggers without error', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + expect(() => + fireEvent.click(screen.getByTestId('trigger-regex-preview')) + ).not.toThrow(); + }); + + it('clears regexPreview state when all pattern fields are empty', async () => { + // scheduleRegexPreview with empty opts should not call getStreamsRegexPreview + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('auto-sync-advanced')); + // Trigger with empty patterns by mounting a group with no patterns set + // (the useEffect on configuringGroup?.channel_group fires getRegexOptions with empty strings) + expect(getStreamsRegexPreview).not.toHaveBeenCalled(); + }); + + it('calls getStreamsRegexPreview when non-empty patterns are provided', async () => { + vi.mocked(getStreamsRegexPreview).mockResolvedValue({ + find_matches: ['ESPN HD'], + find_match_count: 1, + total_in_group: 10, + total_scanned: 10, + scan_limit_hit: false, + }); + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { name_regex_pattern: 'ESPN' }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + fireEvent.click(screen.getByTestId('trigger-regex-preview')); + await waitFor(() => { + expect(getStreamsRegexPreview).toHaveBeenCalled(); + }); + }); + }); + + // ── applyGroupChange ─────────────────────────────────────────────────────── + + describe('applyGroupChange (via AutoSyncAdvanced)', () => { + it('updates only the target group in groupStates', async () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + // Group cards will be sorted alphabetically, so the first cog button corresponds to the "News" group, not "Sports" — ensure we target the correct one + const cogButtons = screen.getAllByTestId('icon-cog'); + fireEvent.click(cogButtons[1].closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('trigger-apply-group')); + + // Group 1 name changed; Group 2 unchanged + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/forms/__tests__/LoginForm.test.jsx b/frontend/src/components/forms/__tests__/LoginForm.test.jsx new file mode 100644 index 00000000..7e30f3cd --- /dev/null +++ b/frontend/src/components/forms/__tests__/LoginForm.test.jsx @@ -0,0 +1,475 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import LoginForm from '../LoginForm'; + +// ── Router mock ──────────────────────────────────────────────────────────────── +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + }; +})(); + +global.localStorage = localStorageMock; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Asset mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + Paper: ({ children }) =>
{children}
, + Title: ({ children }) =>

{children}

, + TextInput: ({ + label, + name, + value, + onChange, + placeholder, + type, + disabled, + }) => ( + + ), + Button: ({ children, onClick, loading, disabled, type, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, c, size }) => ( + + {children} + + ), + Image: ({ src, alt }) => {alt}, + Group: ({ children }) =>
{children}
, + Divider: () =>
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Anchor: ({ children, onClick }) => ( + + {children} + + ), + Code: ({ children }) => {children}, + Checkbox: ({ label, checked, onChange }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useAuthStore from '../../../store/auth'; +import useSettingsStore from '../../../store/settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupMocks = ({ + isAuthenticated = false, + loginResult = Promise.resolve(true), + version = '1.0.0', +} = {}) => { + const mockLogin = vi.fn().mockReturnValue(loginResult); + const mockLogout = vi.fn(); + const mockInitData = vi.fn().mockResolvedValue(undefined); + const mockFetchVersion = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ + login: mockLogin, + logout: mockLogout, + isAuthenticated, + initData: mockInitData, + }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ + fetchVersion: mockFetchVersion, + version: { version }, + }) + ); + + return { mockLogin, mockLogout, mockInitData, mockFetchVersion }; +}; + +const renderLoginForm = () => render(); + +const getUsername = () => screen.getByTestId('input-Username'); +const getPassword = () => screen.getByTestId('input-Password'); +const getLoginButton = () => screen.getByRole('button', { type: 'submit' }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LoginForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the login form', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByTestId('paper')).toBeInTheDocument(); + }); + + it('renders the logo', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'logo.png'); + }); + + it('renders username and password inputs', () => { + setupMocks(); + renderLoginForm(); + expect(getUsername()).toBeInTheDocument(); + expect(getPassword()).toBeInTheDocument(); + }); + + it('renders the login button', () => { + setupMocks(); + renderLoginForm(); + expect(getLoginButton()).toBeInTheDocument(); + }); + + it('renders "Remember Me" checkbox', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument(); + }); + + it('renders "Save Password" checkbox', async () => { + setupMocks(); + renderLoginForm(); + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + expect(screen.getByLabelText(/save password/i)).toBeInTheDocument(); + }); + }); + + it('renders version info when version is available', () => { + setupMocks({ version: '2.3.4' }); + renderLoginForm(); + expect(screen.getByText(/2.3.4/i)).toBeInTheDocument(); + }); + + it('renders forgot password anchor', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByText(/forgot password/i)).toBeInTheDocument(); + }); + }); + + // ── fetchVersion on mount ────────────────────────────────────────────────── + + describe('on mount', () => { + it('calls fetchVersion on mount', () => { + const { mockFetchVersion } = setupMocks(); + renderLoginForm(); + expect(mockFetchVersion).toHaveBeenCalled(); + }); + }); + + // ── Form input ───────────────────────────────────────────────────────────── + + describe('form input', () => { + it('updates username field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getUsername(), { target: { value: 'testuser' } }); + expect(getUsername()).toHaveValue('testuser'); + }); + + it('updates password field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getPassword(), { target: { value: 'secret' } }); + expect(getPassword()).toHaveValue('secret'); + }); + + it('password input has type="password"', () => { + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveAttribute('type', 'password'); + }); + }); + + // ── Successful login ─────────────────────────────────────────────────────── + + describe('successful login', () => { + it('calls login with username and password', async () => { + const { mockLogin } = setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + username: 'admin', + password: 'pass123', + }); + }); + }); + + it('calls initData after successful login', async () => { + const { mockInitData } = setupMocks({ + loginResult: Promise.resolve(true), + }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockInitData).toHaveBeenCalled(); + }); + }); + + it('navigates to "/channels" when authenticated', async () => { + setupMocks({ isAuthenticated: true }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels'); + }); + }); + }); + + // ── Failed login ─────────────────────────────────────────────────────────── + + describe('failed login', () => { + it('does not navigate when login fails', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'wrong' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it('does not navigate when login throws', async () => { + setupMocks({ loginResult: Promise.reject(new Error('fail')) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables the login button while loading', async () => { + let resolveLogin; + const loginResult = new Promise((res) => { + resolveLogin = res; + }); + setupMocks({ loginResult }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).toBeDisabled(); + }); + + resolveLogin(true); + }); + + it('re-enables the login button after login completes', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).not.toBeDisabled(); + }); + }); + }); + + // ── Remember Me ─────────────────────────────────────────────────────────── + + describe('remember me', () => { + it('saves username to localStorage when Remember Me is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.change(getUsername(), { target: { value: 'savedUser' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_remembered_username')).toBe( + 'savedUser' + ); + }); + }); + + it('removes username from localStorage when Remember Me is unchecked', async () => { + localStorage.setItem('dispatcharr_remembered_username', 'oldUser'); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect( + localStorage.getItem('dispatcharr_remembered_username') + ).toBeNull(); + }); + }); + + it('pre-fills username from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + setupMocks(); + renderLoginForm(); + expect(getUsername()).toHaveValue('storedUser'); + }); + }); + + // ── Save Password ───────────────────────────────────────────────────────── + + describe('save password', () => { + it('saves encoded password to localStorage when Save Password is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(screen.getByLabelText(/save password/i)); + fireEvent.change(getPassword(), { target: { value: 'mySecret' } }); + fireEvent.click(getLoginButton()); + }); + + await waitFor(() => { + expect( + localStorage.getItem('dispatcharr_saved_password') + ).not.toBeNull(); + }); + }); + + it('removes password from localStorage when Save Password is unchecked', async () => { + localStorage.setItem('dispatcharr_saved_password', btoa('oldPass')); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_saved_password')).toBeNull(); + }); + }); + + it('pre-fills password from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + localStorage.setItem('dispatcharr_saved_password', btoa('storedPass')); + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveValue('storedPass'); + }); + }); + + // ── Forgot Password modal ────────────────────────────────────────────────── + + describe('forgot password modal', () => { + it('opens the forgot password modal when the anchor is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('shows the modal title', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('closes the modal when the close button is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + fireEvent.click(screen.getByTestId('modal-close')); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/Logo.test.jsx b/frontend/src/components/forms/__tests__/Logo.test.jsx new file mode 100644 index 00000000..9b3b0107 --- /dev/null +++ b/frontend/src/components/forms/__tests__/Logo.test.jsx @@ -0,0 +1,518 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import LogoForm from '../Logo'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LogoUtils.js', () => ({ + createLogo: vi.fn(), + updateLogo: vi.fn(), + uploadLogo: vi.fn(), + getFilenameWithoutExtension: vi.fn((name) => name.replace(/\.[^.]+$/, '')), + getResolver: vi.fn(() => undefined), + getUpdateLogoErrorMessage: vi.fn((logo, err) => err.message), + getUploadErrorMessage: vi.fn((err) => err.message), + releaseUrl: vi.fn(), + validateFileSize: vi.fn(() => true), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Mantine dropzone ─────────────────────────────────────────────────────────── +vi.mock('@mantine/dropzone', () => ({ + Dropzone: vi.fn(({ children, onDrop }) => ( +
onDrop([])}> + {children} +
+ )), + DropzoneAccept: ({ children }) => ( +
{children}
+ ), + DropzoneReject: ({ children }) => ( +
{children}
+ ), + DropzoneIdle: ({ children }) =>
{children}
, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Divider: ({ label }) =>
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, color }) => ( + + {children} + + ), + TextInput: ({ + label, + placeholder, + onChange, + onBlur, + error, + disabled, + ...rest + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + FileImage: () => , + Upload: () => , + X: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import * as LogoUtils from '../../../utils/forms/LogoUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'My Logo', + url: 'https://example.com/logo.png', + cache_url: 'https://cdn.example.com/logo.png', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + logo: null, + isOpen: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + ...overrides, +}); + +const makeFile = (name = 'test-logo.png', size = 1024) => + new File(['content'], name, { type: 'image/png', size }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LogoForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true); + vi.mocked(LogoUtils.createLogo).mockResolvedValue({ + id: 2, + name: 'New Logo', + url: 'https://example.com/new.png', + }); + vi.mocked(LogoUtils.updateLogo).mockResolvedValue({ + id: 1, + name: 'Updated Logo', + url: 'https://example.com/updated.png', + }); + vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({ + id: 3, + name: 'uploaded-logo', + url: 'https://cdn.example.com/uploaded.png', + }); + vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined); + + URL.createObjectURL = vi.fn(); + }); + + afterEach(() => { + delete URL.createObjectURL; + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('shows "Add Logo" title for new logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Add Logo'); + }); + + it('shows "Edit Logo" title when editing existing logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Logo'); + }); + + it('shows "Create" button for new logo', () => { + render(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('shows "Update" button when editing existing logo', () => { + render(); + expect(screen.getByText('Update')).toBeInTheDocument(); + }); + + it('pre-fills name input when editing existing logo', () => { + render(); + expect(screen.getByDisplayValue('My Logo')).toBeInTheDocument(); + }); + + it('pre-fills URL input when editing existing logo', () => { + render(); + expect( + screen.getByDisplayValue('https://example.com/logo.png') + ).toBeInTheDocument(); + }); + + it('shows logo preview when logo has cache_url', () => { + render(); + const img = screen.getByAltText('Logo preview'); + expect(img).toHaveAttribute('src', 'https://cdn.example.com/logo.png'); + }); + + it('does not show preview when no logo provided', () => { + render(); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('renders the dropzone', () => { + render(); + expect(screen.getByTestId('dropzone')).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Create logo ──────────────────────────────────────────────────────────── + + describe('creating a logo via URL', () => { + it('calls createLogo with entered values on submit', async () => { + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.change(screen.getByPlaceholderText('Enter logo name'), { + target: { value: 'New Logo' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.createLogo).toHaveBeenCalled(); + }); + }); + + it('shows success notification after creating logo', async () => { + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('calls onSuccess with type "create" after creating logo', async () => { + const onSuccess = vi.fn(); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'create' }) + ); + }); + }); + + it('calls onClose after creating logo', async () => { + const onClose = vi.fn(); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error notification when createLogo throws', async () => { + vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error')); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── Update logo ──────────────────────────────────────────────────────────── + + describe('updating an existing logo', () => { + it('calls updateLogo on submit', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(LogoUtils.updateLogo).toHaveBeenCalledWith( + makeLogo(), + expect.objectContaining({ name: 'My Logo' }) + ); + }); + }); + + it('calls onSuccess with type "update" after updating logo', async () => { + const onSuccess = vi.fn(); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'update' }) + ); + }); + }); + + it('shows success notification after updating logo', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('shows error notification when updateLogo throws', async () => { + vi.mocked(LogoUtils.updateLogo).mockRejectedValue( + new Error('Server error') + ); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── File upload ──────────────────────────────────────────────────────────── + + describe('file upload via dropzone', () => { + it('calls uploadLogo with the selected file on submit', async () => { + const file = makeFile('my-logo.png'); + + // Override dropzone to pass our test file + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementation(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.uploadLogo).toHaveBeenCalledWith( + file, + expect.any(Object) + ); + }); + }); + + it('shows error notification when file is too large', async () => { + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false); + + // Override dropzone to pass a file + const file = makeFile('big.png', 10 * 1024 * 1024); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + + it('shows upload error notification when uploadLogo throws', async () => { + vi.mocked(LogoUtils.uploadLogo).mockRejectedValue( + new Error('Upload failed') + ); + const file = makeFile('logo.png'); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Upload Error', color: 'red' }) + ); + }); + }); + }); + + // ── URL input behaviour ──────────────────────────────────────────────────── + + describe('URL input behaviour', () => { + it('updates preview when a valid http URL is entered', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/img.png' }, + } + ); + expect(screen.getByAltText('Logo preview')).toHaveAttribute( + 'src', + 'https://example.com/img.png' + ); + }); + + it('removes preview when URL is cleared', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: '' }, + } + ); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('auto-fills name from URL on blur', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/my-channel-logo.png' }, + } + ); + fireEvent.blur( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/my-channel-logo.png' }, + } + ); + expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled(); + }); + + it('does not throw on blur with invalid URL', () => { + render(); + expect(() => { + fireEvent.blur( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'not-a-url' }, + } + ); + }).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3U.test.jsx b/frontend/src/components/forms/__tests__/M3U.test.jsx new file mode 100644 index 00000000..7e26d97a --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3U.test.jsx @@ -0,0 +1,708 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3U from '../M3U'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/userAgents', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uUtils.js', () => ({ + addPlaylist: vi.fn(), + getPlaylist: vi.fn(), + prepareSubmitValues: vi.fn((values) => values), + updatePlaylist: vi.fn(), +})); + +vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({ + addEPG: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UProfiles', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UGroupFilter', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UFilters', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../ScheduleInput', () => ({ + default: ({ onChange, value }) => ( +
+ onChange?.(e.target.value)} + /> +
+ ), +})); + +// ── Mantine dates ────────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, placeholder }) => ( +
+ +
+ ), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { + _values = null; + }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { + Object.assign(_values, v); + }, + setFieldValue: (field, val) => { + _values[field] = val; + }, + reset: () => { + _values = { ...initialValues }; + }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + if (_values?.name) handler(); + }), + getInputProps: vi.fn((field) => ({ + value: _values?.[field] ?? '', + onChange: vi.fn((e) => { + const val = e?.target?.value ?? e; + if (_values) _values[field] = val; + }), + error: null, + })), + }; + }), + }; +}); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, disabled }) => ( + + ), + Divider: ({ label }) =>
, + FileInput: ({ label, placeholder, onChange, accept, disabled }) => ( +
+ +
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ + label, + placeholder, + value, + onChange, + min, + max, + disabled, + error, + }) => ( +
+ + {error && {error}} +
+ ), + PasswordInput: ({ + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + }) => ( +
+ + {error && {error}} +
+ ), + Select: ({ label, placeholder, value, onChange, data, disabled, error }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ + id, + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + ...rest + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useUserAgentsStore from '../../../store/userAgents'; +import useChannelsStore from '../../../store/channels'; +import useEPGsStore from '../../../store/epgs'; +import useVODStore from '../../../store/useVODStore'; +import * as M3uUtils from '../../../utils/forms/M3uUtils.js'; +import * as DummyEpgUtils from '../../../utils/forms/DummyEpgUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3uAccount = (overrides = {}) => ({ + id: 1, + name: 'Test M3U', + server_url: 'http://example.com/playlist.m3u', + username: 'user1', + password: 'pass1', + account_type: 'XC', + max_streams: 0, + refresh_interval: 24, + auto_refresh: false, + is_active: true, + custom_properties: {}, + ...overrides, +}); + +const makeUserAgent = (overrides = {}) => ({ + id: 1, + name: 'Default Agent', + user_agent: 'Mozilla/5.0', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + m3uAccount: null, + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = (overrides = {}) => { + const fetchUserAgents = vi.fn(); + const fetchChannelGroups = vi.fn(); + const fetchEPGs = vi.fn(); + const fetchCategories = vi.fn(); + + useUserAgentsStore.mockImplementation((selector) => { + const state = { + userAgents: overrides.userAgents || [], + fetchUserAgents, + }; + return selector(state); + }); + + useChannelsStore.mockImplementation((selector) => { + const state = { + fetchChannelGroups, + }; + return selector(state); + }); + + useEPGsStore.mockImplementation((selector) => { + const state = { + fetchEPGs, + }; + return selector(state); + }); + + useVODStore.mockImplementation((selector) => { + const state = { + fetchCategories, + }; + return selector(state); + }); + + return { + fetchUserAgents, + fetchChannelGroups, + fetchEPGs, + fetchCategories, + }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3U', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uUtils.addPlaylist).mockResolvedValue( + makeM3uAccount({ id: 2 }) + ); + vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v); + vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({ + id: 10, + name: 'Dummy EPG', + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders Name input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-name')).toBeInTheDocument(); + }); + + it('renders URL input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-server_url')).toBeInTheDocument(); + }); + + it('renders submit button with "Add" label for new account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|create|save/i }) + ).toBeInTheDocument(); + }); + + it('renders submit button with "Update" or "Save" label for existing account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /update|save/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills name when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('Test M3U')).toBeInTheDocument(); + }); + + it('pre-fills URL when editing', () => { + setupStores(); + render(); + expect( + screen.getByDisplayValue('http://example.com/playlist.m3u') + ).toBeInTheDocument(); + }); + + it('renders M3UProfiles sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-profiles')).toBeInTheDocument(); + }); + + it('renders M3UGroupFilter sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-group-filter')).toBeInTheDocument(); + }); + + it('renders M3UFilters sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-filters')).toBeInTheDocument(); + }); + + it('renders ScheduleInput sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + + it('populates user agent select with agents from store', () => { + setupStores({ + userAgents: [ + makeUserAgent({ id: 1, name: 'Agent One' }), + makeUserAgent({ id: 2, name: 'Agent Two' }), + ], + }); + render(); + expect(screen.getByText('Agent One')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a playlist ────────────────────────────────────────────────────── + + describe('adding a new playlist', () => { + const fillRequiredFields = () => { + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'New Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { + target: { value: 'http://example.com/new.m3u' }, + }); + } + }; + + it('calls addPlaylist on valid submit', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + }); + + it('calls prepareSubmitValues before addPlaylist', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.prepareSubmitValues).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a playlist ──────────────────────────────────────────────────── + + describe('updating an existing playlist', () => { + it('calls updatePlaylist on submit', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + }); + + it('does not call addPlaylist when updating', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Form validation ──────────────────────────────────────────────────────── + + describe('form validation', () => { + it('does not call addPlaylist when Name is empty', async () => { + setupStores(); + render(); + // Clear name if pre-filled, then submit + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await new Promise((r) => setTimeout(r, 50)); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Refresh / schedule behaviour ─────────────────────────────────────────── + + describe('schedule and refresh', () => { + it('renders the ScheduleInput', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + }); + + // ── Max streams ──────────────────────────────────────────────────────────── + + describe('max streams field', () => { + it('renders max streams number input', () => { + setupStores(); + render(); + expect( + screen.getByRole('spinbutton', { name: /max.?stream/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills max_streams when editing', () => { + setupStores(); + render( + + ); + expect(screen.getByDisplayValue('5')).toBeInTheDocument(); + }); + }); + + // ── User agent select ────────────────────────────────────────────────────── + + describe('user agent select', () => { + it('renders user agent select', () => { + setupStores(); + render(); + expect( + screen.getByRole('combobox', { name: /user.?agent/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Credential fields ────────────────────────────────────────────────────── + + describe('credential fields', () => { + it('renders username input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-username')).toBeInTheDocument(); + }); + + it('renders password input', () => { + setupStores(); + render(); + expect( + document.querySelector('input[type="password"]') + ).toBeInTheDocument(); + }); + + it('pre-fills username when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('user1')).toBeInTheDocument(); + }); + }); + + // ── Dummy EPG creation ───────────────────────────────────────────────────── + + describe('dummy EPG auto-creation', () => { + it('calls addEPG after successfully adding a playlist', async () => { + setupStores(); + render(); + + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'My Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { + target: { value: 'http://example.com/p.m3u' }, + }); + } + + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + // addEPG may be called conditionally — assert it was called or not based on a checkbox + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx new file mode 100644 index 00000000..fc71f4b8 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx @@ -0,0 +1,410 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilter from '../M3UFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + addM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +vi.mock('../../../utils', () => ({ + setCustomProperty: vi.fn((obj, key, value) => ({ ...obj, [key]: value })), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { + _values = null; + }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { + Object.assign(_values, v); + }, + setFieldValue: (field, val) => { + _values[field] = val; + }, + reset: () => { + _values = { ...initialValues }; + }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + handler(); + }), + getInputProps: vi.fn((field, options = {}) => { + const isCheckbox = options?.type === 'checkbox'; + return { + ...(isCheckbox + ? { checked: !!_values?.[field] } + : { value: _values?.[field] ?? '' }), + onChange: vi.fn((e) => { + const val = isCheckbox + ? (e?.target?.checked ?? e) + : (e?.target?.value ?? e); + if (_values) _values[field] = val; + }), + error: null, + }; + }), + }; + }), + }; +}); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Select: ({ label, placeholder, value, onChange, data, error, disabled }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ id, label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3U = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + custom_properties: {}, + filters: [], + ...overrides, +}); + +const makeFilter = (overrides = {}) => ({ + id: 10, + filter_type: 'include', + regex_pattern: 'HBO.*', + exclude: false, + custom_properties: { case_sensitive: false }, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + filter: null, + m3u: makeM3U(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + fetchPlaylist = vi.fn().mockResolvedValue(undefined), +} = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + return { fetchPlaylist }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uFilterUtils.addM3UFilter).mockResolvedValue({ id: 99 }); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue({}); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders modal title as "Filter"', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Filter'); + }); + + it('renders filter type Select', () => { + setupStores(); + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders filter type options from M3U_FILTER_TYPES', () => { + setupStores(); + render(); + expect( + screen.getByRole('option', { name: 'Include' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: 'Exclude' }) + ).toBeInTheDocument(); + }); + + it('renders a text input for the pattern', () => { + setupStores(); + render(); + expect(screen.getByLabelText('Regex Pattern')).toBeInTheDocument(); + }); + + it('renders the exclusion switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('exclude')).toBeInTheDocument(); + }); + + it('renders the case sensitivity switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('case_sensitive')).toBeInTheDocument(); + }); + + it('renders a submit button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|save|submit/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Form reset on open/filter change ────────────────────────────────────── + + describe('form reset behaviour', () => { + it('shows empty pattern field for a new filter', () => { + setupStores(); + render(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a filter ──────────────────────────────────────────────────────── + + describe('adding a new filter', () => { + const fillForm = () => { + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'include' }, + }); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Sports.*' }, + }); + }; + + it('calls addM3UFilter on submit for a new filter', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when adding', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful add', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a filter ────────────────────────────────────────────────────── + + describe('updating an existing filter', () => { + it('calls updateM3UFilter on submit for an existing filter', async () => { + setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call addM3UFilter when updating', async () => { + setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.addM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful update', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UFilters.test.jsx b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx new file mode 100644 index 00000000..4adb4ada --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx @@ -0,0 +1,594 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilters from '../M3UFilters'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + deleteM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UFilter', () => ({ + default: ({ isOpen, onClose, filter, m3u }) => + isOpen ? ( +
+
{filter?.id ?? 'new'}
+ + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onConfirm, onClose, title }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── DnD kit mocks ────────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + closestCenter: vi.fn(), + DndContext: vi.fn(({ children, onDragEnd }) => ( +
+ {children} +
+ )), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useDraggable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + }), + useSensor: vi.fn((sensor) => sensor), + useSensors: vi.fn((...sensors) => sensors), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + arrayMove: vi.fn((arr, from, to) => { + const result = [...arr]; + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; + }), + SortableContext: ({ children }) => ( +
{children}
+ ), + useSortable: () => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + }), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { Transform: { toString: vi.fn(() => '') } }, +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +// ── lucide-react mocks ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + GripHorizontal: () => , + Info: () => , + SquareMinus: () => , + SquarePen: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Alert: ({ children, title }) => ( +
+
{title}
+ {children} +
+ ), + Box: ({ children, ref }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, variant, color }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + useMantineTheme: () => ({ + tailwind: { + red: { 6: '#f56565' }, + green: { 5: '#48bb78' }, + yellow: { 3: '#ecc94b' }, + }, + }), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useWarningsStore from '../../../store/warnings'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeFilter = (overrides = {}) => ({ + id: 1, + filter_type: 'include', + regex_pattern: 'HBO.*', + is_active: true, + exclude: false, + order: 0, + ...overrides, +}); + +const makePlaylist = (overrides = {}) => ({ + id: 10, + name: 'Test Playlist', + filters: [ + makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }), + makeFilter({ + id: 2, + filter_type: 'exclude', + regex_pattern: 'ESPN.*', + order: 1, + }), + ], + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + fetchPlaylist = vi.fn().mockResolvedValue(undefined), + isWarningSuppressed = vi.fn().mockReturnValue(false), + suppressWarning = vi.fn(), +} = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + return { fetchPlaylist, isWarningSuppressed, suppressWarning }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockResolvedValue(undefined); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue(undefined); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist is null', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist has no id', () => { + setupStores(); + render( + + ); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders an "Add Filter" button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + }); + + it('renders filter patterns from playlist', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('renders filter type labels for each filter', () => { + setupStores(); + render(); + expect(screen.getAllByText('Include').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Exclude').length).toBeGreaterThanOrEqual(1); + }); + + it('renders edit action icons for each filter', () => { + setupStores(); + render(); + const penIcons = screen.getAllByTestId('icon-square-pen'); + expect(penIcons).toHaveLength(2); + }); + + it('renders delete action icons for each filter', () => { + setupStores(); + render(); + const minusIcons = screen.getAllByTestId('icon-square-minus'); + expect(minusIcons).toHaveLength(2); + }); + + it('renders drag handle icons for each filter', () => { + setupStores(); + render(); + const gripIcons = screen.getAllByTestId('icon-grip'); + expect(gripIcons).toHaveLength(2); + }); + + it('renders empty state when playlist has no filters', () => { + setupStores(); + const playlist = makePlaylist({ + filters: [], + }); + render(); + expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument(); + }); + + it('wraps list in DndContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('dnd-context')).toBeInTheDocument(); + }); + + it('wraps list in SortableContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('sortable-context')).toBeInTheDocument(); + }); + }); + + // ── Close / cancel ───────────────────────────────────────────────────────── + + describe('close behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Opening the filter editor ────────────────────────────────────────────── + + describe('opening the filter editor', () => { + it('opens M3UFilter editor when Add Filter is clicked', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + }); + + it('passes null filter to editor when adding a new filter', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('new'); + }); + + it('opens editor with the correct filter when edit icon is clicked', () => { + setupStores(); + render(); + const editButtons = screen + .getAllByTestId('icon-square-pen') + .map((icon) => icon.closest('button')); + fireEvent.click(editButtons[0]); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('1'); + }); + + it('closes editor when editor fires onClose without updated playlist', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + + it('closes editor and refreshes filters when editor fires onClose with playlist', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close-with-playlist')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + }); + + // ── Delete filter ────────────────────────────────────────────────────────── + + describe('deleting a filter', () => { + const clickDeleteFirst = () => { + const deleteButtons = screen + .getAllByTestId('icon-square-minus') + .map((icon) => icon.closest('button')); + fireEvent.click(deleteButtons[0]); + }; + + it('opens confirmation dialog when delete icon is clicked (warning not suppressed)', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('calls deleteM3UFilter directly when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not open confirmation dialog when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('calls deleteM3UFilter after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('calls fetchPlaylist after successful deletion', async () => { + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(10); + }); + }); + + it('closes confirmation dialog after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('closes confirmation dialog when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('does not call deleteM3UFilter when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect(M3uFilterUtils.deleteM3UFilter).not.toHaveBeenCalled(); + }); + + it('does not call fetchPlaylist when deleteM3UFilter throws', async () => { + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockRejectedValue( + new Error('fail') + ); + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + expect(fetchPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Initialization from playlist prop ───────────────────────────────────── + + describe('filter initialization', () => { + it('loads filters from playlist.custom_properties.filters on mount', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('updates displayed filters when playlist prop changes', () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ + filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })], + }); + rerender(); + expect(screen.getByText('CNN.*')).toBeInTheDocument(); + expect(screen.queryByText('HBO.*')).not.toBeInTheDocument(); + }); + }); + + // ── Drag and drop reordering ─────────────────────────────────────────────── + + describe('drag and drop', () => { + it('calls updateM3UFilter for reordered filters after drag end', async () => { + setupStores(); + // We need to trigger handleDragEnd manually via the DndContext mock. + // Re-mock DndContext to capture and expose onDragEnd. + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + // Simulate drag: move filter id=1 over filter id=2 + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 2 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when drag ends on same position', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 1 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when there is no over target', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: null }); + + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + // ── Warning suppression ──────────────────────────────────────────────────── + + describe('warning suppression', () => { + it('calls suppressWarning when user confirms and opts to suppress', async () => { + // This depends on ConfirmationDialog exposing a "suppress" callback. + // Here we verify suppressWarning is available from the store. + const { suppressWarning } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + expect(suppressWarning).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx new file mode 100644 index 00000000..aba4e139 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx @@ -0,0 +1,441 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UGroupFilter from '../M3UGroupFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({ + saveAndRefreshPlaylist: vi.fn(), + buildGroupStates: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../LiveGroupFilter', () => ({ + default: ({ + groupStates, + setGroupStates, + autoEnableNewGroupsLive, + setAutoEnableNewGroupsLive, + }) => ( +
+ {groupStates?.length ?? 0} + + +
+ ), +})); + +vi.mock('../VODCategoryFilter', () => ({ + default: ({ + categoryStates, + setCategoryStates, + autoEnableNewGroups, + setAutoEnableNewGroups, + type, + }) => ( +
+ + {categoryStates?.length ?? 0} + + + +
+ ), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Button: ({ children, onClick, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Tabs: ({ children, defaultValue, value }) => ( +
+ {children} +
+ ), + TabsList: ({ children }) =>
{children}
, + TabsPanel: ({ children, value }) => ( +
{children}
+ ), + TabsTab: ({ children, value, onClick }) => ( + + ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useVODStore from '../../../store/useVODStore'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as M3uGroupFilterUtils from '../../../utils/forms/M3uGroupFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + account_type: 'XC', + enable_vod: true, + ...overrides, +}); + +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + playlist_id: 1, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })], + fetchCategories = vi.fn().mockResolvedValue(undefined), +} = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + vi.mocked(useVODStore).mockImplementation((sel) => sel({ fetchCategories })); + return { channelGroups, fetchCategories }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UGroupFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue( + undefined + ); + vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders tab list with Live and VOD tabs', () => { + setupStores(); + render(); + expect(screen.getByTestId('tabs-list')).toBeInTheDocument(); + expect(screen.getByTestId('tab-live')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-movie')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-series')).toBeInTheDocument(); + }); + + it('renders LiveGroupFilter panel', () => { + setupStores(); + render(); + expect(screen.getByTestId('live-group-filter')).toBeInTheDocument(); + }); + + it('renders VODCategoryFilter panels', () => { + setupStores(); + render(); + expect( + screen.getByTestId('vod-category-filter-movie') + ).toBeInTheDocument(); + expect( + screen.getByTestId('vod-category-filter-series') + ).toBeInTheDocument(); + }); + + it('renders a Save button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders a Cancel button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls buildGroupStates with channelGroups and playlist on mount', async () => { + const { channelGroups } = setupStores(); + render(); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + channelGroups, + undefined + ); + }); + }); + + it('calls fetchCategories on mount', async () => { + const { fetchCategories } = setupStores(); + render(); + await waitFor(() => { + expect(fetchCategories).toHaveBeenCalled(); + }); + }); + + it('re-initializes when playlist prop changes', async () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ + id: 2, + name: 'Updated Playlist', + channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }], + }); + rerender( + + ); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + expect.anything(), + updatedPlaylist.channel_groups + ); + }); + }); + }); + + // ── Close / cancel behaviour ─────────────────────────────────────────────── + + describe('close / cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Cancel button is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── LiveGroupFilter interaction ──────────────────────────────────────────── + + describe('LiveGroupFilter interaction', () => { + it('updates groupStates when LiveGroupFilter fires onGroupStatesChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + fireEvent.click(screen.getByTestId('live-change-groups')); + expect(screen.getByTestId('live-group-count')).toHaveTextContent('1'); + }); + + it('toggles autoEnableNewGroupsLive when LiveGroupFilter fires onAutoEnableChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + // Toggle twice to verify it actually flips + fireEvent.click(screen.getByTestId('live-toggle-auto')); + fireEvent.click(screen.getByTestId('live-toggle-auto')); + // No crash = state updated correctly + }); + }); + + // ── VODCategoryFilter interaction ────────────────────────────────────────── + + describe('VODCategoryFilter interaction', () => { + it('updates movieCategoryStates when movie VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-movie')); + expect(screen.getByTestId('movie-category-count')).toHaveTextContent('1'); + }); + + it('updates seriesCategoryStates when series VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-series')); + expect(screen.getByTestId('series-category-count')).toHaveTextContent( + '1' + ); + }); + + it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + }); + + it('toggles autoEnableNewGroupsSeries when series VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + }); + }); + + // ── Save ─────────────────────────────────────────────────────────────────── + + describe('saving', () => { + it('calls saveAndRefreshPlaylist with playlist and current states on Save click', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(M3uGroupFilterUtils.saveAndRefreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + expect.any(Array), + expect.any(Array), + expect.any(Array), + expect.objectContaining({ + auto_enable_new_groups_live: true, + auto_enable_new_groups_vod: true, + auto_enable_new_groups_series: true, + }) + ); + }); + }); + + it('calls onClose after successful save', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows success notification after saving', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: expect.stringMatching(/green|teal/), + }) + ); + }); + }); + + it('does not call onClose when saveAndRefreshPlaylist throws', async () => { + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockRejectedValue( + new Error('save failed') + ); + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables Save button while submitting', async () => { + let resolveSave; + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation( + () => + new Promise((res) => { + resolveSave = res; + }) + ); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(saveBtn.disabled || saveBtn.dataset.loading === 'true').toBe( + true + ); + }); + + resolveSave(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UProfile.test.jsx b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx new file mode 100644 index 00000000..45f73b71 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx @@ -0,0 +1,824 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UProfile from '../M3UProfile'; + +// ── WebSocket mock ───────────────────────────────────────────────────────────── +vi.mock('../../../WebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({ + addM3UProfile: vi.fn(), + applyRegex: vi.fn(), + applyXcSimplePatterns: vi.fn(), + buildProfileSchema: vi.fn(), + buildSubmitValues: vi.fn(), + fetchFirstStreamUrl: vi.fn(), + getDetectedMode: vi.fn(), + prepareExpDate: vi.fn(), + splitByPattern: vi.fn(), + updateM3UProfile: vi.fn(), + validateXcSimple: vi.fn(), +})); + +// ── react-hook-form mock ─────────────────────────────────────────────────────── +vi.mock('react-hook-form', async () => { + const actual = await vi.importActual('react-hook-form'); + return { ...actual, useForm: vi.fn() }; +}); + +// ── @hookform/resolvers/yup mock ─────────────────────────────────────────────── +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => vi.fn()), +})); + +// ── @mantine/dates mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, disabled, placeholder }) => ( +
+ + onChange?.(e.target.value)} + disabled={disabled} + placeholder={placeholder} + /> +
+ ), +})); + +// ── @mantine/core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ children, title }) => ( +
+ {title} + {children} +
+ ), + Badge: ({ children, color }) => ( + + {children} + + ), + Button: ({ children, onClick, disabled, loading, variant, color, type }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Grid: ({ children }) =>
{children}
, + GridCol: ({ children, span }) => ( +
+ {children} +
+ ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ + label, + value, + onChange, + disabled, + min, + max, + placeholder, + }) => ( +
+ + onChange?.(Number(e.target.value))} + disabled={disabled} + min={min} + max={max} + placeholder={placeholder} + /> +
+ ), + Paper: ({ children }) =>
{children}
, + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data?.map((item) => ( + + ))} +
+ ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + Textarea: ({ label, value, onChange, disabled, placeholder, error }) => ( +
+ +