diff --git a/CHANGELOG.md b/CHANGELOG.md index ea23e1dd..704dc58e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) (#1242) + - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated). + - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. + - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. + - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. + - **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Stream-limit exemptions for `ignore_same_channel_connections` apply only to sibling requests from the same `session_id`. + - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. + - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. + - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. +- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810) + +### Performance + +- **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change. +- **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely. +- **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync. +- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration. +- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. + +### Fixed + +- **Celery workers no longer use django-db-geventpool (fixes Postgres protocol errors under concurrent tasks).** Celery's default queue runs prefork (`--autoscale=6,1`), not gevent, but it was sharing the same `django-db-geventpool` backend as uWSGI. That pool is a process-wide singleton of warm connections; `fork()` (including autoscale spawning new children on demand) duplicated those sockets across processes and corrupted Postgres session state (`the last operation didn't produce records (command status: SET/BEGIN/ROLLBACK)`, `connection ... in transaction status INTRANS`, and matching server-side `there is already a transaction in progress` on one backend). Celery workers and beat now use Django's standard PostgreSQL backend (`CONN_MAX_AGE=0`, no warm pool); uWSGI keeps geventpool. Plugin discovery moved from `worker_ready` (arbiter) to `worker_process_init` (each child after `connections.close_all()`), so the prefork parent no longer opens DB handles that children would inherit. +- **EPG channel parsing progress no longer exceeds 100%.** During `parsing_channels`, progress used the pre-parse database channel count as the denominator while the numerator counted channels found in the XML. When the guide grew (or on sources where the file had more channels than the DB), the UI could show values well above 100% (e.g. ~300%). The estimate now bumps when the XML exceeds the DB baseline, progress is capped at 98% until final cleanup, and update frequency adapts to the known total (~20 ticks on steady-state refreshes; coarse every-100-channel updates for first import or when the file outgrows the estimate). A final in-loop update reports the true parsed count before completion. +- **EPG refresh and per-channel parses no longer race.** Full-source refresh holds an `epg_source_file` lock through download, channel parse, and bulk programme swap; programme index builds acquire the same lock separately (after refresh releases it). Per-channel `parse_programs_for_tvg_id` tasks run concurrently for different tvg-ids (no file lock between them) but defer while a source refresh is in progress. Refresh waits until in-flight per-channel parses finish (Redis counter) before downloading. Channels matched mid-refresh are excluded from the bulk-parse snapshot but receive a follow-up per-channel parse when refresh finishes; orphan cleanup at swap time uses live channel assignments so mid-refresh matches are not wiped. Duplicate tasks for the same `epg_id` are deduped via `parse_epg_programs` lock with a log noting how many channels map to that EPG. Busy file/index deferrals retry for up to two hours (15s interval). +- **Per-channel EPG parse no longer wipes guide data on failure.** `parse_programs_for_tvg_id` used to delete existing programmes before streaming the XML and flush new rows in batches as it parsed, so any failure partway through left a mapped channel with no guide data. It also re-fetched the `EPGData` row mid-task instead of reusing the instance loaded at task start. The task now reuses the initial row and replaces programmes in one transaction at the end (delete old, insert new), so a parse failure leaves the previous guide intact. +- **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). +- **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. +- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) +- **XC refresh no longer wipes auto-created channels on an empty provider fetch.** When `collect_xc_streams()` returned no live streams (transient upstream failure, fetch exception, or no enabled category matched), the refresh used to continue into stale-marking and auto-sync, which deleted the account's entire auto-created channel lineup. An empty XC result now aborts before those steps, sets the account to `ERROR`, and preserves the existing lineup—matching the standard M3U path's empty/failed-download guards. (Fixes #1377) — Thanks [@Jacob-Lasky](https://github.com/Jacob-Lasky) +- **`ChannelUtils.requeryChannels()` returns the API promise again** so bulk channel delete, drag reorder, and inline channel-number saves await the table refetch before clearing selection or continuing. — Thanks [@nick4810](https://github.com/nick4810) (#1393) +- **VOD connection card Content Duration badge** shows human-readable lengths (`45m`, `2h 0m`) again on the Stats page instead of bare minute integers. — Thanks [@nick4810](https://github.com/nick4810) (#1393) + +## [0.27.2] - 2026-06-30 + +### Added + +- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately. + +### Changed + +- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now: + - **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup. + - **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case). + - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet. +- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced). +- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**. + ## [0.27.1] - 2026-06-25 ### Security diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index fd8a4758..3f28903c 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -27,6 +27,7 @@ from apps.accounts.permissions import ( from core.models import UserAgent, CoreSettings from core.utils import RedisClient, safe_upload_path +from apps.m3u.utils import convert_js_numbered_backreferences from .models import ( Stream, @@ -368,6 +369,17 @@ class StreamViewSet(viewsets.ModelViewSet): except re.error as e: exclude_error = str(e) + # The replace field accepts JS-style $1 backreferences, but the regex + # engine honors \1. Convert once so the preview's "after" matches the + # name the live rename produces (apps/m3u/tasks.py sync_auto_channels + # applies the same conversion on the same engine). + replace_repl = convert_js_numbered_backreferences(replace_pat) + + # The live rename caps the result at Channel.name's column length + # before bulk_create; mirror that cap so the preview never shows a + # name the sync would truncate. + name_max_len = Channel._meta.get_field("name").max_length + # Capped at SCAN_CAP to bound memory on huge groups; the # separate COUNT lets the client surface scan_limit_hit when # the preview covers only a sample. @@ -390,11 +402,12 @@ class StreamViewSet(viewsets.ModelViewSet): total_scanned += 1 if find_re is not None: try: - new_name = find_re.sub(replace_pat, name, timeout=REGEX_TIMEOUT) + new_name = find_re.sub(replace_repl, name, timeout=REGEX_TIMEOUT) except (TimeoutError, re.error) as e: find_error = find_error or f"Pattern timed out: {e}" find_re = None continue + new_name = new_name[:name_max_len] if new_name != name: find_match_count += 1 if len(find_matches) < limit: diff --git a/apps/channels/migrations/0038_add_catchup_fields.py b/apps/channels/migrations/0038_add_catchup_fields.py new file mode 100644 index 00000000..8f653a3c --- /dev/null +++ b/apps/channels/migrations/0038_add_catchup_fields.py @@ -0,0 +1,101 @@ +"""Add denormalized catch-up fields to Stream and Channel.""" + +from django.db import migrations, models + + +def backfill_stream_catchup(apps, schema_editor): + """Derive is_catchup/catchup_days from Stream.custom_properties JSON.""" + with schema_editor.connection.cursor() as cursor: + cursor.execute(""" + UPDATE dispatcharr_channels_stream + SET is_catchup = TRUE, + catchup_days = COALESCE( + CASE WHEN (custom_properties->>'tv_archive_duration') ~ '^\\d+$' + THEN (custom_properties->>'tv_archive_duration')::int + ELSE NULL + END, 7 + ) + WHERE custom_properties IS NOT NULL + AND custom_properties != 'null'::jsonb + AND ( + custom_properties->>'tv_archive' = '1' + -- JSON booleans extract as lowercase 'true' via ->>; the + -- 'True' spelling covers Python-str values stored by + -- older import code. + OR custom_properties->>'tv_archive' = 'true' + OR custom_properties->>'tv_archive' = 'True' + ) + """) + + +def backfill_channel_catchup(apps, schema_editor): + """Roll up catch-up fields from streams to channels.""" + with schema_editor.connection.cursor() as cursor: + cursor.execute(""" + UPDATE dispatcharr_channels_channel c SET + is_catchup = EXISTS ( + SELECT 1 FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), + catchup_days = COALESCE(( + SELECT MAX(s.catchup_days) FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), 0) + """) + + +class Migration(migrations.Migration): + + dependencies = [ + ("dispatcharr_channels", "0037_auto_sync_overhaul"), + ] + + operations = [ + # Stream fields + migrations.AddField( + model_name="stream", + name="is_catchup", + field=models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)", + ), + ), + migrations.AddField( + model_name="stream", + name="catchup_days", + field=models.PositiveIntegerField( + default=0, + help_text="Number of days of catch-up archive available (tv_archive_duration)", + ), + ), + # Channel fields + migrations.AddField( + model_name="channel", + name="is_catchup", + field=models.BooleanField( + default=False, + db_index=True, + help_text="Whether any stream on this channel supports catch-up (tv_archive=1)", + ), + ), + migrations.AddField( + model_name="channel", + name="catchup_days", + field=models.PositiveIntegerField( + default=0, + help_text="Max catch-up archive days across all streams on this channel", + ), + ), + # Backfill existing data + migrations.RunPython( + backfill_stream_catchup, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + backfill_channel_catchup, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 747d0b89..fb421080 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -135,6 +135,17 @@ class Stream(models.Model): db_index=True ) + # Populated at import from tv_archive / tv_archive_duration. + is_catchup = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)", + ) + catchup_days = models.PositiveIntegerField( + default=0, + help_text="Number of days of catch-up archive available (tv_archive_duration)", + ) + class Meta: # If you use m3u_account, you might do unique_together = ('name','url','m3u_account') verbose_name = "Stream" @@ -364,6 +375,17 @@ class Channel(models.Model): help_text="The M3U account that auto-created this channel" ) + # Populated at import; rolled up via ChannelStream signal / m3u refresh. + is_catchup = models.BooleanField( + default=False, + db_index=True, + help_text="Whether any stream on this channel supports catch-up (tv_archive=1)", + ) + catchup_days = models.PositiveIntegerField( + default=0, + help_text="Max catch-up archive days across all streams on this channel", + ) + # Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries. # Auto-sync still recognizes them so they are not recreated when their # underlying provider stream persists; this is an output-layer concern, not diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 61bbef8d..d62b480e 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -448,6 +448,7 @@ class ChannelSerializer(serializers.ModelSerializer): "logo_id", "user_level", "is_adult", + "is_catchup", "hidden_from_output", "auto_created", "auto_created_by", diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 53f1ea33..1c3d00ef 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from django.utils.timezone import now, is_aware, make_aware from celery.result import AsyncResult from django_celery_beat.models import ClockedSchedule, PeriodicTask -from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording +from .models import Channel, Stream, ChannelStream, ChannelProfile, ChannelProfileMembership, Recording from apps.m3u.models import M3UAccount from apps.epg.tasks import parse_programs_for_tvg_id import json @@ -369,3 +369,20 @@ def schedule_task_on_save(sender, instance, created, **kwargs): @receiver(post_delete, sender=Recording) def revoke_task_on_delete(sender, instance, **kwargs): revoke_task(instance.task_id) + + +@receiver([post_save, post_delete], sender=ChannelStream) +def update_channel_catchup_fields(sender, instance, **kwargs): + """Roll up catch-up flags from active streams (UI path; import uses SQL rollup).""" + from django.db.models import Max + + channel = instance.channel + catchup_qs = channel.streams.filter( + is_catchup=True, + m3u_account__is_active=True, + ) + max_days = catchup_qs.aggregate(max_days=Max("catchup_days"))["max_days"] + Channel.objects.filter(pk=channel.pk).update( + is_catchup=catchup_qs.exists(), + catchup_days=max_days or 0, + ) diff --git a/apps/channels/tests/test_catchup_utils.py b/apps/channels/tests/test_catchup_utils.py new file mode 100644 index 00000000..d79eec11 --- /dev/null +++ b/apps/channels/tests/test_catchup_utils.py @@ -0,0 +1,100 @@ +from unittest.mock import patch + +from django.test import RequestFactory, SimpleTestCase, TestCase + +from apps.accounts.models import User +from apps.channels.models import Channel, ChannelStream, Stream +from apps.channels.utils import resolve_xc_epg_prev_days +from apps.m3u.models import M3UAccount + + +class ResolveXcEpgPrevDaysTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = User(username="xc-prev", custom_properties={}) + + def test_url_prev_days_zero_is_explicit(self): + request = self.factory.get("/xmltv.php?prev_days=0") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 0) + + def test_user_epg_prev_days_used_when_url_omitted(self): + self.user.custom_properties = {"epg_prev_days": 5} + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 5) + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_auto_detect_only_when_no_url_or_user_default(self, mock_compute): + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14) + mock_compute.assert_called_once() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_per_channel_epg_skips_global_auto_detect(self, mock_compute): + request = self.factory.get("/player_api.php") + self.assertEqual( + resolve_xc_epg_prev_days(request, self.user, auto_detect_fallback=False), + 0, + ) + mock_compute.assert_not_called() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_user_default_prevents_auto_detect(self, mock_compute): + self.user.custom_properties = {"epg_prev_days": 3} + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3) + mock_compute.assert_not_called() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + @patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7) + def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute): + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7) + mock_compute.assert_not_called() + + +class CatchupRollupActiveAccountTests(TestCase): + """Denormalized catch-up flags ignore disabled M3U accounts.""" + + @classmethod + def setUpTestData(cls): + cls.inactive = M3UAccount.objects.create( + name="catchup-inactive", + server_url="http://example.test", + account_type="XC", + is_active=False, + ) + + def test_channelstream_signal_ignores_inactive_catchup_stream(self): + channel = Channel.objects.create(name="inactive-only") + stream = Stream.objects.create( + name="inactive-catchup", + url="http://example.test/inactive", + m3u_account=self.inactive, + is_catchup=True, + catchup_days=9, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_ignores_inactive_catchup_stream(self): + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel = Channel.objects.create(name="rollup-inactive-only") + stream = Stream.objects.create( + name="rollup-inactive-catchup", + url="http://example.test/rollup-inactive", + m3u_account=self.inactive, + is_catchup=True, + catchup_days=9, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(self.inactive.id) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) diff --git a/apps/channels/tests/test_ts_proxy_teardown.py b/apps/channels/tests/test_ts_proxy_teardown.py index ef78e576..b76918ac 100644 --- a/apps/channels/tests/test_ts_proxy_teardown.py +++ b/apps/channels/tests/test_ts_proxy_teardown.py @@ -923,3 +923,62 @@ class StreamManagerStillOwnerTests(TestCase): return_value=5, ): self.assertTrue(manager._still_owner()) + + +class PreActiveNoClientsTimeoutTests(TestCase): + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_uses_client_wait_period(self, _mock_client_wait): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1006.0, + ) + self.assertTrue(should_stop) + self.assertEqual(timeout, 5) + self.assertEqual(reason, "client_wait") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_within_client_wait_period(self, _mock_client_wait): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1003.0, + ) + self.assertFalse(should_stop) + self.assertEqual(timeout, 5) + self.assertEqual(reason, "client_wait") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60) + def test_startup_uses_init_grace_period(self, _mock_init_grace): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=None, + start_time=1000.0, + now=1070.0, + ) + self.assertTrue(should_stop) + self.assertEqual(timeout, 60) + self.assertEqual(reason, "startup") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60) + def test_startup_within_init_grace_period(self, _mock_init_grace): + should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=None, + start_time=1000.0, + now=1030.0, + ) + self.assertFalse(should_stop) + self.assertEqual(timeout, 60) + self.assertEqual(reason, "startup") + + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30) + @patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5) + def test_buffer_ready_does_not_use_shutdown_delay(self, mock_client_wait, mock_shutdown_delay): + should_stop, _, reason = ProxyServer._pre_active_no_clients_should_stop( + connection_ready_time=1000.0, + start_time=900.0, + now=1006.0, + ) + self.assertTrue(should_stop) + self.assertEqual(reason, "client_wait") + mock_client_wait.assert_called_once() + mock_shutdown_delay.assert_not_called() diff --git a/apps/channels/utils.py b/apps/channels/utils.py index 60a27146..70b9eecd 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -1,8 +1,13 @@ import logging import threading +from django.core.cache import cache + logger = logging.getLogger(__name__) +PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300 +MAX_AUTO_PREV_DAYS = 30 + # Bound memory/DB work per chunk for large libraries (20k+ channels). EPG_LOGO_APPLY_BATCH_SIZE = 500 EPG_LOGO_APPLY_MAX_ERRORS = 100 @@ -23,6 +28,91 @@ def format_channel_number(value, empty=""): return int(value) return value + +def compute_provider_archive_days_capped(): + """Max ``catchup_days`` across active XC catch-up streams (capped, cached). + + Cached briefly so XC XMLTV exports without an explicit ``prev_days`` do not + repeat the aggregate query on every request. + """ + def _scan(): + from django.db.models import Max + + from apps.channels.models import Stream + + result = Stream.objects.filter( + m3u_account__account_type="XC", + m3u_account__is_active=True, + is_catchup=True, + ).aggregate(max_days=Max("catchup_days")) + return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS) + + return cache.get_or_set( + "channels:provider_archive_days_capped", + _scan, + PROVIDER_ARCHIVE_CACHE_TTL_SECONDS, + ) + + +def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): + """Resolve ``prev_days`` for XC XMLTV and player_api EPG. + + Args: + request: HTTP request (reads ``?prev_days=``). + user: Authenticated user (reads ``custom_properties.epg_prev_days``). + auto_detect_fallback: When True (XC XMLTV), fall back to the largest + provider archive depth. When False (per-channel EPG), return 0 so + ``xc_get_epg`` can expand to each channel's ``catchup_days``. + + Resolution order: + 1. URL ``?prev_days=`` (explicit; 0 means no past programmes) + 2. ``user.custom_properties.epg_prev_days`` + 3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0 + 4. Auto-detect (only when *auto_detect_fallback* is True) + """ + user_custom = (user.custom_properties or {}) if user else {} + url_prev = request.GET.get("prev_days") + user_prev = user_custom.get("epg_prev_days") if user_custom else None + + if url_prev is not None: + try: + return max(0, min(int(url_prev), MAX_AUTO_PREV_DAYS)) + except (ValueError, TypeError): + return 0 + if user_prev not in (None, ""): + try: + return max(0, min(int(user_prev), MAX_AUTO_PREV_DAYS)) + except (ValueError, TypeError): + return 0 + + from core.models import CoreSettings + + try: + override = int(CoreSettings.get_xmltv_prev_days_override() or 0) + except (TypeError, ValueError): + override = 0 + if override > 0: + return max(0, min(override, MAX_AUTO_PREV_DAYS)) + if auto_detect_fallback: + return compute_provider_archive_days_capped() + return 0 + + +def get_channel_catchup_streams(channel): + """Active catch-up streams for a channel, in ``channelstream`` order. + + Inactive M3U accounts are excluded, matching live dispatch. + """ + if not getattr(channel, "is_catchup", False): + return [] + + return list( + channel.streams.filter(is_catchup=True, m3u_account__is_active=True) + .order_by("channelstream__order") + .select_related("m3u_account") + ) + + def increment_stream_count(account): with lock: current_usage = active_streams_map.get(account.id, 0) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 83aaa275..612e9804 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -28,6 +28,7 @@ from channels.layers import get_channel_layer from .models import EPGSource, EPGSourceIndex, EPGData, ProgramData, SDScheduleMD5, SDProgramMD5 from core.utils import ( + RedisClient, acquire_task_lock, is_task_lock_held, release_task_lock, @@ -39,6 +40,58 @@ from core.utils import ( logger = logging.getLogger(__name__) +_EPG_SOURCE_FILE_LOCK = 'epg_source_file' +_EPG_REFRESH_DEFER_SECONDS = 15 +_EPG_TVG_PARSE_DEFER_SECONDS = 5 +_EPG_PARSE_DEFER_MAX = 480 # 15s x 480 = 2h for refresh; 5s x 480 = 40m for index build + + +def _source_tvg_parse_count_key(source_id): + return f'epg_source_tvg_parse_{source_id}' + + +def _incr_source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if client: + client.incr(_source_tvg_parse_count_key(source_id)) + + +def _decr_source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if not client: + return + key = _source_tvg_parse_count_key(source_id) + value = client.decr(key) + if value is not None and value <= 0: + client.delete(key) + + +def _source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if not client: + return 0 + return int(client.get(_source_tvg_parse_count_key(source_id)) or 0) + + +def _defer_refresh_epg_data(source, force, file_defer_retry, reason): + if file_defer_retry >= _EPG_PARSE_DEFER_MAX: + msg = f"EPG refresh blocked for too long ({reason}); retry later" + logger.error(f"Cannot refresh {source.name}: {msg}") + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + return True + logger.info( + f"Deferring refresh for {source.name} for {_EPG_REFRESH_DEFER_SECONDS}s: {reason}" + ) + refresh_epg_data.apply_async( + args=[source.id], + kwargs={'force': force, '_file_defer_retry': file_defer_retry + 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + return True + + _NON_TERMINAL_REFRESH_STATUSES = frozenset({ EPGSource.STATUS_FETCHING, EPGSource.STATUS_PARSING, @@ -58,9 +111,9 @@ def _db_query_with_retry(fn, *, label="DB query", max_retries=2): Poisoned Celery worker connections often surface as OperationalError or as ``IndexError: list index out of range`` inside Django's row converters. """ - from django.db import InterfaceError, OperationalError + from django.db import DatabaseError, InterfaceError, OperationalError - transient_errors = (OperationalError, InterfaceError, IndexError) + transient_errors = (OperationalError, InterfaceError, IndexError, DatabaseError) for attempt in range(max_retries): try: return fn() @@ -589,7 +642,7 @@ def refresh_all_epg_data(): @shared_task(time_limit=14400) -def refresh_epg_data(source_id, force=False): +def refresh_epg_data(source_id, force=False, _file_defer_retry=0): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return @@ -600,7 +653,9 @@ def refresh_epg_data(source_id, force=False): _release_task_db_connection() try: - return _refresh_epg_data_impl(source_id, force=force) + return _refresh_epg_data_impl( + source_id, force=force, _file_defer_retry=_file_defer_retry + ) except Exception as e: logger.error( f"Error in refresh_epg_data for source {source_id}: {e}", @@ -621,7 +676,7 @@ def refresh_epg_data(source_id, force=False): release_task_lock('refresh_epg_data', source_id) -def _refresh_epg_data_impl(source_id, force=False): +def _refresh_epg_data_impl(source_id, force=False, _file_defer_retry=0): try: source = _get_epg_source(source_id) except EPGSource.DoesNotExist: @@ -656,20 +711,39 @@ def _refresh_epg_data_impl(source_id, force=False): EPGSourceIndex.objects.update_or_create( source_id=source.id, defaults={'data': None} ) - if not fetch_xmltv(source): - logger.error(f"Failed to fetch XMLTV for source {source.name}") + + if _source_tvg_parse_count(source.id) > 0: + _defer_refresh_epg_data( + source, force, _file_defer_retry, 'per-channel program parses in progress' + ) return - if not parse_channels_only(source): - logger.error(f"Failed to parse channels for source {source.name}") + if not acquire_task_lock(_EPG_SOURCE_FILE_LOCK, source.id): + _defer_refresh_epg_data( + source, force, _file_defer_retry, 'EPG file in use (index build)' + ) return - # Build byte-offset index after programme data is committed so refresh - # does not compete for memory/IO during the programme swap. - if not parse_programs_for_source(source): - logger.error(f"Failed to parse programs for source {source.name}") - return + file_lock_renewer = TaskLockRenewer(_EPG_SOURCE_FILE_LOCK, source.id) + file_lock_renewer.start() + try: + if not fetch_xmltv(source): + logger.error(f"Failed to fetch XMLTV for source {source.name}") + return + if not parse_channels_only(source): + logger.error(f"Failed to parse channels for source {source.name}") + return + + if not parse_programs_for_source(source): + logger.error(f"Failed to parse programs for source {source.name}") + return + finally: + file_lock_renewer.stop() + release_task_lock(_EPG_SOURCE_FILE_LOCK, source.id) + + # Index build runs after the file lock is released so it does not + # compete with download/parse for the same XML on disk. build_programme_index_task.delay(source.id) elif source.source_type == 'schedules_direct': @@ -1197,6 +1271,35 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False): logger.error(f"Error extracting {file_path}: {str(e)}", exc_info=True) return None +_CHANNEL_PARSE_PROGRESS_START = 25 +_CHANNEL_PARSE_PROGRESS_CAP = 98 +_CHANNEL_PARSE_PROGRESS_SCALE = 0.98 + + +def _channel_parse_progress(processed_channels, channel_estimate, had_db_baseline): + """Map channel parsing onto 25-98%. Bump estimate when the XML has grown.""" + if not had_db_baseline: + if processed_channels <= 0: + return _CHANNEL_PARSE_PROGRESS_START, channel_estimate + return ( + min( + _CHANNEL_PARSE_PROGRESS_START + (processed_channels // 100), + _CHANNEL_PARSE_PROGRESS_CAP - 1, + ), + channel_estimate, + ) + + if processed_channels > channel_estimate: + channel_estimate = processed_channels + if channel_estimate <= 0: + return _CHANNEL_PARSE_PROGRESS_START, channel_estimate + + span = _CHANNEL_PARSE_PROGRESS_CAP - _CHANNEL_PARSE_PROGRESS_START + progress = _CHANNEL_PARSE_PROGRESS_START + int( + (processed_channels / channel_estimate) * span * _CHANNEL_PARSE_PROGRESS_SCALE + ) + return min(_CHANNEL_PARSE_PROGRESS_CAP, progress), channel_estimate + def parse_channels_only(source): # Use extracted file if available, otherwise use the original file path @@ -1309,6 +1412,8 @@ def parse_channels_only(source): epgs_to_create = [] epgs_to_update = [] total_channels = 0 + channel_estimate = 0 + had_db_baseline = False processed_channels = 0 batch_size = 500 # Process in batches to limit memory usage progress = 0 # Initialize progress variable here @@ -1323,10 +1428,14 @@ def parse_channels_only(source): # Attempt to count existing channels in the database try: total_channels = EPGData.objects.filter(epg_source=source).count() + channel_estimate = total_channels + had_db_baseline = total_channels > 0 logger.info(f"Found {total_channels} existing channels for this source") except Exception as e: logger.error(f"Error counting channels: {e}") total_channels = 500 # Default estimate + channel_estimate = total_channels + had_db_baseline = True if process: logger.debug(f"[parse_channels_only] Memory after closing initial file: {process.memory_info().rss / 1024 / 1024:.2f} MB") @@ -1453,20 +1562,36 @@ def parse_channels_only(source): if process: logger.info(f"[parse_channels_only] Memory after clearing cache: {process.memory_info().rss / 1024 / 1024:.2f} MB") - # Send progress updates - if processed_channels % 100 == 0 or processed_channels == total_channels: - progress = 25 + int((processed_channels / total_channels) * 65) if total_channels > 0 else 90 + # Send progress updates (~20 ticks when the DB count still holds; else every 100) + estimate_exceeded = had_db_baseline and processed_channels > total_channels + interval = ( + max(1, total_channels // 20) + if had_db_baseline and total_channels and not estimate_exceeded + else 100 + ) + if processed_channels % interval == 0: + progress, channel_estimate = _channel_parse_progress( + processed_channels, + channel_estimate, + had_db_baseline, + ) send_epg_update( source.id, "parsing_channels", progress, processed=processed_channels, - total=total_channels + total=channel_estimate, + ) + if had_db_baseline and processed_channels > total_channels: + logger.debug( + f"[parse_channels_only] Processed channel {tvg_id} - " + f"processed {processed_channels - total_channels} additional channels" ) - if processed_channels > total_channels: - logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels - total_channels} additional channels") else: - logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels}/{total_channels}") + logger.debug( + f"[parse_channels_only] Processed channel {tvg_id} - " + f"processed {processed_channels}/{channel_estimate or total_channels}" + ) if process: logger.debug(f"[parse_channels_only] Memory before elem cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") # Clear memory @@ -1499,6 +1624,21 @@ def parse_channels_only(source): logger.info(f"[parse_channels_only] Processed {processed_channels} channels current memory: {process.memory_info().rss / 1024 / 1024:.2f} MB") else: logger.info(f"[parse_channels_only] Processed {processed_channels} channels") + + if processed_channels > 0: + progress, channel_estimate = _channel_parse_progress( + processed_channels, + channel_estimate, + had_db_baseline, + ) + send_epg_update( + source.id, + "parsing_channels", + progress, + processed=processed_channels, + total=channel_estimate, + ) + # Process any remaining items if epgs_to_create: EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) @@ -1600,305 +1740,335 @@ def parse_channels_only(source): pass +def _defer_parse_programs_for_tvg_id(epg_id, force, defer_retry, reason): + if defer_retry >= _EPG_PARSE_DEFER_MAX: + logger.warning( + f"Giving up on parse_programs_for_tvg_id({epg_id}) after " + f"{defer_retry} deferrals: {reason}" + ) + return f"Deferred too many times: {reason}" + + logger.info( + f"Deferring parse_programs_for_tvg_id({epg_id}) for " + f"{_EPG_REFRESH_DEFER_SECONDS}s: {reason}" + ) + parse_programs_for_tvg_id.apply_async( + args=[epg_id], + kwargs={'force': force, '_defer_retry': defer_retry + 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + return "Deferred" + @shared_task(time_limit=3600, soft_time_limit=3500) -def parse_programs_for_tvg_id(epg_id, force=False): +def parse_programs_for_tvg_id(epg_id, force=False, _defer_retry=0): + epg_obj = None try: - from apps.epg.models import EPGData epg_obj = EPGData.objects.select_related('epg_source').filter(id=epg_id).first() if epg_obj and epg_obj.epg_source and epg_obj.epg_source.source_type == 'schedules_direct': return fetch_sd_guide_for_epg(epg_id, force=force) except Exception as e: logger.warning(f"Could not check EPG source type for id={epg_id}: {e}") - if not acquire_task_lock('parse_epg_programs', epg_id): - logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") - return "Task already running" + if not epg_obj or not epg_obj.epg_source: + return "EPG not found" - lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) - lock_renewer.start() + epg_source = epg_obj.epg_source + if epg_source.source_type == 'dummy': + return - source_file = None - program_parser = None - programs_to_create = [] - programs_processed = 0 + source_id = epg_source.id + if is_task_lock_held('refresh_epg_data', source_id): + return _defer_parse_programs_for_tvg_id( + epg_id, force, _defer_retry, 'source refresh in progress' + ) + + _incr_source_tvg_parse_count(source_id) try: - # Add memory tracking only in trace mode or higher + if not acquire_task_lock('parse_epg_programs', epg_id): + mapped_channels = Channel.objects.filter(epg_data_id=epg_id).count() + logger.info( + f"Program parse for epg_id={epg_id} ({epg_obj.tvg_id}) already in progress, " + f"skipping duplicate task ({mapped_channels} channel(s) mapped to this EPG)" + ) + return "Task already running" + + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + + source_file = None + program_parser = None + programs_to_create = [] + programs_processed = 0 try: - process = None - # Get current log level as a number - current_log_level = logger.getEffectiveLevel() + # Add memory tracking only in trace mode or higher + try: + process = None + # Get current log level as a number + current_log_level = logger.getEffectiveLevel() + + # Only track memory usage when log level is TRACE or more verbose or if running in DEBUG mode + should_log_memory = current_log_level <= 5 or settings.DEBUG + + if should_log_memory: + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Initial memory usage: {initial_memory:.2f} MB") + mem_before = initial_memory + except ImportError: + process = None + should_log_memory = False + + # Reuse the EPGData fetched at the top of the task (epg_source is already + # cached via select_related) instead of issuing a second query mid-task. + epg = epg_obj - # Only track memory usage when log level is TRACE or more verbose or if running in DEBUG mode - should_log_memory = current_log_level <= 5 or settings.DEBUG - - if should_log_memory: - process = psutil.Process() - initial_memory = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Initial memory usage: {initial_memory:.2f} MB") - mem_before = initial_memory - except ImportError: - process = None - should_log_memory = False - - epg = EPGData.objects.get(id=epg_id) - epg_source = epg.epg_source - - # Skip program parsing for dummy EPG sources - they don't have program data files - if epg_source.source_type == 'dummy': - logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - if not force and not Channel.objects.filter(epg_data=epg).exists(): - logger.info(f"No channels matched to EPG {epg.tvg_id}") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - - # Optimize deletion with a single delete query instead of chunking - # This is faster for most database engines - ProgramData.objects.filter(epg=epg).delete() - - file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path - if not file_path: - file_path = epg_source.get_cache_file() - - # Check if the file exists - if not os.path.exists(file_path): - logger.error(f"EPG file not found at: {file_path}") - - if epg_source.url: - # Update the file path in the database - new_path = epg_source.get_cache_file() - logger.info(f"Updating file_path from '{file_path}' to '{new_path}'") - epg_source.file_path = new_path - epg_source.save(update_fields=['file_path']) - logger.info(f"Fetching new EPG data from URL: {epg_source.url}") - else: - logger.info(f"EPG source does not have a URL, using existing file path: {file_path} to rebuild cache") - - # Fetch new data before continuing - if epg_source: - - # Properly check the return value from fetch_xmltv - fetch_success = fetch_xmltv(epg_source) - - # If fetch was not successful or the file still doesn't exist, abort - if not fetch_success: - logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") - # Update status to error if not already set - epg_source.status = 'error' - epg_source.last_message = f"Failed to download EPG data, cannot parse programs" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - # Also check if the file exists after download - if not os.path.exists(epg_source.file_path): - logger.error(f"Failed to fetch EPG data, file still missing at: {epg_source.file_path}") - epg_source.status = 'error' - epg_source.last_message = f"Failed to download EPG data, file missing after download" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - # Update file_path with the new location - if epg_source.extracted_file_path: - file_path = epg_source.extracted_file_path - else: - file_path = epg_source.file_path - else: - logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") - # Update status to error - epg_source.status = 'error' - epg_source.last_message = f"No URL provided, cannot fetch EPG data" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) + if not force and not Channel.objects.filter(epg_data=epg).exists(): + logger.info(f"No channels matched to EPG {epg.tvg_id}") return - # Use streaming parsing to reduce memory usage - # No need to check file type anymore since it's always XML - logger.debug(f"Parsing programs for tvg_id={epg.tvg_id} from {file_path}") + logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - # Memory usage tracking - if process: - try: - mem_before = process.memory_info().rss / 1024 / 1024 - logger.debug(f"[parse_programs_for_tvg_id] Memory before parsing {epg.tvg_id} - {mem_before:.2f} MB") - except Exception as e: - logger.warning(f"Error tracking memory: {e}") - mem_before = 0 - - programs_to_create = [] - batch_size = 1000 # Process in batches to limit memory usage - - try: - # Open the file directly - no need to check compression - logger.debug(f"Opening file for parsing: {file_path}") - source_file = _open_xmltv_file(file_path) - - # Stream parse the file using lxml's iterparse - program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True) - - for _, elem in program_parser: - if elem.get('channel') == epg.tvg_id: - try: - start_time = parse_xmltv_time(elem.get('start')) - end_time = parse_xmltv_time(elem.get('stop')) - title = None - desc = None - sub_title = None - - # Efficiently process child elements - for child in elem: - if child.tag == 'title': - title = child.text or 'No Title' - elif child.tag == 'desc': - desc = child.text or '' - elif child.tag == 'sub-title': - sub_title = child.text or '' - - if not title: - title = 'No Title' - - # Extract custom properties - custom_props = extract_custom_properties(elem) - custom_properties_json = None - - if custom_props: - logger.trace(f"Number of custom properties: {len(custom_props)}") - custom_properties_json = custom_props - - # Fallback: extract S/E from description when episode-num - # elements didn't provide them - if desc: - has_season = (custom_properties_json or {}).get('season') is not None - has_episode = (custom_properties_json or {}).get('episode') is not None - if not has_season or not has_episode: - d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) - if d_season is not None and d_episode is not None: - if custom_properties_json is None: - custom_properties_json = {} - if not has_season: - custom_properties_json['season'] = d_season - if not has_episode: - custom_properties_json['episode'] = d_episode - custom_properties_json['season_episode_source'] = 'description' - desc = cleaned_desc - - programs_to_create.append(ProgramData( - epg=epg, - start_time=start_time, - end_time=end_time, - title=title[:255], - description=desc, - sub_title=sub_title, - tvg_id=epg.tvg_id, - custom_properties=custom_properties_json - )) - programs_processed += 1 - # Clear the element to free memory - clear_element(elem) - # Batch processing - if len(programs_to_create) >= batch_size: - ProgramData.objects.bulk_create(programs_to_create) - logger.debug(f"Saved batch of {len(programs_to_create)} programs for {epg.tvg_id}") - programs_to_create = [] - # Only call gc.collect() every few batches - if programs_processed % (batch_size * 5) == 0: - gc.collect() - - except Exception as e: - logger.error(f"Error processing program for {epg.tvg_id}: {e}", exc_info=True) + file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path + if not file_path: + file_path = epg_source.get_cache_file() + + # Check if the file exists + if not os.path.exists(file_path): + logger.error(f"EPG file not found at: {file_path}") + + if epg_source.url: + # Update the file path in the database + new_path = epg_source.get_cache_file() + logger.info(f"Updating file_path from '{file_path}' to '{new_path}'") + epg_source.file_path = new_path + epg_source.save(update_fields=['file_path']) + logger.info(f"Fetching new EPG data from URL: {epg_source.url}") else: - # Immediately clean up non-matching elements to reduce memory pressure - if elem is not None: - clear_element(elem) - continue - - # Make sure to close the file and release parser resources - if source_file: - source_file.close() - source_file = None - - if program_parser: - program_parser = None - - gc.collect() - - except zipfile.BadZipFile as zip_error: - logger.error(f"Bad ZIP file: {zip_error}") - raise - except etree.XMLSyntaxError as xml_error: - logger.error(f"XML syntax error parsing program data: {xml_error}") - raise - except Exception as e: - logger.error(f"Error parsing XML for programs: {e}", exc_info=True) - raise - finally: - # Ensure file is closed even if an exception occurs - if source_file: - source_file.close() - source_file = None - # Memory tracking after processing + logger.info(f"EPG source does not have a URL, using existing file path: {file_path} to rebuild cache") + + # Fetch new data before continuing + if epg_source: + + # Properly check the return value from fetch_xmltv + fetch_success = fetch_xmltv(epg_source) + + # If fetch was not successful or the file still doesn't exist, abort + if not fetch_success: + logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") + # Update status to error if not already set + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, cannot parse programs" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + return + + # Also check if the file exists after download + if not os.path.exists(epg_source.file_path): + logger.error(f"Failed to fetch EPG data, file still missing at: {epg_source.file_path}") + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, file missing after download" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + return + + # Update file_path with the new location + if epg_source.extracted_file_path: + file_path = epg_source.extracted_file_path + else: + file_path = epg_source.file_path + else: + logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") + # Update status to error + epg_source.status = 'error' + epg_source.last_message = f"No URL provided, cannot fetch EPG data" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + return + + # Use streaming parsing to reduce memory usage + # No need to check file type anymore since it's always XML + logger.debug(f"Parsing programs for tvg_id={epg.tvg_id} from {file_path}") + + # Memory usage tracking if process: try: - mem_after = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Memory after parsing 1 {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + mem_before = process.memory_info().rss / 1024 / 1024 + logger.debug(f"[parse_programs_for_tvg_id] Memory before parsing {epg.tvg_id} - {mem_before:.2f} MB") except Exception as e: logger.warning(f"Error tracking memory: {e}") - - # Process any remaining items - if programs_to_create: - ProgramData.objects.bulk_create(programs_to_create) - logger.debug(f"Saved final batch of {len(programs_to_create)} programs for {epg.tvg_id}") + mem_before = 0 + + # Accumulate all of this channel's programmes in memory and swap them in + # atomically at the end (delete-old + insert-new in one transaction), so a + # mid-parse failure leaves the previous guide data intact instead of the + # channel ending up with nothing. Per-channel volume is small enough + # (weeks of one channel's schedule) that this doesn't need batched flushing + # to the DB the way the whole-source bulk parse does. + programs_to_create = [] + + try: + # Open the file directly - no need to check compression + logger.debug(f"Opening file for parsing: {file_path}") + source_file = _open_xmltv_file(file_path) + + # Stream parse the file using lxml's iterparse + program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True) + + for _, elem in program_parser: + if elem.get('channel') == epg.tvg_id: + try: + start_time = parse_xmltv_time(elem.get('start')) + end_time = parse_xmltv_time(elem.get('stop')) + title = None + desc = None + sub_title = None + + # Efficiently process child elements + for child in elem: + if child.tag == 'title': + title = child.text or 'No Title' + elif child.tag == 'desc': + desc = child.text or '' + elif child.tag == 'sub-title': + sub_title = child.text or '' + + if not title: + title = 'No Title' + + # Extract custom properties + custom_props = extract_custom_properties(elem) + custom_properties_json = None + + if custom_props: + logger.trace(f"Number of custom properties: {len(custom_props)}") + custom_properties_json = custom_props + + # Fallback: extract S/E from description when episode-num + # elements didn't provide them + if desc: + has_season = (custom_properties_json or {}).get('season') is not None + has_episode = (custom_properties_json or {}).get('episode') is not None + if not has_season or not has_episode: + d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) + if d_season is not None and d_episode is not None: + if custom_properties_json is None: + custom_properties_json = {} + if not has_season: + custom_properties_json['season'] = d_season + if not has_episode: + custom_properties_json['episode'] = d_episode + custom_properties_json['season_episode_source'] = 'description' + desc = cleaned_desc + + programs_to_create.append(ProgramData( + epg=epg, + start_time=start_time, + end_time=end_time, + title=title[:255], + description=desc, + sub_title=sub_title, + tvg_id=epg.tvg_id, + custom_properties=custom_properties_json + )) + programs_processed += 1 + # Clear the element to free memory + clear_element(elem) + if programs_processed % 5000 == 0: + gc.collect() + + except Exception as e: + logger.error(f"Error processing program for {epg.tvg_id}: {e}", exc_info=True) + else: + # Immediately clean up non-matching elements to reduce memory pressure + if elem is not None: + clear_element(elem) + continue + + # Make sure to close the file and release parser resources + if source_file: + source_file.close() + source_file = None + + if program_parser: + program_parser = None + + gc.collect() + + except zipfile.BadZipFile as zip_error: + logger.error(f"Bad ZIP file: {zip_error}") + raise + except etree.XMLSyntaxError as xml_error: + logger.error(f"XML syntax error parsing program data: {xml_error}") + raise + except Exception as e: + logger.error(f"Error parsing XML for programs: {e}", exc_info=True) + raise + finally: + # Ensure file is closed even if an exception occurs + if source_file: + source_file.close() + source_file = None + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Memory after parsing 1 {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + + # Swap old programmes for the newly parsed ones atomically. If the insert + # fails (including a poisoned-connection blip), the transaction rolls back + # and the previous guide data for this channel is left untouched. + with transaction.atomic(): + deleted_count = ProgramData.objects.filter(epg=epg).delete()[0] + if programs_to_create: + for i in range(0, len(programs_to_create), _EPG_SWAP_BATCH_SIZE): + ProgramData.objects.bulk_create( + programs_to_create[i:i + _EPG_SWAP_BATCH_SIZE] + ) + logger.debug( + f"Replaced {deleted_count} program(s) with {len(programs_to_create)} " + f"for {epg.tvg_id}" + ) programs_to_create = None custom_props = None custom_properties_json = None - - logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") - finally: - # Reset internal caches and pools that lxml might be keeping - try: - etree.clear_error_log() - except: - pass - # Explicit cleanup of all potentially large objects - if source_file: + logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") + finally: + # Reset internal caches and pools that lxml might be keeping try: - source_file.close() + etree.clear_error_log() except: pass - source_file = None - program_parser = None - programs_to_create = None - - epg_source = None - # Add comprehensive cleanup before releasing lock - cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing - if process: - try: - mem_after = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Final memory usage {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") - except Exception as e: - logger.warning(f"Error tracking memory: {e}") - process = None - epg = None - programs_processed = None - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) + # Explicit cleanup of all potentially large objects + if source_file: + try: + source_file.close() + except: + pass + source_file = None + program_parser = None + programs_to_create = None + + epg_source = None + # Add comprehensive cleanup before releasing lock + cleanup_memory(log_usage=should_log_memory, force_collection=True) + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Final memory usage {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + process = None + epg = None + programs_processed = None + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + finally: + _decr_source_tvg_parse_count(source_id) _EPG_PROGRAM_STAGING_TABLE = 'epg_program_staging' @@ -1977,6 +2147,57 @@ def _flush_epg_program_staging_batch(programs_batch): ) +def _epg_ids_mapped_to_channels(epg_source): + """EPGData ids currently assigned to at least one channel on this source.""" + return set( + Channel.objects.filter( + epg_data__epg_source=epg_source, + epg_data__isnull=False, + ).values_list('epg_data_id', flat=True) + ) + + +def _delete_orphaned_epg_programs(epg_source): + """ + Remove programme rows for EPG entries that have no channel mapped now. + + Uses live channel assignments, not the snapshot taken at bulk-parse start, + so channels matched mid-refresh are not treated as orphaned. + """ + currently_mapped = _epg_ids_mapped_to_channels(epg_source) + unmapped_epg_ids = list( + EPGData.objects.filter(epg_source=epg_source) + .exclude(id__in=currently_mapped) + .values_list('id', flat=True) + ) + if not unmapped_epg_ids: + return 0 + orphaned_count = ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete()[0] + if orphaned_count > 0: + logger.info( + f"Cleaned up {orphaned_count} orphaned programs for " + f"{len(unmapped_epg_ids)} unmapped EPG entries" + ) + return orphaned_count + + +def _dispatch_late_mapped_epg_parses(epg_source, bulk_parsed_epg_ids): + """ + Queue per-channel parses for EPG rows mapped after bulk parse began. + + Per-channel tasks triggered during refresh defer until refresh finishes; + this ensures they still run promptly even if a deferred retry is pending. + """ + missed_epg_ids = _epg_ids_mapped_to_channels(epg_source) - bulk_parsed_epg_ids + if not missed_epg_ids: + return 0 + logger.info( + f"Queueing per-channel program parse for {len(missed_epg_ids)} EPG row(s) " + f"mapped during bulk refresh of {epg_source.name}" + ) + return dispatch_program_refresh_for_epg_ids(missed_epg_ids) + + def _swap_staged_epg_programs(mapped_epg_ids, epg_source, batch_size=_EPG_SWAP_BATCH_SIZE): """ Atomically replace mapped programme rows with staged data. @@ -1991,18 +2212,7 @@ def _swap_staged_epg_programs(mapped_epg_ids, epg_source, batch_size=_EPG_SWAP_B deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0] logger.debug(f"Deleted {deleted_count} existing programs") - unmapped_epg_ids = list( - EPGData.objects.filter(epg_source=epg_source) - .exclude(id__in=mapped_epg_ids) - .values_list('id', flat=True) - ) - if unmapped_epg_ids: - orphaned_count = ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete()[0] - if orphaned_count > 0: - logger.info( - f"Cleaned up {orphaned_count} orphaned programs for " - f"{len(unmapped_epg_ids)} unmapped EPG entries" - ) + _delete_orphaned_epg_programs(epg_source) if not _epg_program_staging_supported(): raise RuntimeError('_swap_staged_epg_programs requires PostgreSQL staging support') @@ -2047,13 +2257,7 @@ def _swap_parsed_epg_programs(mapped_epg_ids, epg_source, programs_to_create, ba """SQLite/dev fallback: atomic delete + bulk insert from an in-memory batch list.""" with transaction.atomic(): deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0] - unmapped_epg_ids = list( - EPGData.objects.filter(epg_source=epg_source) - .exclude(id__in=mapped_epg_ids) - .values_list('id', flat=True) - ) - if unmapped_epg_ids: - ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete() + _delete_orphaned_epg_programs(epg_source) for i in range(0, len(programs_to_create), batch_size): ProgramData.objects.bulk_create(programs_to_create[i:i + batch_size]) return deleted_count @@ -2380,6 +2584,7 @@ def parse_programs_for_source(epg_source, tvg_id=None): logger.info(f"Completed parsing programs for source: {epg_source.name} - " f"{total_programs:,} programs for {channels_with_programs} channels, " f"skipped {skipped_programs:,} programs for unmapped channels") + _dispatch_late_mapped_epg_parses(epg_source, mapped_epg_ids) return True except Exception as e: @@ -4581,18 +4786,32 @@ def build_programme_index(source_id): @shared_task -def build_programme_index_task(source_id): - """Celery wrapper. Locks so refresh and preview don't both build the same source. Releases on finish rather than waiting out the TTL.""" - from core.utils import RedisClient - - redis_client = RedisClient.get_client() - lock_key = f'building_programme_index_{source_id}' - if not redis_client.set(lock_key, '1', nx=True, ex=300): +def build_programme_index_task(source_id, _defer_retry=0): + """Celery wrapper. Serializes index builds with other EPG file access on this source.""" + if not acquire_task_lock(_EPG_SOURCE_FILE_LOCK, source_id): + if _defer_retry >= _EPG_PARSE_DEFER_MAX: + logger.warning( + f"Giving up on programme index build for source {source_id} " + f"after {_defer_retry} deferrals: file busy" + ) + return + logger.debug( + f"EPG source file busy, deferring programme index build for source {source_id}" + ) + build_programme_index_task.apply_async( + args=[source_id], + kwargs={'_defer_retry': _defer_retry + 1}, + countdown=_EPG_TVG_PARSE_DEFER_SECONDS, + ) return + + lock_renewer = TaskLockRenewer(_EPG_SOURCE_FILE_LOCK, source_id) + lock_renewer.start() try: build_programme_index(source_id) finally: - redis_client.delete(lock_key) + lock_renewer.stop() + release_task_lock(_EPG_SOURCE_FILE_LOCK, source_id) def find_current_program_for_tvg_id(epg_or_id): diff --git a/apps/epg/tests/test_epg_source_file_lock.py b/apps/epg/tests/test_epg_source_file_lock.py new file mode 100644 index 00000000..9665e4ed --- /dev/null +++ b/apps/epg/tests/test_epg_source_file_lock.py @@ -0,0 +1,95 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase, TestCase + +from apps.epg.models import EPGSource, EPGData +from apps.epg.tasks import ( + _EPG_PARSE_DEFER_MAX, + _EPG_REFRESH_DEFER_SECONDS, + _defer_parse_programs_for_tvg_id, + _refresh_epg_data_impl, + parse_programs_for_tvg_id, +) + + +class DeferParseProgramsTests(SimpleTestCase): + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + def test_defer_schedules_retry_for_refresh(self, mock_apply_async): + result = _defer_parse_programs_for_tvg_id(5, False, 0, "source refresh in progress") + + self.assertEqual(result, "Deferred") + mock_apply_async.assert_called_once_with( + args=[5], + kwargs={"force": False, "_defer_retry": 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + def test_defer_gives_up_after_max_retries(self, mock_apply_async): + result = _defer_parse_programs_for_tvg_id( + 5, False, _EPG_PARSE_DEFER_MAX, "source refresh in progress" + ) + + self.assertIn("Deferred too many times", result) + mock_apply_async.assert_not_called() + + +class ParseProgramsForTvgIdLockTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name="Lock Test", + source_type="xmltv", + file_path="/tmp/unused.xml", + ) + self.epg = EPGData.objects.create( + tvg_id="test.channel", + name="Test", + epg_source=self.source, + ) + + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + @patch("apps.epg.tasks.is_task_lock_held", return_value=True) + def test_defers_while_source_refresh_running(self, mock_held, mock_apply_async): + result = parse_programs_for_tvg_id(self.epg.id) + + self.assertEqual(result, "Deferred") + mock_held.assert_called_once_with("refresh_epg_data", self.source.id) + mock_apply_async.assert_called_once() + + @patch("apps.epg.tasks._decr_source_tvg_parse_count") + @patch("apps.epg.tasks._incr_source_tvg_parse_count") + @patch("apps.epg.tasks.acquire_task_lock", return_value=False) + @patch("apps.epg.tasks.is_task_lock_held", return_value=False) + def test_skips_when_duplicate_epg_parse_running( + self, mock_held, mock_acquire, mock_incr, mock_decr + ): + result = parse_programs_for_tvg_id(self.epg.id) + + self.assertEqual(result, "Task already running") + mock_acquire.assert_called_once_with("parse_epg_programs", self.epg.id) + mock_incr.assert_called_once_with(self.source.id) + mock_decr.assert_called_once_with(self.source.id) + + +class RefreshEpgDataDeferTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name="Refresh Defer Test", + source_type="xmltv", + file_path="/tmp/unused.xml", + ) + + @patch("apps.epg.tasks.refresh_epg_data.apply_async") + @patch("apps.epg.tasks.fetch_xmltv") + @patch("apps.epg.tasks._source_tvg_parse_count", return_value=1) + def test_refresh_defers_while_per_channel_parses_running( + self, mock_count, mock_fetch, mock_apply_async + ): + _refresh_epg_data_impl(self.source.id) + + mock_fetch.assert_not_called() + mock_apply_async.assert_called_once_with( + args=[self.source.id], + kwargs={"force": False, "_file_defer_retry": 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) diff --git a/apps/epg/tests/test_parse_programs_for_source.py b/apps/epg/tests/test_parse_programs_for_source.py index f49a5d70..cfddc76c 100644 --- a/apps/epg/tests/test_parse_programs_for_source.py +++ b/apps/epg/tests/test_parse_programs_for_source.py @@ -13,6 +13,8 @@ from apps.epg.tasks import ( parse_programs_for_source, _flush_epg_program_staging_batch, _swap_staged_epg_programs, + _delete_orphaned_epg_programs, + _dispatch_late_mapped_epg_parses, _EPG_PARSE_BATCH_SIZE, ) @@ -236,3 +238,37 @@ class ParseProgramsForSourceTests(TestCase): self.assertFalse(result) self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'Keep Me') + + def test_orphan_cleanup_respects_channels_mapped_during_bulk_parse(self): + late_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.unmapped_epg, + start_time=late_start, + end_time=late_start + timedelta(hours=1), + title='Late Match Programme', + tvg_id=self.unmapped_epg.tvg_id, + ) + Channel.objects.create( + channel_number=2, + name='Late Mapped Channel', + epg_data=self.unmapped_epg, + ) + + deleted = _delete_orphaned_epg_programs(self.source) + + self.assertEqual(deleted, 0) + self.assertEqual(ProgramData.objects.filter(epg=self.unmapped_epg).count(), 1) + + @patch('apps.epg.tasks.dispatch_program_refresh_for_epg_ids', return_value=1) + def test_late_mapped_dispatches_per_channel_parse(self, mock_dispatch): + Channel.objects.create( + channel_number=2, + name='Late Mapped Channel', + epg_data=self.unmapped_epg, + ) + bulk_snapshot = {self.mapped_epg.id} + + dispatched = _dispatch_late_mapped_epg_parses(self.source, bulk_snapshot) + + self.assertEqual(dispatched, 1) + mock_dispatch.assert_called_once_with({self.unmapped_epg.id}) diff --git a/apps/epg/tests/test_parse_programs_for_tvg_id.py b/apps/epg/tests/test_parse_programs_for_tvg_id.py new file mode 100644 index 00000000..e17c2d49 --- /dev/null +++ b/apps/epg/tests/test_parse_programs_for_tvg_id.py @@ -0,0 +1,128 @@ +import os +import tempfile +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel +from apps.epg.models import EPGSource, EPGData, ProgramData +from apps.epg.tasks import parse_programs_for_tvg_id + + +def _programme_xml(channel_id, title, start, stop): + return ( + f' \n' + f' {title}\n' + f' \n' + ) + + +def _xmltv_file(programmes): + body = ( + '\n' + '\n' + f'{programmes}' + '\n' + ) + handle = tempfile.NamedTemporaryFile( + mode='w', + suffix='.xml', + delete=False, + encoding='utf-8', + ) + handle.write(body) + handle.close() + return handle.name + + +class ParseProgramsForTvgIdSwapTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name='Per-Channel Parse Test', + source_type='xmltv', + ) + self.epg = EPGData.objects.create( + epg_source=self.source, + tvg_id='test.channel', + name='Test Channel', + ) + self.channel = Channel.objects.create( + channel_number=1, + name='Test Channel', + epg_data=self.epg, + ) + self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0) + self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000') + self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000') + + def tearDown(self): + if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path): + os.unlink(self.xml_path) + + def _configure_source_file(self, programmes): + self.xml_path = _xmltv_file(programmes) + self.source.file_path = self.xml_path + self.source.save(update_fields=['file_path']) + + def test_replaces_programs_for_channel(self): + old_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.epg, + start_time=old_start, + end_time=old_start + timedelta(hours=1), + title='Old Programme', + tvg_id=self.epg.tvg_id, + ) + self._configure_source_file( + _programme_xml('test.channel', 'New Show', self.start, self.stop) + ) + + parse_programs_for_tvg_id(self.epg.id) + + programs = ProgramData.objects.filter(epg=self.epg) + self.assertEqual(programs.count(), 1) + self.assertEqual(programs.get().title, 'New Show') + + def test_failed_insert_preserves_existing_programs(self): + """A failed atomic swap must not leave the channel with no guide data.""" + old_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.epg, + start_time=old_start, + end_time=old_start + timedelta(hours=1), + title='Keep Me', + tvg_id=self.epg.tvg_id, + ) + self._configure_source_file( + _programme_xml('test.channel', 'Replacement', self.start, self.stop) + ) + + with patch( + 'apps.epg.tasks.ProgramData.objects.bulk_create', + side_effect=RuntimeError('simulated insert failure'), + ): + with self.assertRaises(RuntimeError): + parse_programs_for_tvg_id(self.epg.id) + + remaining = ProgramData.objects.filter(epg=self.epg) + self.assertEqual(remaining.count(), 1) + self.assertEqual(remaining.get().title, 'Keep Me') + + def test_does_not_refetch_epg_data_mid_task(self): + """The task must reuse the EPGData row loaded at task start.""" + self._configure_source_file( + _programme_xml('test.channel', 'New Show', self.start, self.stop) + ) + + with patch( + 'apps.epg.tasks.EPGData.objects.get', + side_effect=AssertionError('should not re-fetch EPGData mid-task'), + ) as mock_get: + parse_programs_for_tvg_id(self.epg.id) + + mock_get.assert_not_called() + self.assertEqual( + ProgramData.objects.filter(epg=self.epg).get().title, 'New Show' + ) diff --git a/apps/epg/tests/test_parsing_channels_progress.py b/apps/epg/tests/test_parsing_channels_progress.py new file mode 100644 index 00000000..ece0e71c --- /dev/null +++ b/apps/epg/tests/test_parsing_channels_progress.py @@ -0,0 +1,44 @@ +from django.test import SimpleTestCase + +from apps.epg.tasks import ( + _CHANNEL_PARSE_PROGRESS_CAP, + _CHANNEL_PARSE_PROGRESS_START, + _channel_parse_progress, +) + + +class ChannelParseProgressTests(SimpleTestCase): + def test_exceeding_estimate_recalculates_instead_of_going_over_cap(self): + """101/100 used to yield ~90%+; now bumps estimate to 101 so ratio stays <= 1.""" + progress, estimate = _channel_parse_progress(100, 100, had_db_baseline=True) + self.assertEqual(estimate, 100) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + progress, estimate = _channel_parse_progress(101, 100, had_db_baseline=True) + self.assertEqual(estimate, 101) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + self.assertGreater(progress, 90) + + def test_large_xml_growth_never_exceeds_cap(self): + """Previously 425 channels vs DB estimate of 100 could show ~300%.""" + estimate = 100 + for processed in (100, 200, 300, 425): + progress, estimate = _channel_parse_progress( + processed, estimate, had_db_baseline=True + ) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + def test_first_import_crawls_instead_of_flat_ninety(self): + progress, estimate = _channel_parse_progress(100, 0, had_db_baseline=False) + self.assertEqual(estimate, 0) + self.assertEqual(progress, _CHANNEL_PARSE_PROGRESS_START + 1) + self.assertLess(progress, 90) + + progress, _ = _channel_parse_progress(5000, 0, had_db_baseline=False) + self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + def test_partial_progress_when_below_estimate(self): + progress, estimate = _channel_parse_progress(50, 100, had_db_baseline=True) + self.assertEqual(estimate, 100) + self.assertGreater(progress, _CHANNEL_PARSE_PROGRESS_START) + self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP) diff --git a/apps/epg/tests/test_programme_index.py b/apps/epg/tests/test_programme_index.py index 829afc87..b9c42bf9 100644 --- a/apps/epg/tests/test_programme_index.py +++ b/apps/epg/tests/test_programme_index.py @@ -1,7 +1,7 @@ import os import gzip import tempfile -from unittest.mock import patch, MagicMock +from unittest.mock import patch from django.test import TestCase from django.utils import timezone @@ -11,6 +11,7 @@ from apps.epg.tasks import ( find_current_program_for_tvg_id, build_programme_index, build_programme_index_task, + _EPG_TVG_PARSE_DEFER_SECONDS, ) FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -663,38 +664,40 @@ class BuildProgrammeIndexTests(TestCase): build_programme_index(99999) @patch("apps.epg.tasks.build_programme_index") - def test_task_builds_and_releases_lock_when_free(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = True # lock acquired - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): - build_programme_index_task(42) + @patch("apps.epg.tasks.release_task_lock") + @patch("apps.epg.tasks.acquire_task_lock", return_value=True) + def test_task_builds_and_releases_lock_when_free( + self, mock_acquire, mock_release, mock_build + ): + build_programme_index_task(42) mock_build.assert_called_once_with(42) - mock_redis.set.assert_called_once() - self.assertEqual( - mock_redis.set.call_args.args[0], "building_programme_index_42" - ) - mock_redis.delete.assert_called_once_with("building_programme_index_42") + mock_acquire.assert_called_once_with("epg_source_file", 42) + mock_release.assert_called_once_with("epg_source_file", 42) @patch("apps.epg.tasks.build_programme_index") - def test_task_skips_when_lock_held(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = False # another build in flight - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): + @patch("apps.epg.tasks.acquire_task_lock", return_value=False) + def test_task_defers_when_lock_held(self, mock_acquire, mock_build): + with patch("apps.epg.tasks.build_programme_index_task.apply_async") as mock_apply: build_programme_index_task(42) mock_build.assert_not_called() - mock_redis.delete.assert_not_called() + mock_apply.assert_called_once_with( + args=[42], + kwargs={'_defer_retry': 1}, + countdown=_EPG_TVG_PARSE_DEFER_SECONDS, + ) @patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom")) - def test_task_releases_lock_on_failure(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = True - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): - with self.assertRaises(RuntimeError): - build_programme_index_task(42) + @patch("apps.epg.tasks.release_task_lock") + @patch("apps.epg.tasks.acquire_task_lock", return_value=True) + def test_task_releases_lock_on_failure( + self, mock_acquire, mock_release, mock_build + ): + with self.assertRaises(RuntimeError): + build_programme_index_task(42) - mock_redis.delete.assert_called_once_with("building_programme_index_42") + mock_release.assert_called_once_with("epg_source_file", 42) def test_per_channel_interleaved_marking(self): xml = ( diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 462f45db..581a390f 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -26,7 +26,7 @@ from core.utils import ( from core.models import CoreSettings, UserAgent from core.xtream_codes import Client as XCClient from core.utils import send_websocket_update -from .utils import normalize_stream_url +from .utils import convert_js_numbered_backreferences, normalize_stream_url logger = logging.getLogger(__name__) @@ -52,9 +52,9 @@ def _db_query_with_retry(fn, *, label="DB query", max_retries=2): Poisoned Celery worker connections often surface as OperationalError or as ``IndexError: list index out of range`` inside Django's row converters. """ - from django.db import InterfaceError, OperationalError + from django.db import DatabaseError, InterfaceError, OperationalError - transient_errors = (OperationalError, InterfaceError, IndexError) + transient_errors = (OperationalError, InterfaceError, IndexError, DatabaseError) for attempt in range(max_retries): try: return fn() @@ -134,11 +134,23 @@ def _ensure_m3u_refresh_terminal_status(account_id): _EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2') +def _open_m3u_text_source(source_path): + """Open an on-disk M3U (or .m3u.gz) file for line-by-line parsing.""" + if source_path.endswith(".gz"): + return gzip.open(source_path, "rt", encoding="utf-8") + return open(source_path, "r", encoding="utf-8") + + def fetch_m3u_lines(account, use_cache=False): + """Fetch M3U source for parsing. + + On success returns ``(source, True)`` where *source* is either a filesystem + path (streamed during parse) or, for ZIP uploads only, an in-memory line + list. Failures return ``(None, False)``. + """ os.makedirs(m3u_dir, exist_ok=True) file_path = os.path.join(m3u_dir, f"{account.id}.m3u") - """Fetch M3U file lines efficiently.""" if account.server_url: if not use_cache or not os.path.exists(file_path): try: @@ -213,7 +225,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Only call raise_for_status if we have a success code (this should not raise now) response.raise_for_status() @@ -288,7 +300,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Validate the file by reading only the first portion from # disk — no need to load the entire file into memory just @@ -360,7 +372,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except UnicodeDecodeError: with open(temp_path, "rb") as vf: @@ -378,7 +390,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Validation passed — promote temp file to final path os.replace(temp_path, file_path) @@ -433,7 +445,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except requests.exceptions.RequestException as e: # Handle other request errors (connection, timeout, etc.) if "timeout" in str(e).lower(): @@ -454,7 +466,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except Exception as e: # Handle any other unexpected errors error_msg = f"Unexpected error while fetching M3U file from URL: {account.server_url} - {str(e)}" @@ -469,7 +481,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Check if the file exists and is not empty (fallback check - should not happen with new validation) if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: @@ -481,27 +493,14 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False # Return empty list and False for success + return None, False - try: - with open(file_path, "r", encoding="utf-8") as f: - return f.readlines(), True - except Exception as e: - error_msg = f"Error reading M3U file: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account.id, "downloading", 100, status="error", error=error_msg - ) - return [], False + return file_path, True elif account.file_path: try: if account.file_path.endswith(".gz"): - with gzip.open(account.file_path, "rt", encoding="utf-8") as f: - return f.readlines(), True + return account.file_path, True elif account.file_path.endswith(".zip"): with zipfile.ZipFile(account.file_path, "r") as zip_file: @@ -522,11 +521,10 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False + return None, False else: - with open(account.file_path, "r", encoding="utf-8") as f: - return f.readlines(), True + return account.file_path, True except (IOError, OSError, zipfile.BadZipFile, gzip.BadGzipFile) as e: error_msg = f"Error opening file {account.file_path}: {e}" @@ -537,7 +535,7 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False + return None, False # Neither server_url nor uploaded_file is available error_msg = "No M3U source available (missing URL and file)" @@ -546,7 +544,7 @@ def fetch_m3u_lines(account, use_cache=False): account.last_message = error_msg account.save(update_fields=["status", "last_message"]) send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) - return [], False + return None, False def get_case_insensitive_attr(attributes, key, default=""): @@ -746,13 +744,16 @@ def process_groups(account, groups, scan_start_time=None): all_group_objs = existing_group_objs + newly_created_group_objs # Get existing relationships for this account - existing_relationships = { - rel.channel_group.name: rel - for rel in ChannelGroupM3UAccount.objects.filter( - m3u_account=account, - channel_group__name__in=groups.keys() - ).select_related('channel_group') - } + existing_relationships = _db_query_with_retry( + lambda: { + rel.channel_group.name: rel + for rel in ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + channel_group__name__in=groups.keys(), + ).select_related("channel_group") + }, + label=f"process_groups relationships for account {account.id}", + ) relations_to_create = [] relations_to_update = [] @@ -905,6 +906,11 @@ def collect_xc_streams(account_id, enabled_groups): account.get_user_agent(), ) as xc_client: + stream_url_prefix = ( + f"{xc_client.server_url.rstrip('/')}/live/" + f"{xc_client.username}/{xc_client.password}/" + ) + # Fetch ALL live streams in a single API call (much more efficient) logger.info("Fetching ALL live streams from XC provider...") all_xc_streams = xc_client.get_all_live_streams() # Get all streams without category filter @@ -917,46 +923,49 @@ def collect_xc_streams(account_id, enabled_groups): # Filter streams based on enabled categories for stream in all_xc_streams: - # Fall back to a generated name if the provider returns null/empty - stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + category_id = str(stream.get("category_id", "")) + if category_id not in enabled_category_ids: + continue + + group_info = enabled_category_ids[category_id] + stream_name = stream.get("name") or ( + f"{account.name} - {stream.get('stream_id', 'Unknown')}" + ) if not stream.get("name"): logger.warning( - f"XC stream has null/empty name; using generated name '{stream_name}' " - f"(stream_id={stream.get('stream_id', 'unknown')})" + "XC stream has null/empty name; using generated name '%s' " + "(stream_id=%s)", + stream_name, stream.get("stream_id", "unknown"), ) - # Get the category_id for this stream - category_id = str(stream.get("category_id", "")) - - # Only include streams from enabled categories - if category_id in enabled_category_ids: - group_info = enabled_category_ids[category_id] - - # Convert XC stream to our standard format with all properties preserved - stream_data = { - "name": stream_name, - "url": xc_client.get_stream_url(stream["stream_id"]), - "attributes": { - "tvg-id": stream.get("epg_channel_id", ""), - "tvg-logo": stream.get("stream_icon", ""), - "group-title": group_info["name"], - # Preserve all XC stream properties as custom attributes - "stream_id": str(stream.get("stream_id", "")), - "num": stream.get("num"), - "category_id": category_id, - "stream_type": stream.get("stream_type", ""), - "added": stream.get("added", ""), - "is_adult": str(stream.get("is_adult", "0")), - "custom_sid": stream.get("custom_sid", ""), - # Include any other properties that might be present - **{k: str(v) for k, v in stream.items() if k not in [ - "name", "stream_id", "epg_channel_id", "stream_icon", - "category_id", "stream_type", "added", "is_adult", "custom_sid", "num" - ] and v is not None} - } - } - all_streams.append(stream_data) - filtered_count += 1 + stream_id = stream.get("stream_id") + attributes = { + "tvg-id": stream.get("epg_channel_id", ""), + "tvg-logo": stream.get("stream_icon", ""), + "group-title": group_info["name"], + "stream_id": str(stream.get("stream_id", "")), + "num": stream.get("num"), + "category_id": category_id, + "stream_type": stream.get("stream_type", ""), + "added": stream.get("added", ""), + "is_adult": str(stream.get("is_adult", "0")), + "custom_sid": stream.get("custom_sid", ""), + **{ + k: str(v) + for k, v in stream.items() + if k not in [ + "name", "stream_id", "epg_channel_id", "stream_icon", + "category_id", "stream_type", "added", "is_adult", + "custom_sid", "num", + ] and v is not None + }, + } + all_streams.append({ + "name": stream_name, + "url": f"{stream_url_prefix}{stream_id}.ts", + "attributes": attributes, + }) + filtered_count += 1 # Drop the full provider catalog before returning; only filtered rows are needed. del all_xc_streams @@ -971,6 +980,80 @@ def collect_xc_streams(account_id, enabled_groups): ) return all_streams + +def _compile_m3u_stream_filters(filter_queryset): + """Compile account M3UFilter rows once per refresh for batch workers.""" + compiled = [] + for filter_obj in filter_queryset: + flags = ( + re.IGNORECASE + if (filter_obj.custom_properties or {}).get("case_sensitive", True) is False + else 0 + ) + compiled.append((re.compile(filter_obj.regex_pattern, flags), filter_obj)) + return compiled + + +def _stream_passes_m3u_filters(name, url, group_title, compiled_filters): + """Return False when the first matching filter excludes the stream.""" + for pattern, filter_obj in compiled_filters: + logger.trace("Checking filter pattern %s", pattern.pattern) + if filter_obj.filter_type == "url": + target = url + elif filter_obj.filter_type == "group": + target = group_title + else: + target = name + + if pattern.search(target or ""): + logger.debug( + "Stream %s - %s matches filter pattern %s", + name, url, filter_obj.regex_pattern, + ) + return not filter_obj.exclude + return True + + +_STREAM_TOUCH_FIELDS = ("last_seen", "is_stale") +_STREAM_CHANGED_FIELDS = ( + "name", "url", "logo_url", "tvg_id", "custom_properties", "is_adult", + "last_seen", "updated_at", "is_stale", "stream_id", "stream_chno", + "channel_group_id", "is_catchup", "catchup_days", +) + + +def _bulk_update_stream_refresh_batches(changed_streams, touch_streams, *, batch_size): + """Unchanged streams only need last_seen/is_stale; changed rows get the full set.""" + if touch_streams: + Stream.objects.bulk_update( + touch_streams, list(_STREAM_TOUCH_FIELDS), batch_size=batch_size, + ) + if changed_streams: + Stream.objects.bulk_update( + changed_streams, list(_STREAM_CHANGED_FIELDS), batch_size=batch_size, + ) + + +def _batch_stream_count_message(created, updated, unchanged): + """Human-readable batch summary; unchanged = last_seen touch only.""" + return ( + f"{created} created, {updated} updated, {unchanged} unchanged." + ) + + +def _parse_batch_stream_counts(result): + """Extract (created, updated, unchanged) from a batch processing result string.""" + if not isinstance(result, str): + return 0, 0, 0 + try: + created = int(re.search(r"(\d+) created", result).group(1)) + updated = int(re.search(r"(\d+) updated", result).group(1)) + unchanged = int(re.search(r"(\d+) unchanged", result).group(1)) + return created, updated, unchanged + except (AttributeError, ValueError): + return 0, 0, 0 + + def process_xc_category_direct(account_id, batch, groups, hash_keys): from django.db import connections @@ -981,6 +1064,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): streams_to_create = [] streams_to_update = [] + streams_to_touch = [] stream_hashes = {} try: @@ -1053,6 +1137,13 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, account_type='XC', stream_id=provider_stream_id ) + _tv_archive = str(stream.get("tv_archive", "0")) + _is_catchup = _tv_archive in ("1", "True") + try: + _catchup_days = int(stream.get("tv_archive_duration", 0) or 0) + except (TypeError, ValueError): + _catchup_days = 0 + stream_props = { "name": name, "url": url, @@ -1066,6 +1157,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "is_stale": False, "stream_id": provider_stream_id, "stream_chno": stream_chno, + "is_catchup": _is_catchup, + "catchup_days": _catchup_days, } if stream_hash not in stream_hashes: @@ -1080,7 +1173,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days' ) } @@ -1097,7 +1190,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.is_adult != stream_props["is_adult"] or obj.stream_id != stream_props["stream_id"] or obj.stream_chno != stream_props["stream_chno"] or - obj.channel_group_id != stream_props["channel_group_id"] + obj.channel_group_id != stream_props["channel_group_id"] or + obj.is_catchup != stream_props["is_catchup"] or + obj.catchup_days != stream_props["catchup_days"] ) if changed: @@ -1108,11 +1203,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.is_stale = False streams_to_update.append(obj) else: - # Always update last_seen, even if nothing else changed obj.last_seen = timezone.now() obj.is_stale = False - # Don't update updated_at for unchanged streams - streams_to_update.append(obj) + streams_to_touch.append(obj) # Remove from existing_streams since we've processed it del existing_streams[stream_hash] @@ -1129,13 +1222,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): if streams_to_create: Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) - if streams_to_update: - # Simplified bulk update for better performance - Stream.objects.bulk_update( - streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'], - batch_size=150 # Smaller batch size for XC processing - ) + _bulk_update_stream_refresh_batches( + streams_to_update, streams_to_touch, batch_size=150, + ) # Update last_seen for any remaining existing streams that weren't processed if len(existing_streams.keys()) > 0: @@ -1143,7 +1232,14 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): except Exception as e: logger.error(f"Bulk operation failed for XC streams: {str(e)}") - retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + retval = ( + "Batch processed: " + + _batch_stream_count_message( + len(streams_to_create), + len(streams_to_update), + len(streams_to_touch), + ) + ) except Exception as e: logger.error(f"XC category processing error: {str(e)}") @@ -1153,14 +1249,18 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): connections.close_all() # Aggressive garbage collection - del streams_to_create, streams_to_update, stream_hashes, existing_streams + del streams_to_create, streams_to_update, streams_to_touch, stream_hashes, existing_streams gc.collect() return retval -def process_m3u_batch_direct(account_id, batch, groups, hash_keys): - """Processes a batch of M3U streams using bulk operations with thread-safe DB connections.""" +def process_m3u_batch_direct(account_id, batch, groups, hash_keys, compiled_filters=None): + """Processes a batch of M3U streams using bulk operations with thread-safe DB connections. + + ``compiled_filters`` should be pre-built once per account refresh and shared + across batch workers. Pass an empty list when the account has no filters. + """ from django.db import connections # Ensure clean database connections for threading @@ -1168,33 +1268,22 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): account = M3UAccount.objects.get(id=account_id) - compiled_filters = [ - ( - re.compile( - f.regex_pattern, - ( - re.IGNORECASE - if (f.custom_properties or {}).get( - "case_sensitive", True - ) - == False - else 0 - ), - ), - f, - ) - for f in account.filters.order_by("order") - ] + if compiled_filters is None: + compiled_filters = _compile_m3u_stream_filters(account.filters.order_by("order")) streams_to_create = [] streams_to_update = [] + streams_to_touch = [] stream_hashes = {} name_max_length = Stream._meta.get_field('name').max_length logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}") if compiled_filters: - logger.debug(f"Using compiled filters: {[f[1].regex_pattern for f in compiled_filters]}") + logger.debug( + "Using compiled filters: %s", + [filter_obj.regex_pattern for _, filter_obj in compiled_filters], + ) for stream_info in batch: try: name, url = stream_info["name"], stream_info["url"] @@ -1215,25 +1304,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): group_title = get_case_insensitive_attr( stream_info["attributes"], "group-title", "Default Group" ) - logger.debug(f"Processing stream: {name} - {url} in group {group_title}") - include = True - for pattern, filter in compiled_filters: - logger.trace(f"Checking filter pattern {pattern}") - target = name - if filter.filter_type == "url": - target = url - elif filter.filter_type == "group": - target = group_title + logger.trace("Processing stream: %s - %s in group %s", name, url, group_title) - if pattern.search(target or ""): - logger.debug( - f"Stream {name} - {url} matches filter pattern {filter.regex_pattern}" - ) - include = not filter.exclude - break - - if not include: - logger.debug(f"Stream excluded by filter, skipping.") + if compiled_filters and not _stream_passes_m3u_filters( + name, url, group_title, compiled_filters, + ): + logger.debug("Stream excluded by filter, skipping.") continue # Filter out disabled groups for this account @@ -1279,6 +1355,14 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): account_type=account_type_for_hash, stream_id=provider_stream_id ) + _attrs = stream_info["attributes"] + _tv_archive_m3u = str(_attrs.get("tv_archive", "0")) + _is_catchup_m3u = _tv_archive_m3u in ("1", "True") + try: + _catchup_days_m3u = int(_attrs.get("tv_archive_duration", 0) or 0) + except (TypeError, ValueError): + _catchup_days_m3u = 0 + stream_props = { "name": name, "url": url, @@ -1287,11 +1371,13 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "m3u_account": account, "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, - "custom_properties": {**stream_info["attributes"], "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else stream_info["attributes"], - "is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)), + "custom_properties": {**_attrs, "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else _attrs, + "is_adult": parse_is_adult(_attrs.get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": channel_num, + "is_catchup": _is_catchup_m3u, + "catchup_days": _catchup_days_m3u, } if stream_hash not in stream_hashes: @@ -1303,7 +1389,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days' ) } @@ -1320,14 +1406,15 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.is_adult != stream_props["is_adult"] or obj.stream_id != stream_props["stream_id"] or obj.stream_chno != stream_props["stream_chno"] or - obj.channel_group_id != stream_props["channel_group_id"] + obj.channel_group_id != stream_props["channel_group_id"] or + obj.is_catchup != stream_props["is_catchup"] or + obj.catchup_days != stream_props["catchup_days"] ) - # Always update last_seen obj.last_seen = timezone.now() + obj.is_stale = False if changed: - # Only update fields that changed and set updated_at obj.name = stream_props["name"] obj.url = stream_props["url"] obj.logo_url = stream_props["logo_url"] @@ -1337,12 +1424,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.stream_id = stream_props["stream_id"] obj.stream_chno = stream_props["stream_chno"] obj.channel_group_id = stream_props["channel_group_id"] + obj.is_catchup = stream_props["is_catchup"] + obj.catchup_days = stream_props["catchup_days"] obj.updated_at = timezone.now() - - # Always mark as not stale since we saw it in this refresh - obj.is_stale = False - - streams_to_update.append(obj) + streams_to_update.append(obj) + else: + streams_to_touch.append(obj) else: # New stream stream_props["last_seen"] = timezone.now() @@ -1355,23 +1442,26 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): if streams_to_create: Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) - if streams_to_update: - # Update all streams in a single bulk operation - Stream.objects.bulk_update( - streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'], - batch_size=200 - ) + _bulk_update_stream_refresh_batches( + streams_to_update, streams_to_touch, batch_size=200, + ) except Exception as e: logger.error(f"Bulk operation failed: {str(e)}") - retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + retval = ( + f"M3U account: {account_id}, Batch processed: " + + _batch_stream_count_message( + len(streams_to_create), + len(streams_to_update), + len(streams_to_touch), + ) + ) # Clean up database connections for threading connections.close_all() # Free batch data structures (reference-counted deallocation) - del streams_to_create, streams_to_update, stream_hashes, existing_streams + del streams_to_create, streams_to_update, streams_to_touch, stream_hashes, existing_streams gc.collect() return retval @@ -1658,28 +1748,49 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None else: - lines, success = fetch_m3u_lines(account, use_cache) + source, success = fetch_m3u_lines(account, use_cache) if not success: # If fetch failed, don't continue processing lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return f"Failed to fetch M3U data for account_id={account_id}.", None - # Log basic file structure for debugging - logger.debug(f"Processing {len(lines)} lines from M3U file") - valid_stream_count = 0 - for entry in iter_m3u_entries(lines): - valid_stream_count += 1 - group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") - if group_title_attr and group_title_attr not in groups: - logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") - groups[group_title_attr] = {} - extinf_data.append(entry) + if isinstance(source, str): + logger.debug(f"Streaming M3U parse from {source}") + with _open_m3u_text_source(source) as m3u_file: + entry_iter = iter_m3u_entries(m3u_file) + for entry in entry_iter: + valid_stream_count += 1 + group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") + if group_title_attr and group_title_attr not in groups: + logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") + groups[group_title_attr] = {} + extinf_data.append(entry) - if valid_stream_count % 1000 == 0: - logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}") + if valid_stream_count % 1000 == 0: + logger.debug( + f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}" + ) + else: + logger.debug(f"Processing {len(source)} in-memory M3U lines (zip upload)") + try: + for entry in iter_m3u_entries(source): + valid_stream_count += 1 + group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") + if group_title_attr and group_title_attr not in groups: + logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") + groups[group_title_attr] = {} + extinf_data.append(entry) + + if valid_stream_count % 1000 == 0: + logger.debug( + f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}" + ) + finally: + del source + gc.collect() logger.info(f"M3U parsing complete - Valid streams: {valid_stream_count}") @@ -1865,6 +1976,60 @@ def _classify_sync_failure(exc): return "OTHER" +def rollup_channel_catchup_fields(account_id): + """Roll up catch-up flags from streams to channels (active accounts only). + + Both the aggregate update and the self-heal pass are limited to channels + that still have at least one stream from *account_id*. + """ + from django.db import connection + + account_channels = """ + SELECT DISTINCT cs.channel_id + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE s.m3u_account_id = %s + """ + + with connection.cursor() as cur: + cur.execute(f""" + WITH agg AS ( + SELECT + cs.channel_id, + bool_or(s.is_catchup AND a.is_active) AS any_catchup, + MAX(s.catchup_days) FILTER (WHERE s.is_catchup AND a.is_active) AS max_days + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + JOIN m3u_m3uaccount a ON a.id = s.m3u_account_id + WHERE cs.channel_id IN ({account_channels}) + GROUP BY cs.channel_id + ) + UPDATE dispatcharr_channels_channel c + SET + is_catchup = COALESCE(agg.any_catchup, FALSE), + catchup_days = COALESCE(agg.max_days, 0) + FROM agg + WHERE c.id = agg.channel_id + """, [account_id]) + + # Self-heal stale is_catchup flags on account-linked channels only. + cur.execute(f""" + UPDATE dispatcharr_channels_channel c + SET is_catchup = FALSE, catchup_days = 0 + WHERE c.is_catchup = TRUE + AND c.id IN ({account_channels}) + AND NOT EXISTS ( + SELECT 1 + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + JOIN m3u_m3uaccount a ON a.id = s.m3u_account_id + WHERE cs.channel_id = c.id + AND s.is_catchup = TRUE + AND a.is_active = TRUE + ) + """, [account_id]) + + @shared_task def sync_auto_channels(account_id, scan_start_time=None): """ @@ -1882,6 +2047,11 @@ def sync_auto_channels(account_id, scan_start_time=None): from apps.epg.models import EPGData from django.utils import timezone + channel_name_max_len = Channel._meta.get_field("name").max_length + # Per-call cap on the rename substitution; bounds catastrophic + # backtracking on a user-supplied pattern so it cannot hang the worker. + rename_regex_timeout = 0.1 + try: account = M3UAccount.objects.get(id=account_id) logger.info(f"Starting auto channel sync for M3U account {account.name}") @@ -2435,17 +2605,30 @@ def sync_auto_channels(account_id, scan_start_time=None): else "" ) try: - # Convert $1, $2, etc. to \1, \2, etc. for consistency with M3U profiles - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace) - new_name = re.sub( - name_regex_pattern, safe_replace_pattern, original_name + # Use the regex module (not stdlib re) so rename + # patterns match the JS-style semantics the UI + # authors and the preview uses; the timeout bounds + # catastrophic backtracking. + safe_replace_pattern = convert_js_numbered_backreferences(replace) + new_name = regex.sub( + name_regex_pattern, + safe_replace_pattern, + original_name, + timeout=rename_regex_timeout, ) - except re.error as e: + except (regex.error, TimeoutError) as e: logger.warning( f"Regex error for group '{channel_group.name}': {e}. Using original name." ) new_name = original_name + # Channel.name is bounded by the column length; a rename + # that expands past it would otherwise fail the whole + # bulk_create and abort the sync. Cap it so one overlong + # result cannot break the batch, and so the preview (which + # applies the same cap) stays faithful. + new_name = new_name[:channel_name_max_len] + # Check if we already have a channel for this stream existing_channel = existing_channel_map.get(stream.id) @@ -2798,6 +2981,17 @@ def sync_auto_channels(account_id, scan_start_time=None): f"{pack_result['failed']} failed" ) + # Release per-group working sets before the next group iteration. + del ( + current_streams, + logo_cache_by_url, + epg_cache_by_tvg_id, + existing_channel_map, + existing_channels_by_id, + existing_channel_streams, + processed_stream_ids, + ) + # Cleanup mode read from account.custom_properties.orphan_channel_cleanup: # "always" (default; key absent) removes every orphan auto channel; # "preserve_customized" keeps those with a ChannelOverride row; @@ -2833,7 +3027,7 @@ def sync_auto_channels(account_id, scan_start_time=None): f"{channels_created} created, {channels_updated} updated, " f"{channels_deleted} deleted, {channels_failed} failed" ) - return { + result = { "status": "ok", "channels_created": channels_created, "channels_updated": channels_updated, @@ -2841,6 +3035,9 @@ def sync_auto_channels(account_id, scan_start_time=None): "channels_failed": channels_failed, "failed_stream_details": failed_stream_details, } + del failed_stream_details + gc.collect() + return result except Exception as e: logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}") @@ -3208,6 +3405,8 @@ def _refresh_single_m3u_account_impl(account_id): start_time = time.time() # For tracking elapsed time as float streams_created = 0 streams_updated = 0 + streams_unchanged = 0 + streams_stale = 0 streams_deleted = 0 try: @@ -3224,7 +3423,15 @@ def _refresh_single_m3u_account_impl(account_id): ) account = _get_active_m3u_account(account_id) - filters = list(account.filters.all()) + compiled_stream_filters = _compile_m3u_stream_filters( + account.filters.order_by("order") + ) + if compiled_stream_filters: + logger.debug( + "Account %s has %s stream filter(s) for this refresh", + account_id, + len(compiled_stream_filters), + ) # Check if VOD is enabled for this account vod_enabled = ensure_custom_properties_dict(account.custom_properties).get( @@ -3381,6 +3588,7 @@ def _refresh_single_m3u_account_impl(account_id): # Initialize stream counters streams_created = 0 streams_updated = 0 + streams_unchanged = 0 if account.account_type == M3UAccount.Types.STADNARD: logger.debug( @@ -3401,7 +3609,14 @@ def _refresh_single_m3u_account_impl(account_id): with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit batch processing tasks using direct functions (now thread-safe) future_to_batch = { - executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + executor.submit( + process_m3u_batch_direct, + account_id, + batch, + existing_groups, + hash_keys, + compiled_stream_filters, + ): i for i, batch in enumerate(batches) } @@ -3416,17 +3631,13 @@ def _refresh_single_m3u_account_impl(account_id): completed_batches += 1 # Extract stream counts from result - if isinstance(result, str): - try: - created_match = re.search(r"(\d+) created", result) - updated_match = re.search(r"(\d+) updated", result) - if created_match and updated_match: - created_count = int(created_match.group(1)) - updated_count = int(updated_match.group(1)) - streams_created += created_count - streams_updated += updated_count - except (AttributeError, ValueError): - pass + created_count, updated_count, unchanged_count = ( + _parse_batch_stream_counts(result) + ) + if created_count or updated_count or unchanged_count: + streams_created += created_count + streams_updated += updated_count + streams_unchanged += unchanged_count # Send progress update progress = int((completed_batches / total_batches) * 100) @@ -3444,7 +3655,7 @@ def _refresh_single_m3u_account_impl(account_id): progress, elapsed_time=current_elapsed, time_remaining=time_remaining, - streams_processed=streams_created + streams_updated, + streams_processed=streams_created + streams_updated + streams_unchanged, ) logger.debug(f"Thread batch {completed_batches}/{total_batches} completed") @@ -3456,6 +3667,10 @@ def _refresh_single_m3u_account_impl(account_id): batches[batch_idx] = None logger.info(f"Thread-based processing completed for account {account_id}") + + # Parsed catalog is no longer needed; drop before stale cleanup / auto-sync. + del extinf_data, batches + gc.collect() else: # For XC accounts, get the groups with their custom properties containing xc_id logger.debug(f"Processing XC account with groups: {existing_groups}") @@ -3493,8 +3708,26 @@ def _refresh_single_m3u_account_impl(account_id): logger.info("Fetching all XC streams from provider and filtering by enabled categories...") all_xc_streams = collect_xc_streams(account_id, filtered_groups) + del channel_group_relationships, filtered_groups + if not all_xc_streams: - logger.warning("No streams collected from XC groups") + # Empty XC fetch (provider hiccup, fetch error, or no enabled + # category matched) must not fall through to stale-marking and + # auto-sync, which would delete the entire auto-created lineup. + logger.error( + f"No streams collected from XC provider for account " + f"{account_id}; aborting refresh to preserve the existing " + f"channel lineup." + ) + error_msg = "No streams returned from Xtream Codes provider" + _set_m3u_account_status( + account_id, + M3UAccount.Status.ERROR, + error_msg, + notify_error=True, + ws_error=error_msg, + ) + return "Failed to update m3u account, no streams returned from provider" else: # Now batch by stream count (like standard M3U processing) batches = [ @@ -3514,7 +3747,14 @@ def _refresh_single_m3u_account_impl(account_id): with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit stream batch processing tasks (reuse standard M3U processing) future_to_batch = { - executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + executor.submit( + process_m3u_batch_direct, + account_id, + batch, + existing_groups, + hash_keys, + compiled_stream_filters, + ): i for i, batch in enumerate(batches) } @@ -3529,17 +3769,13 @@ def _refresh_single_m3u_account_impl(account_id): completed_batches += 1 # Extract stream counts from result - if isinstance(result, str): - try: - created_match = re.search(r"(\d+) created", result) - updated_match = re.search(r"(\d+) updated", result) - if created_match and updated_match: - created_count = int(created_match.group(1)) - updated_count = int(updated_match.group(1)) - streams_created += created_count - streams_updated += updated_count - except (AttributeError, ValueError): - pass + created_count, updated_count, unchanged_count = ( + _parse_batch_stream_counts(result) + ) + if created_count or updated_count or unchanged_count: + streams_created += created_count + streams_updated += updated_count + streams_unchanged += unchanged_count # Send progress update progress = int((completed_batches / total_batches) * 100) @@ -3557,7 +3793,7 @@ def _refresh_single_m3u_account_impl(account_id): progress, elapsed_time=current_elapsed, time_remaining=time_remaining, - streams_processed=streams_created + streams_updated, + streams_processed=streams_created + streams_updated + streams_unchanged, ) logger.debug(f"XC thread batch {completed_batches}/{total_batches} completed") @@ -3570,6 +3806,9 @@ def _refresh_single_m3u_account_impl(account_id): logger.info(f"XC thread-based processing completed for account {account_id}") + del batches + gc.collect() + # Ensure all database transactions are committed before cleanup logger.info( f"All thread processing completed, ensuring DB transactions are committed before cleanup" @@ -3580,11 +3819,11 @@ def _refresh_single_m3u_account_impl(account_id): ).exists() # This will never find anything but ensures DB sync # Mark streams that weren't seen in this refresh as stale (pending deletion) - stale_stream_count = Stream.objects.filter( + streams_stale = Stream.objects.filter( m3u_account=account, last_seen__lt=refresh_start_timestamp ).update(is_stale=True) - logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}") + logger.info(f"Marked {streams_stale} streams as stale for account {account_id}") # Mark group relationships that weren't seen in this refresh as stale (pending deletion) stale_group_count = ChannelGroupM3UAccount.objects.filter( @@ -3634,17 +3873,24 @@ def _refresh_single_m3u_account_impl(account_id): f"Error running auto channel sync for account {account_id}: {str(e)}" ) + try: + rollup_channel_catchup_fields(account_id) + logger.debug(f"Catch-up field rollup complete for account {account_id}") + except Exception as e: + logger.error(f"Error rolling up catch-up fields for account {account_id}: {str(e)}") + # Calculate elapsed time elapsed_time = time.time() - start_time # Calculate total streams processed - streams_processed = streams_created + streams_updated + streams_processed = streams_created + streams_updated + streams_unchanged # Set status to success and update timestamp BEFORE sending the final update account.status = M3UAccount.Status.SUCCESS account.last_message = ( f"Processing completed in {elapsed_time:.1f} seconds. " - f"Streams: {streams_created} created, {streams_updated} updated, {streams_deleted} removed. " + f"Streams: {streams_created} created, {streams_updated} updated, " + f"{streams_stale} marked stale, {streams_deleted} removed. " f"Total processed: {streams_processed}.{auto_sync_message}" ) account.updated_at = timezone.now() @@ -3657,6 +3903,7 @@ def _refresh_single_m3u_account_impl(account_id): elapsed_time=round(elapsed_time, 2), streams_created=streams_created, streams_updated=streams_updated, + streams_stale=streams_stale, streams_deleted=streams_deleted, total_processed=streams_processed, ) @@ -3672,6 +3919,7 @@ def _refresh_single_m3u_account_impl(account_id): streams_processed=streams_processed, streams_created=streams_created, streams_updated=streams_updated, + streams_stale=streams_stale, streams_deleted=streams_deleted, # Structured auto-sync counts so the frontend can render a # warning card when anything failed, without parsing the @@ -3684,6 +3932,9 @@ def _refresh_single_m3u_account_impl(account_id): message=account.last_message, ) + del auto_sync_result + gc.collect() + # Trigger VOD refresh if enabled and account is XtreamCodes type if vod_enabled and account.account_type == M3UAccount.Types.XC: logger.info(f"VOD is enabled for account {account_id}, triggering VOD refresh") @@ -3715,6 +3966,10 @@ def _refresh_single_m3u_account_impl(account_id): del filtered_groups if 'channel_group_relationships' in locals(): del channel_group_relationships + if 'compiled_stream_filters' in locals(): + del compiled_stream_filters + + gc.collect() # Remove cache file after processing (success or failure) cache_path = os.path.join(m3u_dir, f"{account_id}.json") @@ -3723,8 +3978,6 @@ def _refresh_single_m3u_account_impl(account_id): except OSError: pass - _release_task_db_connection() - return f"Dispatched jobs complete." diff --git a/apps/m3u/tests/test_memory_cleanup.py b/apps/m3u/tests/test_memory_cleanup.py index bfc964d0..b29ca947 100644 --- a/apps/m3u/tests/test_memory_cleanup.py +++ b/apps/m3u/tests/test_memory_cleanup.py @@ -30,7 +30,7 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase): mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") with patch("django.db.connections") as mock_connections: - process_m3u_batch_direct(1, [], {}, ["name", "url"]) + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) mock_connections.close_all.assert_called() @patch("apps.m3u.tasks.Stream") @@ -48,9 +48,29 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase): mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") with patch("gc.collect") as mock_gc, patch("django.db.connections"): - process_m3u_batch_direct(1, [], {}, ["name", "url"]) + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) mock_gc.assert_called() + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_precompiled_empty_filters_skip_db_lookup( + self, mock_account_cls, mock_stream_cls, + ): + """When filters are precompiled as empty, batch workers must not query filters.""" + from apps.m3u.tasks import process_m3u_batch_direct + + mock_account = MagicMock() + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + with patch("django.db.connections"): + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) + + mock_account.filters.order_by.assert_not_called() + class LockReleaseTests(SimpleTestCase): """Verify task lock is released on all exit paths.""" diff --git a/apps/m3u/tests/test_rename_preview_parity.py b/apps/m3u/tests/test_rename_preview_parity.py new file mode 100644 index 00000000..4650c50b --- /dev/null +++ b/apps/m3u/tests/test_rename_preview_parity.py @@ -0,0 +1,212 @@ +""" +Differential parity tests: the auto-sync rename preview must predict the exact +name the live rename produces, across a broad matrix of regex strategies. + +Both the preview and the live rename compile with the third-party `regex` +module (for its JS-aligned syntax and per-call timeout). They can only be +trusted together if, for every find/replace a user might author, the preview's +predicted `after` equals the channel name the sync actually writes. + +Each case is run end-to-end: real streams, the real sync_auto_channels rename, +and the real regex-preview endpoint, compared per original stream name. +""" +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, ChannelGroup, ChannelStream, Stream +from apps.m3u.models import M3UAccount +from apps.m3u.tasks import sync_auto_channels + + +# Diverse names: distinct word-prefixes (collision-resistant), with quality +# tags, brackets, pipes, ampersands, extra whitespace, underscores, dots, CJK, +# and emoji, to exercise anchors, classes, boundaries, and Unicode handling. +NAMES = [ + "Alpha Channel 11", + "Bravo Sports HD", + "Charlie News FHD", + "Delta Movie (2024)", + "Echo [UK] 4K", + "Foxtrot spaced", + "Golf|Pipe|Name", + "Hotel & Inn ", + "India 日本語 77", + "Juliet 📺 88", + "Kilo_under_9", + "Lima.dot.name", +] + +# (find, replace) pairs spanning common user strategies and edge cases. +STRATEGIES = [ + # --- capture groups --- + (r"(.+) Channel (\d+)", r"$1 #$2"), + (r"(\w+) (\w+)", r"$2 $1"), + (r"(.+)", r"$1 - $1"), + (r"(.+)", r"[$1]"), + (r"(.+) (\d+)$", r"$2 $1"), + # --- strip / delete --- + (r" (HD|FHD|4K|SD)\b", r""), + (r"\s+", r" "), + (r"[\[\(].*?[\]\)]", r""), + (r"\d+", r""), + (r"[_.]", r" "), + # --- anchors / inserts --- + (r"^", r"NEW "), + (r"$", r" LIVE"), + (r"^(\w+)", r"<$1>"), + # --- char classes / Unicode (divergence hunters) --- + (r"\w+", r"W"), + (r"\b\w", r"_"), + (r"[A-Z]", r"*"), + (r"\s", r"_"), + (r"[^\x00-\x7F]+", r"?"), + # --- non-capturing / lookaround / in-pattern backref --- + (r"(?:Channel|Movie|News) ", r""), + (r"(\w)\1", r"$1"), + (r"\w+(?= )", r"X"), + (r"(?<=\d)\d", r"#"), + # --- literal $ and odd replacements --- + (r" ", r" $ "), + (r"o", r"0"), + # --- invalid group references (rejected by both engines) --- + (r"(.+)", r"$2"), + (r"(.+)", r"$10"), + # --- rename that expands past Channel.name's column length --- + (r"(.+)", r"$1" * 40), + # --- regex-module syntax: quantified anchor, JS-style and duplicate + # named groups (these transform; both paths use the regex module) --- + (r"^*", r"$"), + (r"(?\d+)", r"S$1"), + (r"(?Px)(?Py)", r"z"), +] + + +class RenamePreviewParityTests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + from rest_framework.test import APIClient + from apps.accounts.models import User + + admin = User.objects.create_superuser( + username="admin_rename_parity", password="pw", user_level=10 + ) + cls.client_api = APIClient() + cls.client_api.force_authenticate(user=admin) + cls.account = M3UAccount.objects.create( + name="Rename Parity Provider", + server_url="http://example.com/test.m3u", + ) + + def _sync(self): + return sync_auto_channels( + self.account.id, + scan_start_time=( + timezone.now() - timezone.timedelta(minutes=1) + ).isoformat(), + ) + + def _run_case(self, group_name, find, replace): + """Returns a list of human-readable mismatch strings (empty == parity).""" + group = ChannelGroup.objects.create(name=group_name) + from apps.channels.models import ChannelGroupM3UAccount + + ChannelGroupM3UAccount.objects.create( + m3u_account=self.account, + channel_group=group, + enabled=True, + auto_channel_sync=True, + auto_sync_channel_start=1000, + custom_properties={ + "name_regex_pattern": find, + "name_replace_pattern": replace, + }, + ) + for i, name in enumerate(NAMES): + Stream.objects.create( + name=name, + url=f"http://example.com/{group_name}_{i}.m3u8", + m3u_account=self.account, + channel_group=group, + tvg_id=f"{group_name}-{i}", + last_seen=timezone.now(), + ) + + # --- live rename via sync --- + result = self._sync() + if result.get("status") != "ok": + return [f"[{find!r} -> {replace!r}] sync status={result.get('status')}"] + + channels = Channel.objects.filter( + auto_created_by=self.account, channel_group=group + ) + cs_rows = ChannelStream.objects.filter( + channel__in=channels + ).select_related("channel", "stream") + sync_map = {row.stream.name: row.channel.name for row in cs_rows} + + # --- preview endpoint --- + response = self.client_api.get( + "/api/channels/streams/regex-preview/", + { + "channel_group": group_name, + "find": find, + "replace": replace, + "limit": 50, + }, + ) + if response.status_code != 200: + return [f"[{find!r} -> {replace!r}] preview HTTP {response.status_code}"] + data = response.data + # When the preview reports find_error it predicts no rename at all. + preview_map = {name: name for name in NAMES} + if "find_error" not in data: + for m in data.get("find_matches", []): + preview_map[m["before"]] = m["after"] + + mismatches = [] + for name in NAMES: + sync_after = sync_map.get(name) + if sync_after is None: + mismatches.append( + f"[{find!r} -> {replace!r}] {name!r}: no channel created" + ) + continue + preview_after = preview_map[name] + if preview_after != sync_after: + mismatches.append( + f"[{find!r} -> {replace!r}] {name!r}: " + f"preview={preview_after!r} sync={sync_after!r} " + f"(find_error={data.get('find_error')!r})" + ) + return mismatches + + def test_preview_predicts_rename_across_strategies(self): + all_mismatches = [] + for idx, (find, replace) in enumerate(STRATEGIES): + all_mismatches.extend( + self._run_case(f"ParityG{idx}", find, replace) + ) + self.assertEqual( + all_mismatches, + [], + "Preview diverged from the live rename:\n" + + "\n".join(all_mismatches), + ) + + def test_overlong_rename_is_bounded_not_aborting_sync(self): + # A rename that expands past Channel.name's column length must not + # abort the bulk_create sync. Both sync and preview cap at the column + # length so the channel is created (truncated) and the preview shows + # the same bounded name. + max_len = Channel._meta.get_field("name").max_length + mismatches = self._run_case("OverlongG", r"(.+)", "$1" * 40) + self.assertEqual(mismatches, [], "\n".join(mismatches)) + + group = ChannelGroup.objects.get(name="OverlongG") + channels = Channel.objects.filter( + auto_created_by=self.account, channel_group=group + ) + self.assertEqual(channels.count(), len(NAMES)) + self.assertTrue(all(len(c.name) <= max_len for c in channels)) + self.assertTrue(any(len(c.name) == max_len for c in channels)) diff --git a/apps/m3u/tests/test_stream_filters.py b/apps/m3u/tests/test_stream_filters.py new file mode 100644 index 00000000..aba1a262 --- /dev/null +++ b/apps/m3u/tests/test_stream_filters.py @@ -0,0 +1,156 @@ +"""Tests for M3U stream filter compilation and batch application.""" +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +from apps.m3u.tasks import ( + _compile_m3u_stream_filters, + _stream_passes_m3u_filters, + process_m3u_batch_direct, +) + + +class CompileM3UStreamFiltersTests(SimpleTestCase): + def test_compiles_case_insensitive_when_configured(self): + filter_obj = MagicMock() + filter_obj.regex_pattern = "news" + filter_obj.custom_properties = {"case_sensitive": False} + + compiled = _compile_m3u_stream_filters([filter_obj]) + + self.assertEqual(len(compiled), 1) + pattern, _ = compiled[0] + self.assertTrue(pattern.search("NEWS")) + + def test_compiles_case_sensitive_by_default(self): + filter_obj = MagicMock() + filter_obj.regex_pattern = "news" + filter_obj.custom_properties = {} + + compiled = _compile_m3u_stream_filters([filter_obj]) + + pattern, _ = compiled[0] + self.assertIsNone(pattern.search("NEWS")) + self.assertTrue(pattern.search("news")) + + +class StreamPassesM3UFiltersTests(SimpleTestCase): + def _compiled(self, *, filter_type="name", exclude=False, pattern="Adult"): + filter_obj = MagicMock() + filter_obj.filter_type = filter_type + filter_obj.exclude = exclude + filter_obj.regex_pattern = pattern + filter_obj.custom_properties = {} + return _compile_m3u_stream_filters([filter_obj]) + + def test_include_filter_passes_matching_stream(self): + compiled = self._compiled(exclude=False) + self.assertTrue( + _stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled) + ) + + def test_include_filter_passes_non_matching_stream(self): + """Non-matching streams still pass unless a matching exclude filter hits.""" + compiled = self._compiled(exclude=False, pattern="news") + self.assertTrue( + _stream_passes_m3u_filters("Sports", "http://x", "Sports", compiled) + ) + + def test_exclude_filter_rejects_matching_stream(self): + compiled = self._compiled(exclude=True, pattern="Adult") + self.assertFalse( + _stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled) + ) + + def test_url_filter_type_targets_url(self): + compiled = self._compiled(filter_type="url", exclude=True, pattern="blocked") + self.assertFalse( + _stream_passes_m3u_filters("OK", "http://blocked.example/live", "News", compiled) + ) + self.assertTrue( + _stream_passes_m3u_filters("blocked name", "http://ok.example/live", "News", compiled) + ) + + def test_group_filter_type_targets_group(self): + compiled = self._compiled(filter_type="group", exclude=True, pattern="Hidden") + self.assertFalse( + _stream_passes_m3u_filters("Channel", "http://x", "Hidden Group", compiled) + ) + + +class ProcessM3UBatchFilterTests(SimpleTestCase): + def _mock_stream_meta(self, mock_stream_cls, max_length=255): + mock_field = MagicMock() + mock_field.max_length = max_length + mock_stream_cls._meta.get_field.return_value = mock_field + + @patch("apps.m3u.tasks._bulk_update_stream_refresh_batches") + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_exclude_filter_skips_stream_import( + self, mock_account_cls, mock_stream_cls, mock_bulk_update, + ): + self._mock_stream_meta(mock_stream_cls) + mock_account = MagicMock() + mock_account.account_type = "STD" + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + filter_obj = MagicMock() + filter_obj.regex_pattern = "skip-me" + filter_obj.filter_type = "name" + filter_obj.exclude = True + filter_obj.custom_properties = {} + compiled = _compile_m3u_stream_filters([filter_obj]) + + batch = [{ + "name": "skip-me channel", + "url": "http://example/live", + "attributes": {"group-title": "News"}, + "vlc_opts": {}, + }] + + with patch("django.db.connections"): + result = process_m3u_batch_direct( + 1, batch, {"News": 1}, ["name", "url"], compiled_filters=compiled, + ) + + self.assertIn("0 created", result) + mock_stream_cls.objects.bulk_create.assert_not_called() + mock_bulk_update.assert_called_once_with([], [], batch_size=200) + + @patch("apps.m3u.tasks._bulk_update_stream_refresh_batches") + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_no_filters_imports_matching_stream( + self, mock_account_cls, mock_stream_cls, mock_bulk_update, + ): + self._mock_stream_meta(mock_stream_cls) + mock_account = MagicMock() + mock_account.account_type = "STD" + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + mock_stream_cls.objects.bulk_create.return_value = [] + + batch = [{ + "name": "News One", + "url": "http://example/live", + "attributes": {"group-title": "News"}, + "vlc_opts": {}, + }] + + with patch("django.db.connections"), patch( + "apps.m3u.tasks.transaction.atomic", + ): + result = process_m3u_batch_direct( + 1, batch, {"News": 1}, ["name", "url"], compiled_filters=[], + ) + + self.assertIn("1 created", result) + mock_stream_cls.objects.bulk_create.assert_called_once() diff --git a/apps/m3u/tests/test_sync_correctness.py b/apps/m3u/tests/test_sync_correctness.py index 8ae09fa8..75bc5d03 100644 --- a/apps/m3u/tests/test_sync_correctness.py +++ b/apps/m3u/tests/test_sync_correctness.py @@ -763,6 +763,128 @@ class RegexPreviewTests(TestCase): self.assertEqual(response.data["total_scanned"], 3) self.assertFalse(response.data["scan_limit_hit"]) + def test_find_replace_applies_numbered_capture_group(self): + # The replace field accepts JS-style $1 backreferences, but the regex + # engine expects \1. Without the conversion the preview echoes the + # literal "$1", so the previewed "after" disagrees with the name the + # live rename produces. + account = self._make_account() + group = _make_group(name="Sports") + Stream.objects.create( + name="High Limit Racing at Eagle @ Jun 9 7:00 PM", + url="http://example.com/hlr.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + ) + client = self._client() + + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Sports", "find": r"(.+) @.*", "replace": "$1"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["find_match_count"], 1) + after = response.data["find_matches"][0]["after"] + self.assertEqual(after, "High Limit Racing at Eagle") + self.assertNotIn("$1", after) + + def test_preview_after_matches_live_sync_rename(self): + # Guards the defect class: the preview and the live rename are + # separate code paths that must convert the replacement identically, + # so the preview can never promise an output the sync would not yield. + name = "High Limit Racing at Eagle @ Jun 9 7:00 PM" + account = self._make_account() + group = _make_group(name="Racing") + _attach_group_to_account( + account, + group, + custom_properties={ + "name_regex_pattern": r"(.+) @.*", + "name_replace_pattern": "$1", + }, + ) + _make_stream(account, group, name=name, tvg_id="hlr") + + result = _sync(account) + self.assertEqual(result.get("status"), "ok") + channel = Channel.objects.get(auto_created=True, auto_created_by=account) + live_name = channel.name + + client = self._client() + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Racing", "find": r"(.+) @.*", "replace": "$1"}, + ) + + self.assertEqual(response.status_code, 200) + preview_after = response.data["find_matches"][0]["after"] + self.assertEqual(preview_after, live_name) + self.assertEqual(preview_after, "High Limit Racing at Eagle") + + def test_regex_engine_pattern_transforms_in_preview(self): + # Both the preview and the live rename use the regex module, which is + # more permissive than stdlib re and matches the JS-style syntax the UI + # authors. A quantified anchor like "^*" (which stdlib re rejects) + # compiles and transforms rather than reporting an error. + account = self._make_account() + group = _make_group(name="Sports") + Stream.objects.create( + name="Doc95", + url="http://example.com/doc95.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + ) + client = self._client() + + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Sports", "find": "^*", "replace": "$"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("find_error", response.data) + self.assertEqual(response.data["find_match_count"], 1) + # ^* matches the empty string at every position, so the literal $ + # replacement is inserted between characters. + self.assertEqual( + response.data["find_matches"][0]["after"], "$D$o$c$9$5$" + ) + + def test_preview_and_sync_agree_on_regex_only_pattern(self): + # Parity guard for the engine alignment: a pattern valid in regex but + # not stdlib re must transform identically in the sync and the preview, + # rather than diverging (the sync no longer silently keeps the + # original name for these patterns). + name = "Doc95" + account = self._make_account() + group = _make_group(name="Docs") + _attach_group_to_account( + account, + group, + custom_properties={ + "name_regex_pattern": "^*", + "name_replace_pattern": "$", + }, + ) + _make_stream(account, group, name=name, tvg_id="doc95") + + result = _sync(account) + self.assertEqual(result.get("status"), "ok") + channel = Channel.objects.get(auto_created=True, auto_created_by=account) + live_name = channel.name + self.assertNotEqual(live_name, name) + + client = self._client() + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Docs", "find": "^*", "replace": "$"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["find_matches"][0]["after"], live_name) + def test_filter_returns_matched_names_with_count(self): account = self._make_account() group = _make_group(name="Sports") diff --git a/apps/m3u/tests/test_xc_empty_fetch_guard.py b/apps/m3u/tests/test_xc_empty_fetch_guard.py new file mode 100644 index 00000000..55cf4576 --- /dev/null +++ b/apps/m3u/tests/test_xc_empty_fetch_guard.py @@ -0,0 +1,131 @@ +""" +Regression test for the Xtream Codes empty-fetch channel wipe. + +Bug: when an XC provider returns no live streams on a routine refresh (a +transient upstream failure, a fetch exception, or no enabled category +matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall +through to stale-marking and ``sync_auto_channels``. With nothing "seen" this +refresh, auto-sync deletes the account's entire auto-created channel lineup. + +Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when +``collect_xc_streams`` returns empty, setting the account to ERROR before any +stale-marking or auto-sync runs, mirroring the standard-path empty guards. +""" +from unittest.mock import MagicMock, patch + +from django.test import TransactionTestCase +from django.utils import timezone + +from apps.channels.models import ( + Channel, + ChannelGroup, + ChannelGroupM3UAccount, + Stream, +) +from apps.m3u.models import M3UAccount +from apps.m3u.tasks import _refresh_single_m3u_account_impl + + +class XCEmptyFetchGuardTests(TransactionTestCase): + def _setup_xc_account_with_auto_channel(self): + account = M3UAccount.objects.create( + name="Test XC Provider", + server_url="http://example.com", + username="user", + password="pass", + account_type=M3UAccount.Types.XC, + is_active=True, + ) + group = ChannelGroup.objects.create(name="Sports") + ChannelGroupM3UAccount.objects.create( + m3u_account=account, + channel_group=group, + enabled=True, + auto_channel_sync=True, + auto_sync_channel_start=100, + custom_properties={"xc_id": "123"}, + ) + # A pre-existing stream and the auto-created channel built from it on a + # prior healthy refresh -- this is exactly what the bug deletes. + stream = Stream.objects.create( + name="ESPN", + url="http://example.com/espn.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + is_stale=False, + ) + channel = Channel.objects.create( + channel_number=100, + name="ESPN", + channel_group=group, + auto_created=True, + auto_created_by=account, + ) + return account, group, stream, channel + + @patch("apps.m3u.tasks.sync_auto_channels") + @patch("apps.m3u.tasks.collect_xc_streams", return_value=[]) + @patch("apps.m3u.tasks.refresh_m3u_groups") + def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels( + self, mock_refresh_groups, _mock_collect, mock_sync + ): + account, group, stream, channel = self._setup_xc_account_with_auto_channel() + # XC refresh: empty extinf_data is normal, groups must be present. + mock_refresh_groups.return_value = ([], {"Sports": group.id}) + + result = _refresh_single_m3u_account_impl(account.id) + + # The refresh aborts, so auto channel sync never runs. + mock_sync.assert_not_called() + # The auto-created channel survives the empty fetch. + self.assertTrue(Channel.objects.filter(pk=channel.pk).exists()) + # The stream is not marked stale (stale-marking is skipped on abort). + stream.refresh_from_db() + self.assertFalse(stream.is_stale) + # The account is surfaced as errored, not silently "successful". + account.refresh_from_db() + self.assertEqual(account.status, M3UAccount.Status.ERROR) + self.assertIn("no streams returned from provider", result) + + @patch("apps.m3u.tasks.log_system_event") + @patch("apps.m3u.tasks.send_m3u_update") + @patch("apps.m3u.tasks.cleanup_stale_group_relationships") + @patch("apps.m3u.tasks.cleanup_streams", return_value=0) + @patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated") + @patch("apps.m3u.tasks.sync_auto_channels") + @patch("apps.m3u.tasks.refresh_m3u_groups") + def test_non_empty_xc_fetch_still_runs_sync( + self, + mock_refresh_groups, + mock_sync, + _mock_process, + _mock_cleanup_streams, + _mock_cleanup_groups, + _mock_ws, + _mock_log, + ): + # The guard must not fire on a healthy refresh: a non-empty fetch + # proceeds to auto channel sync as before. + account, group, _stream, _channel = self._setup_xc_account_with_auto_channel() + mock_refresh_groups.return_value = ([], {"Sports": group.id}) + mock_sync.return_value = { + "status": "ok", + "channels_created": 1, + "channels_updated": 0, + "channels_deleted": 0, + "channels_failed": 0, + "failed_stream_details": [], + } + xc_stream = { + "name": "ESPN", + "url": "http://example.com/espn.m3u8", + "attributes": {"group-title": "Sports", "stream_id": "1"}, + } + + with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]): + _refresh_single_m3u_account_impl(account.id) + + mock_sync.assert_called_once() + account.refresh_from_db() + self.assertEqual(account.status, M3UAccount.Status.SUCCESS) diff --git a/apps/m3u/utils.py b/apps/m3u/utils.py index 598ef713..dbf8d91a 100644 --- a/apps/m3u/utils.py +++ b/apps/m3u/utils.py @@ -1,4 +1,5 @@ # apps/m3u/utils.py +import regex import threading import logging from django.db import models @@ -9,6 +10,18 @@ active_streams_map = {} logger = logging.getLogger(__name__) +def convert_js_numbered_backreferences(replacement): + """Translate JS-style ``$1``/``$2`` backreferences to Python ``\\1``/``\\2``. + + Auto-sync replace patterns are authored in JS regex syntax, but Python's + regex engines honor backslash backreferences, not ``$1``. The live rename + and the UI preview must convert identically, so both call this single + helper and cannot drift apart (otherwise the preview promises an output + the sync would never produce). + """ + return regex.sub(r"\$(\d+)", r"\\\1", replacement) + + def normalize_stream_url(url): """ Normalize stream URLs for compatibility with FFmpeg. diff --git a/apps/output/epg.py b/apps/output/epg.py index 626b21aa..d34d209e 100644 --- a/apps/output/epg.py +++ b/apps/output/epg.py @@ -1088,7 +1088,7 @@ def generate_dummy_epg( return xml_lines -def generate_epg(request, profile_name=None, user=None): +def generate_epg(request, profile_name=None, user=None, *, xc_catchup_prev_days=False): """ Dynamically generate an XMLTV (EPG) file using a streaming response. Since the EPG data is stored independently of Channels, we group programmes @@ -1100,11 +1100,16 @@ def generate_epg(request, profile_name=None, user=None): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - try: - prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) - prev_days = max(0, min(prev_days, 30)) - except (ValueError, TypeError): - prev_days = 0 + if xc_catchup_prev_days: + from apps.channels.utils import resolve_xc_epg_prev_days + + prev_days = resolve_xc_epg_prev_days(request, user) + else: + try: + prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) + prev_days = max(0, min(prev_days, 30)) + except (ValueError, TypeError): + prev_days = 0 use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() cache_params = ( diff --git a/apps/output/tests.py b/apps/output/tests.py index 30b22a18..25a38d18 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -896,3 +896,22 @@ class XcVodSeriesRegressionTests(TestCase): stream = xc_get_vod_streams(self.request, self.user)[0] self.assertEqual(stream["container_extension"], first.container_extension) + + +class GenerateEpgPrevDaysTests(SimpleTestCase): + """Profile EPG keeps legacy prev_days=0 unless URL or user setting says otherwise.""" + + def setUp(self): + self.factory = RequestFactory() + + @patch("apps.output.epg.stream_cached_response") + @patch("apps.output.epg.Channel.objects") + def test_non_xc_epg_defaults_prev_days_to_zero(self, _channels, mock_cache): + from apps.output.epg import generate_epg + + mock_cache.side_effect = lambda cache_key, _source, **_kwargs: cache_key + request = self.factory.get("/epg/") + + cache_key = generate_epg(request, profile_name="test", user=None) + + self.assertIn(":p=0:", cache_key) diff --git a/apps/output/views.py b/apps/output/views.py index 6c4e7c13..a7e34c31 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -11,10 +11,9 @@ from apps.accounts.models import User from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone as dt_timezone import html import time -from tzlocal import get_localzone from urllib.parse import urlencode import base64 import logging @@ -23,6 +22,7 @@ import os from apps.m3u.utils import calculate_tuner_count from apps.proxy.utils import get_user_active_connections import regex +from core.models import CoreSettings from core.utils import log_system_event, build_absolute_uri_with_port import hashlib from apps.output.epg import generate_epg, generate_dummy_programs @@ -368,6 +368,24 @@ def _xc_allowed_output_formats(user): return ['ts', 'mp4'] +def _build_xc_server_info(request, hostname, port): + """Build XC ``server_info``; keep timezone, ``time_now``, and EPG times in UTC. + + XC clients use ``server_info.timezone`` to interpret EPG start/end strings. + Provider-local conversion happens in the timeshift proxy at request time. + """ + # datetime.timezone.utc, not ZoneInfo("UTC"); avoids mis-set Docker /etc/timezone. + return { + "url": hostname, + "server_protocol": request.scheme, + "port": port, + "timezone": "UTC", + "timestamp_now": int(time.time()), + "time_now": datetime.now(dt_timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + "process": True, + } + + def xc_get_info(request, full=False): user = xc_get_user(request) @@ -400,15 +418,7 @@ def xc_get_info(request, full=False): "max_connections": str(max_connections), "allowed_output_formats": _xc_allowed_output_formats(user), }, - "server_info": { - "url": hostname, - "server_protocol": request.scheme, - "port": port, - "timezone": get_localzone().key, - "timestamp_now": int(time.time()), - "time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "process": True, - }, + "server_info": _build_xc_server_info(request, hostname, port), } if full == True: @@ -534,7 +544,7 @@ def xc_xmltv(request): ) return JsonResponse({'error': 'Unauthorized'}, status=401) - return generate_epg(request, None, user) + return generate_epg(request, None, user, xc_catchup_prev_days=True) def xc_get_live_categories(user): @@ -683,6 +693,14 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url effective_logo = channel.effective_logo_obj effective_group = channel.effective_channel_group_obj group_id = effective_group.id if effective_group else _get_default_group_id() + + if channel.is_catchup: + tv_archive = 1 + tv_archive_duration = channel.catchup_days + else: + tv_archive = 0 + tv_archive_duration = 0 + return { "num": channel_num_int, "name": channel.effective_name, @@ -697,10 +715,10 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url "is_adult": int(channel.is_adult), "category_id": str(group_id), "category_ids": [group_id], - "custom_sid": None, - "tv_archive": 0, + "custom_sid": "", + "tv_archive": tv_archive, "direct_source": "", - "tv_archive_duration": 0, + "tv_archive_duration": tv_archive_duration, } @@ -733,10 +751,12 @@ def xc_get_epg(request, user, short=False): if not channel_id: raise Http404() + try: + resolved_channel_id = int(channel_id) + except (TypeError, ValueError): + raise Http404() + channel = None - # Apply effective-value annotation + hidden-exclusion at every channel - # resolution path so a single channel lookup honors the same visibility - # rules as xc_get_live_streams. def _annotate(qs): return with_effective_values(qs, select_related_fks=True).exclude(hidden_from_output=True) @@ -747,7 +767,7 @@ def xc_get_epg(request, user, short=False): if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level filters = { - "id": channel_id, + "id": resolved_channel_id, "user_level__lte": user.user_level } # Hide adult content if user preference is set @@ -757,7 +777,7 @@ def xc_get_epg(request, user, short=False): else: # User has specific limited profiles assigned filters = { - "id": channel_id, + "id": resolved_channel_id, "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() @@ -770,7 +790,7 @@ def xc_get_epg(request, user, short=False): if not channel: raise Http404() else: - channel = _annotate(Channel.objects.filter(id=channel_id).select_related('epg_data__epg_source')).first() + channel = _annotate(Channel.objects.filter(id=resolved_channel_id).select_related('epg_data__epg_source')).first() if not channel: raise Http404() @@ -818,6 +838,8 @@ def xc_get_epg(request, user, short=False): int(channel.effective_channel_number) if channel.effective_channel_number is not None else 0, ) + from apps.channels.utils import resolve_xc_epg_prev_days + limit = int(request.GET.get('limit', 4)) user_custom = user.custom_properties or {} try: @@ -825,12 +847,15 @@ def xc_get_epg(request, user, short=False): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - try: - prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) - prev_days = max(0, min(prev_days, 30)) - except (ValueError, TypeError): - prev_days = 0 + prev_days = resolve_xc_epg_prev_days(request, user, auto_detect_fallback=False) now = django_timezone.now() + + # XC catch-up clients expect past programmes when prev_days was not set. + _channel_is_catchup = getattr(channel, "is_catchup", False) + _channel_catchup_days = min(getattr(channel, "catchup_days", 0) or 0, 30) + if _channel_is_catchup and prev_days == 0: + prev_days = _channel_catchup_days + lookback_cutoff = now - timedelta(days=prev_days) forward_cutoff = now + timedelta(days=num_days) if num_days > 0 else None effective_epg_data = channel.effective_epg_data_obj @@ -876,6 +901,13 @@ def xc_get_epg(request, user, short=False): output = {"epg_listings": []} + if _channel_is_catchup: + archive_window = timedelta(days=_channel_catchup_days) + else: + archive_window = None + + _epg_utc = dt_timezone.utc + for program in programs: title = program['title'] if isinstance(program, dict) else program.title description = program['description'] if isinstance(program, dict) else program.description @@ -900,8 +932,8 @@ def xc_get_epg(request, user, short=False): "epg_id": epg_id, "title": base64.b64encode((title or "").encode()).decode(), "lang": "", - "start": start.strftime("%Y-%m-%d %H:%M:%S"), - "end": end.strftime("%Y-%m-%d %H:%M:%S"), + "start": start.astimezone(_epg_utc).strftime("%Y-%m-%d %H:%M:%S"), + "end": end.astimezone(_epg_utc).strftime("%Y-%m-%d %H:%M:%S"), "description": base64.b64encode((description or "").encode()).decode(), "channel_id": str(channel_num_int), "start_timestamp": str(int(start.timestamp())), @@ -909,10 +941,14 @@ def xc_get_epg(request, user, short=False): "stream_id": f"{channel_id}", } - if short == False: - program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 + if archive_window is not None and end < now and end > now - archive_window: + program_output["has_archive"] = 1 + else: program_output["has_archive"] = 0 + if short == False: + program_output["now_playing"] = 1 if start <= now <= end else 0 + output['epg_listings'].append(program_output) return output diff --git a/apps/proxy/config.py b/apps/proxy/config.py index c7ba5628..48d9fc9b 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -42,7 +42,8 @@ class BaseConfig: "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, } @@ -135,7 +136,13 @@ class TSConfig(BaseConfig): def get_channel_init_grace_period(cls): """Max seconds to wait for initial buffer fill during channel startup.""" settings = cls.get_proxy_settings() - return settings.get("channel_init_grace_period", 5) + return settings.get("channel_init_grace_period", 60) + + @classmethod + def get_channel_client_wait_period(cls): + """Seconds to keep a ready channel alive waiting for the first client to connect.""" + settings = cls.get_proxy_settings() + return settings.get("channel_client_wait_period", 5) # Dynamic property access for these settings @property @@ -154,5 +161,6 @@ class TSConfig(BaseConfig): def CHANNEL_INIT_GRACE_PERIOD(self): return self.get_channel_init_grace_period() - - + @property + def CHANNEL_CLIENT_WAIT_PERIOD(self): + return self.get_channel_client_wait_period() diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 9bd1d284..300f3b0d 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -407,6 +407,20 @@ class ChannelStatus: if channel_name: info['channel_name'] = channel_name + info['is_timeshift'] = bool(metadata.get(ChannelMetadataField.IS_TIMESHIFT)) + + for key, field in ( + ('logo_id', ChannelMetadataField.LOGO_ID), + ('m3u_profile_id', ChannelMetadataField.M3U_PROFILE), + ): + raw = metadata.get(field) + if not raw: + continue + try: + info[key] = int(raw) + except (TypeError, ValueError): + pass + stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID) if stream_id_bytes: try: diff --git a/apps/proxy/live_proxy/config_helper.py b/apps/proxy/live_proxy/config_helper.py index d151a8a8..846b0153 100644 --- a/apps/proxy/live_proxy/config_helper.py +++ b/apps/proxy/live_proxy/config_helper.py @@ -110,6 +110,11 @@ class ConfigHelper: """Max seconds to wait for initial buffer fill during channel startup.""" return Config.get_channel_init_grace_period() + @staticmethod + def channel_client_wait_period(): + """Seconds to keep a ready channel alive waiting for the first client to connect.""" + return Config.get_channel_client_wait_period() + @staticmethod def chunk_timeout(): """ diff --git a/apps/proxy/live_proxy/constants.py b/apps/proxy/live_proxy/constants.py index b3839508..414e1a2f 100644 --- a/apps/proxy/live_proxy/constants.py +++ b/apps/proxy/live_proxy/constants.py @@ -54,6 +54,8 @@ class ChannelMetadataField: STREAM_ID = "stream_id" CHANNEL_NAME = "channel_name" STREAM_NAME = "stream_name" + IS_TIMESHIFT = "is_timeshift" + LOGO_ID = "logo_id" # Profile fields STREAM_PROFILE = "stream_profile" diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index fd54fc24..08a029e3 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -910,6 +910,26 @@ class ProxyServer: delay = ConfigHelper.channel_shutdown_delay() return max(int(delay * 2), 60) + @staticmethod + def _pre_active_no_clients_should_stop(connection_ready_time, start_time, now=None): + """ + Decide whether a pre-active channel with zero clients should be stopped. + + Returns (should_stop, timeout_seconds, reason) where reason is + 'client_wait' (buffer ready, waiting for first viewer) or 'startup' + (still connecting / filling buffer). + """ + now = now if now is not None else time.time() + if connection_ready_time: + elapsed = now - connection_ready_time + timeout = ConfigHelper.channel_client_wait_period() + return elapsed > timeout, timeout, "client_wait" + if start_time: + elapsed = now - start_time + timeout = ConfigHelper.channel_init_grace_period() + return elapsed > timeout, timeout, "startup" + return False, None, None + def _wait_for_shutdown_delay(self, channel_id): """ Wait until shutdown_delay has elapsed since the Redis disconnect @@ -1799,31 +1819,27 @@ class ProxyServer: start_time = connection_attempt_time or init_time if start_time: - # Check which timeout to apply based on channel lifecycle - if connection_ready_time: - # Already reached ready - use shutdown_delay - time_since_ready = time.time() - connection_ready_time - shutdown_delay = ConfigHelper.channel_shutdown_delay() - - if time_since_ready > shutdown_delay: + should_stop, timeout, reason = ( + self._pre_active_no_clients_should_stop( + connection_ready_time, + start_time, + ) + ) + if should_stop: + if reason == "client_wait": + time_since_ready = time.time() - connection_ready_time logger.warning( f"Channel {channel_id} in {channel_state} state with 0 clients for {time_since_ready:.1f}s " - f"(after reaching ready, shutdown_delay: {shutdown_delay}s) - stopping channel" + f"(buffer ready, no client connected, client_wait_period: {timeout}s) - stopping channel" ) - self._coordinated_stop_channel(channel_id) - continue - else: - # Never reached ready - use grace_period timeout - time_since_start = time.time() - start_time - connecting_timeout = ConfigHelper.channel_init_grace_period() - - if time_since_start > connecting_timeout: + else: + time_since_start = time.time() - start_time logger.warning( f"Channel {channel_id} stuck in {channel_state} state for {time_since_start:.1f}s " - f"with no clients (timeout: {connecting_timeout}s) - stopping channel due to upstream issues" + f"with no clients (timeout: {timeout}s) - stopping channel due to upstream issues" ) - self._coordinated_stop_channel(channel_id) - continue + self._coordinated_stop_channel(channel_id) + continue elif ( channel_state == ChannelState.WAITING_FOR_CLIENTS and total_clients > 0 @@ -2079,6 +2095,9 @@ class ProxyServer: if not channel_id: continue + if channel_id.startswith("timeshift_"): + continue + # Get metadata first metadata = self.redis_client.hgetall(key) if not metadata: diff --git a/apps/proxy/live_proxy/tests/test_proxy_settings.py b/apps/proxy/live_proxy/tests/test_proxy_settings.py new file mode 100644 index 00000000..e821f1cb --- /dev/null +++ b/apps/proxy/live_proxy/tests/test_proxy_settings.py @@ -0,0 +1,143 @@ +"""Tests for proxy settings defaults, serializer validation, and migration 0026.""" + +from importlib import import_module +from unittest.mock import patch + +from django.apps import apps +from django.test import SimpleTestCase, TestCase + +from apps.proxy.config import TSConfig +from core.models import CoreSettings +from core.serializers import ProxySettingsSerializer + +MIGRATION_0026 = import_module("core.migrations.0026_add_channel_client_wait_period") + + +class TSConfigProxySettingsDefaultsTests(SimpleTestCase): + @patch.object(TSConfig, "get_proxy_settings", return_value={}) + def test_channel_init_grace_period_default(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_init_grace_period(), 60) + + @patch.object(TSConfig, "get_proxy_settings", return_value={}) + def test_channel_client_wait_period_default(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_client_wait_period(), 5) + + @patch.object( + TSConfig, + "get_proxy_settings", + return_value={ + "channel_init_grace_period": 120, + "channel_client_wait_period": 15, + }, + ) + def test_settings_override_db_values(self, _mock_settings): + self.assertEqual(TSConfig.get_channel_init_grace_period(), 120) + self.assertEqual(TSConfig.get_channel_client_wait_period(), 15) + + +class ProxySettingsSerializerTests(SimpleTestCase): + def _valid_payload(self, **overrides): + payload = { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, + "new_client_behind_seconds": 5, + } + payload.update(overrides) + return payload + + def test_accepts_new_client_wait_period(self): + serializer = ProxySettingsSerializer(data=self._valid_payload()) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data["channel_client_wait_period"], 5) + + def test_init_grace_period_allows_up_to_300(self): + serializer = ProxySettingsSerializer( + data=self._valid_payload(channel_init_grace_period=300) + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_init_grace_period_rejects_above_300(self): + serializer = ProxySettingsSerializer( + data=self._valid_payload(channel_init_grace_period=301) + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("channel_init_grace_period", serializer.errors) + + +class CoreSettingsProxyDefaultsTests(TestCase): + def test_get_proxy_settings_defaults_when_missing(self): + CoreSettings.objects.filter(key="proxy_settings").delete() + defaults = CoreSettings.get_proxy_settings() + self.assertEqual(defaults["channel_init_grace_period"], 60) + self.assertEqual(defaults["channel_client_wait_period"], 5) + + +class Migration0026ProxySettingsTests(TestCase): + def _run_migration_forward(self): + MIGRATION_0026.add_channel_client_wait_period(apps, None) + + def _set_proxy_settings(self, value): + settings_obj, _ = CoreSettings.objects.get_or_create( + key="proxy_settings", + defaults={"name": "Proxy Settings", "value": value}, + ) + settings_obj.value = value + settings_obj.save(update_fields=["value"]) + return settings_obj + + def test_bumps_legacy_init_grace_and_adds_client_wait(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 60) + self.assertEqual(settings_obj.value["channel_client_wait_period"], 5) + + def test_bumps_init_grace_below_new_default(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 45, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 60) + + def test_preserves_init_grace_at_or_above_new_default(self): + settings_obj = self._set_proxy_settings( + { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 90, + "new_client_behind_seconds": 5, + } + ) + + self._run_migration_forward() + settings_obj.refresh_from_db() + + self.assertEqual(settings_obj.value["channel_init_grace_period"], 90) + self.assertEqual(settings_obj.value["channel_client_wait_period"], 5) diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index 69ee15e4..fc157791 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -1,6 +1,7 @@ import logging from core.utils import RedisClient from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, get_vod_client_stop_key +from apps.proxy.live_proxy.redis_keys import RedisKeys from core.models import CoreSettings from apps.proxy.live_proxy.services.channel_service import ChannelService @@ -61,6 +62,15 @@ def attempt_stream_termination(user_id, requesting_client_id, active_connections result = ChannelService.stop_client(t['media_id'], t['client_id']) if result.get("status") == "error": logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}") + elif t['type'] == 'timeshift': + # Same Redis stop key as live; timeshift generator polls it every 5s. + redis_client = RedisClient.get_client() + if not redis_client: + # Deny the new stream if we cannot stop the old one. + return False + stop_key = RedisKeys.client_stop(t['media_id'], t['client_id']) + redis_client.setex(stop_key, 60, "true") + logger.info(f"[stream limits][{requesting_client_id}] Set stop key for timeshift client {t['client_id']}") else: connection_manager = MultiWorkerVODConnectionManager.get_instance() redis_client = connection_manager.redis_client @@ -106,13 +116,14 @@ def get_user_active_connections(user_id): if user_id is None or (client_user_id and int(client_user_id) == user_id): try: - logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}") + conn_type = 'timeshift' if channel_id.startswith('timeshift_') else 'live' + logger.debug(f"[stream limits] Found {conn_type.upper()} connection for user {user_id} on channel {channel_id} with client ID {client_id}") connected_at = float(connected_at) if connected_at else 0 connections.append({ 'media_id': channel_id, 'client_id': client_id, 'connected_at': connected_at, - 'type': 'live', + 'type': conn_type, }) except (ValueError, TypeError): pass @@ -172,6 +183,22 @@ def check_user_stream_limits(user, client_id, media_id=None): logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)") return True + # Timeshift sibling range/probe requests share one provider slot per + # session_id. Each distinct client/session still consumes its own slot. + if ignore_same_channel and media_id: + media_id_str = str(media_id) + for conn in active_connections: + if conn.get('type') != 'timeshift': + continue + if conn.get('client_id') != client_id: + continue + conn_media_id = str(conn.get('media_id') or '') + if conn_media_id == media_id_str or conn_media_id.startswith(f"{media_id_str}_"): + logger.debug( + f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed (ignore_same_channel=True)" + ) + return True + if user_stream_count >= user.stream_limit: if user_limit_settings.get("terminate_on_limit_exceeded", True) == False: return False @@ -186,3 +213,28 @@ def check_user_stream_limits(user, client_id, media_id=None): return False return True + + +_TS_PACKET_SIZE = 188 +_TS_SYNC_BYTE = 0x47 + + +def find_ts_sync(buf): + """Return byte offset of the first valid MPEG-TS sync chain in *buf*, or -1. + + Args: + buf: Raw bytes from an upstream HTTP response (typically the first 1 KB). + + Returns: + Offset of the first 0x47 byte that starts three consecutive 188-byte + packets, or -1. Used to strip PHP/HTML preamble before streaming. + """ + end = len(buf) - 2 * _TS_PACKET_SIZE + for i in range(0, end): + if ( + buf[i] == _TS_SYNC_BYTE + and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE + and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE + ): + return i + return -1 diff --git a/apps/timeshift/__init__.py b/apps/timeshift/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timeshift/apps.py b/apps/timeshift/apps.py new file mode 100644 index 00000000..e4ecbfba --- /dev/null +++ b/apps/timeshift/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TimeshiftConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.timeshift" + verbose_name = "Timeshift" diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py new file mode 100644 index 00000000..98d10c36 --- /dev/null +++ b/apps/timeshift/helpers.py @@ -0,0 +1,252 @@ +"""URL builders and timestamp helpers for XC catch-up.""" + +import logging +import re +from collections import namedtuple +from datetime import datetime, timezone +from urllib.parse import quote +from zoneinfo import ZoneInfo + +logger = logging.getLogger(__name__) + +# Credentials for the profile whose pool slot was reserved (not raw account fields). +TimeshiftCredentials = namedtuple( + "TimeshiftCredentials", ("server_url", "username", "password") +) + +DEFAULT_DURATION_MINUTES = 120 +DURATION_BUFFER_MINUTES = 5 +MAX_DURATION_MINUTES = 480 + +# Wall-clock shapes seen from XC / iPlayTV / TiviMate clients. Compiled once. +_CATCHUP_WALL_CLOCK_RE = re.compile( + r"^" + r"(?P\d{4}-\d{2}-\d{2})" + r"(?P[:_]| )" + r"(?P\d{2})" + r"(?P[-:])" + r"(?P\d{2})" + r"(?:" + r":" + r"(?P\d{2})" + r")?" + r"$" +) + + +def normalize_catchup_timestamp_input(timestamp_str): + """Map a client catch-up timestamp to an ISO-8601 string for ``fromisoformat``. + + Supported inputs: + - ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate colon-dash) + - ``YYYY-MM-DD_HH-MM`` (XC underscore) + - ``YYYY-MM-DD:HH:MM[:SS]`` (XC colon time in catch-up URLs) + - ``YYYY-MM-DD HH:MM[:SS]`` (EPG / SQL datetime) + - Unix epoch seconds (10 digits) or milliseconds (13 digits) + + Returns: + An ISO-8601 date-time string (``YYYY-MM-DDTHH:MM:SS``), or None if + the value does not match a known catch-up shape. + """ + if timestamp_str is None: + return None + if not isinstance(timestamp_str, str): + timestamp_str = str(timestamp_str) + value = timestamp_str.strip() + if not value: + return None + + if value.isdigit(): + length = len(value) + if length == 10: + dt = datetime.fromtimestamp(int(value), tz=timezone.utc) + return dt.replace(tzinfo=None).isoformat(timespec="seconds") + if length == 13: + dt = datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc) + return dt.replace(tzinfo=None).isoformat(timespec="seconds") + return None + + match = _CATCHUP_WALL_CLOCK_RE.match(value) + if not match: + return None + + parts = match.groupdict() + second = parts["second"] or "00" + return f"{parts['date']}T{parts['hour']}:{parts['minute']}:{second}" + + +def parse_catchup_timestamp(timestamp_str): + """Parse a catch-up timestamp string into a naive UTC wall-clock datetime. + + See ``normalize_catchup_timestamp_input`` for supported input shapes. + + Returns: + A naive datetime on success, or None. + """ + iso_value = normalize_catchup_timestamp_input(timestamp_str) + if iso_value is None: + if timestamp_str is not None and str(timestamp_str).strip(): + logger.debug( + "Timeshift: unrecognised catch-up timestamp: %r", timestamp_str + ) + return None + try: + return datetime.fromisoformat(iso_value) + except ValueError: + logger.debug( + "Timeshift: invalid catch-up timestamp after normalize: %r -> %r", + timestamp_str, + iso_value, + ) + return None + + +def _reshape_timestamp(timestamp, strftime_fmt, label): + dt = parse_catchup_timestamp(timestamp) + if dt is None: + logger.error( + "Timeshift %s reshape failed for %r: unrecognised format", label, timestamp + ) + return timestamp + return dt.strftime(strftime_fmt) + + +def convert_timestamp_to_provider_tz(timestamp_str, provider_tz_name): + """Convert a UTC catch-up timestamp to the provider's local zone. + + Args: + timestamp_str: UTC wall-clock in ``YYYY-MM-DD:HH-MM`` or underscore form. + provider_tz_name: IANA zone from the provider's ``server_info.timezone`` + (e.g. ``Europe/Brussels``). Falsy, ``UTC``, or unknown: no conversion. + + Returns: + ``YYYY-MM-DD:HH-MM`` in the provider zone, or the input unchanged on skip/failure. + """ + if not provider_tz_name or provider_tz_name == "UTC": + return timestamp_str + dt = parse_catchup_timestamp(timestamp_str) + if dt is None: + return timestamp_str + try: + target = ZoneInfo(provider_tz_name) + except Exception: + logger.warning( + "Timeshift: unknown provider timezone %r, no conversion applied", + provider_tz_name, + ) + return timestamp_str + # timezone.utc, not ZoneInfo("UTC"): avoids mis-set Docker /etc/timezone. + local_dt = dt.replace(tzinfo=timezone.utc).astimezone(target) + return local_dt.strftime("%Y-%m-%d:%H-%M") + + +def get_programme_duration(channel, timestamp_str): + """Look up catch-up duration in minutes from EPG. + + Args: + channel: Channel with optional ``epg_data`` relation loaded. + timestamp_str: Programme start in UTC (same shape as the client URL). + + Returns: + Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``, + or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails. + """ + try: + dt = parse_catchup_timestamp(timestamp_str) + if dt is None: + return DEFAULT_DURATION_MINUTES + # EPG times are timezone-aware; parsed value must be too. + dt = dt.replace(tzinfo=timezone.utc) + if not channel.epg_data: + return DEFAULT_DURATION_MINUTES + + programme = channel.epg_data.programs.filter( + start_time__lte=dt, end_time__gt=dt + ).first() + if not programme: + return DEFAULT_DURATION_MINUTES + + duration_seconds = (programme.end_time - programme.start_time).total_seconds() + duration_minutes = int(duration_seconds / 60) + DURATION_BUFFER_MINUTES + return min(duration_minutes, MAX_DURATION_MINUTES) + except Exception: + return DEFAULT_DURATION_MINUTES + + +def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes): + """QUERY layout: ``/streaming/timeshift.php?username=...&start=...``""" + return ( + f"{creds.server_url.rstrip('/')}/streaming/timeshift.php" + f"?username={quote(str(creds.username), safe='')}" + f"&password={quote(str(creds.password), safe='')}" + f"&stream={stream_id}" + f"&start={timestamp}" + f"&duration={duration_minutes}" + ) + + +def build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes): + """PATH layout: ``/timeshift/{user}/{pass}/{dur}/{start}/{id}.ts``""" + return ( + f"{creds.server_url.rstrip('/')}/timeshift" + f"/{quote(str(creds.username), safe='')}" + f"/{quote(str(creds.password), safe='')}" + f"/{duration_minutes}" + f"/{timestamp}" + f"/{stream_id}.ts" + ) + + +def build_timeshift_candidate_urls(creds, stream_id, timestamp, duration_minutes): + """Build ordered upstream URL candidates (PATH forms first, QUERY last). + + Args: + creds: ``TimeshiftCredentials`` for the reserved profile. + stream_id: Provider stream id from the catch-up stream's custom properties. + timestamp: Already converted to the serving provider's local zone. + duration_minutes: Archive window length passed to the provider. + + Returns: + List of URL strings to try in order. QUERY forms are last because some + providers return live TV even when ``start`` is set. + """ + dt = parse_catchup_timestamp(timestamp) + if dt is None: + colon_dash_ts = timestamp + underscore_ts = timestamp + colon_seconds_ts = timestamp + sql_ts = timestamp + else: + colon_dash_ts = dt.strftime("%Y-%m-%d:%H-%M") + underscore_ts = dt.strftime("%Y-%m-%d_%H-%M") + colon_seconds_ts = dt.strftime("%Y-%m-%d:%H:%M:%S") + sql_ts = dt.strftime("%Y-%m-%d %H:%M:%S") + return [ + build_timeshift_url_format_b(creds, stream_id, colon_dash_ts, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, colon_seconds_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, sql_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, colon_dash_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, colon_seconds_ts, duration_minutes), + ] + + +def format_timestamp_as_colon_dash(timestamp): + """Reshape to ``YYYY-MM-DD:HH-MM`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d:%H-%M", "colon-dash") + + +def format_timestamp_as_colon_seconds(timestamp): + """Reshape to ``YYYY-MM-DD:HH:MM:SS`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d:%H:%M:%S", "colon-seconds") + + +def format_timestamp_as_underscore(timestamp): + """Reshape to ``YYYY-MM-DD_HH-MM`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d_%H-%M", "underscore") + + +def format_timestamp_as_sql_datetime(timestamp): + """Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL") diff --git a/apps/timeshift/tests/__init__.py b/apps/timeshift/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py new file mode 100644 index 00000000..4bd7e0e3 --- /dev/null +++ b/apps/timeshift/tests/test_helpers.py @@ -0,0 +1,325 @@ +"""Tests for `apps.timeshift.helpers`: timestamp shape conversion and URL build.""" + +from datetime import datetime, timezone + +from django.test import TestCase + +from apps.timeshift.helpers import ( + TimeshiftCredentials, + build_timeshift_candidate_urls, + build_timeshift_url_format_a, + build_timeshift_url_format_b, + convert_timestamp_to_provider_tz, + format_timestamp_as_colon_dash, + format_timestamp_as_colon_seconds, + format_timestamp_as_sql_datetime, + format_timestamp_as_underscore, + normalize_catchup_timestamp_input, + parse_catchup_timestamp, +) + + +def _make_creds(): + # The builders consume resolved per-profile credentials, never an account + # object — get_transformed_credentials() produces these in the view. + return TimeshiftCredentials("http://example.test", "user", "pass") + + +class TimestampFormatTests(TestCase): + """Timestamp reshape functions change format only; no timezone conversion.""" + + def test_normalize_colon_dash_shape(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-05-21:12-55"), + "2026-05-21T12:55:00", + ) + + def test_normalize_colon_seconds_xc_format(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-06-23:04:00:00"), + "2026-06-23T04:00:00", + ) + + def test_normalize_epg_sql_format(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-06-23 04:00:00"), + "2026-06-23T04:00:00", + ) + + def test_normalize_unix_epoch_seconds(self): + epoch = str(int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + normalize_catchup_timestamp_input(epoch), + "2026-06-23T04:00:00", + ) + + def test_normalize_unix_epoch_milliseconds(self): + epoch_ms = str( + int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp() * 1000) + ) + self.assertEqual( + normalize_catchup_timestamp_input(epoch_ms), + "2026-06-23T04:00:00", + ) + + def test_normalize_rejects_garbage(self): + self.assertIsNone(normalize_catchup_timestamp_input("garbage")) + self.assertIsNone(normalize_catchup_timestamp_input("")) + self.assertIsNone(normalize_catchup_timestamp_input("12345")) + + def test_parse_rejects_invalid_calendar_date(self): + self.assertIsNone(parse_catchup_timestamp("2026-13-45:04-00")) + + def test_parse_colon_dash_format(self): + dt = parse_catchup_timestamp("2026-05-21:12-55") + self.assertEqual(dt, datetime(2026, 5, 21, 12, 55, 0)) + + def test_parse_underscore_format(self): + dt = parse_catchup_timestamp("2026-05-21_12-55") + self.assertEqual(dt, datetime(2026, 5, 21, 12, 55, 0)) + + def test_parse_colon_minutes_without_seconds(self): + dt = parse_catchup_timestamp("2026-06-23:04:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_parse_colon_seconds_xc_format(self): + dt = parse_catchup_timestamp("2026-06-23:04:00:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_parse_epg_sql_format(self): + dt = parse_catchup_timestamp("2026-06-23 04:00:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_format_colon_dash_from_colon_seconds(self): + self.assertEqual( + format_timestamp_as_colon_dash("2026-06-23:04:00:00"), + "2026-06-23:04-00", + ) + + def test_format_colon_seconds_from_colon_dash(self): + self.assertEqual( + format_timestamp_as_colon_seconds("2026-06-23:04-00"), + "2026-06-23:04:00:00", + ) + + def test_format_colon_seconds_from_unix_epoch(self): + epoch = str(int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + format_timestamp_as_colon_dash(epoch), + "2026-06-23:04-00", + ) + + def test_format_sql_reshapes_without_tz_conversion(self): + self.assertEqual( + format_timestamp_as_sql_datetime("2026-05-12:17-00"), + "2026-05-12 17:00:00", + ) + + def test_format_sql_accepts_underscore_input(self): + self.assertEqual( + format_timestamp_as_sql_datetime("2026-05-12_17-00"), + "2026-05-12 17:00:00", + ) + + def test_format_sql_invalid_falls_back(self): + self.assertEqual(format_timestamp_as_sql_datetime("garbage"), "garbage") + + def test_format_underscore_from_colon_dash(self): + self.assertEqual( + format_timestamp_as_underscore("2026-05-21:12-55"), + "2026-05-21_12-55", + ) + + def test_format_underscore_idempotent(self): + # Underscore input → underscore output (no change) + self.assertEqual( + format_timestamp_as_underscore("2026-05-21_12-55"), + "2026-05-21_12-55", + ) + + def test_format_underscore_invalid_falls_back(self): + self.assertEqual(format_timestamp_as_underscore("garbage"), "garbage") + + +class BuildTimeshiftUrlTests(TestCase): + def setUp(self): + self.creds = _make_creds() + + def test_format_a_passes_dash_shape_unchanged(self): + url = build_timeshift_url_format_a( + self.creds, "22372", "2026-05-12:19-00", 40 + ) + self.assertIn("start=2026-05-12:19-00", url) + self.assertIn("stream=22372", url) + self.assertIn("duration=40", url) + + def test_format_a_passes_sql_shape_unchanged(self): + url = build_timeshift_url_format_a( + self.creds, "22372", "2026-05-12 19:00:00", 40 + ) + self.assertIn("start=2026-05-12 19:00:00", url) + + def test_format_b_path_with_dash_shape(self): + url = build_timeshift_url_format_b( + self.creds, "22372", "2026-05-12:19-00", 40 + ) + self.assertIn("/40/2026-05-12:19-00/22372.ts", url) + + +class CandidateOrderingTests(TestCase): + """`build_timeshift_candidate_urls` must try the PATH form (which seeks the + archive) before the QUERY form (which returns LIVE on some providers, + silently ignoring the requested timestamp). Regression guard for the + "catch-up plays the live stream instead of the requested programme" bug.""" + + def setUp(self): + self.creds = _make_creds() + + def _is_path_form(self, url): + return "/timeshift/" in url and url.endswith(".ts") and "timeshift.php" not in url + + def _is_query_form(self, url): + return "timeshift.php?" in url + + def test_every_path_candidate_precedes_every_query_candidate(self): + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12:19-00", 40) + path_indices = [i for i, u in enumerate(urls) if self._is_path_form(u)] + query_indices = [i for i, u in enumerate(urls) if self._is_query_form(u)] + # Each URL is classified as exactly one form. + self.assertEqual(len(path_indices) + len(query_indices), len(urls)) + self.assertTrue(path_indices and query_indices) + # The last PATH candidate still comes before the first QUERY candidate. + self.assertLess(max(path_indices), min(query_indices)) + + def test_first_candidate_is_path_form_with_canonical_dash_timestamp(self): + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12:19-00", 40) + self.assertTrue(self._is_path_form(urls[0])) + # Canonical colon-dash timestamp, passed through unchanged. + self.assertIn("/40/2026-05-12:19-00/22372.ts", urls[0]) + + def test_accepts_colon_seconds_input_timestamp(self): + urls = build_timeshift_candidate_urls( + self.creds, "22372", "2026-06-23:04:00:00", 40 + ) + self.assertTrue(self._is_path_form(urls[0])) + self.assertIn("/40/2026-06-23:04-00/22372.ts", urls[0]) + self.assertIn("/40/2026-06-23:04:00:00/22372.ts", urls[2]) + + def test_accepts_underscore_input_timestamp(self): + # Client may send the underscore shape; PATH form still leads. + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12_19-00", 40) + self.assertTrue(self._is_path_form(urls[0])) + + +class ConvertTimestampToProviderTzTests(TestCase): + """`convert_timestamp_to_provider_tz` shifts a UTC catch-up timestamp into the + serving provider's local zone (XC providers index archives in their own zone), + DST-correct, and is a no-op when the zone is UTC/unknown/missing.""" + + def test_utc_to_brussels_summer_is_plus_two(self): + # June → CEST (+02:00): 17:00 UTC == 19:00 Brussels (the 19h JT case). + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "Europe/Brussels"), + "2026-06-08:19-00", + ) + + def test_utc_to_brussels_winter_is_plus_one(self): + # January → CET (+01:00): 17:00 UTC == 18:00 Brussels (DST handled). + self.assertEqual( + convert_timestamp_to_provider_tz("2026-01-08:17-00", "Europe/Brussels"), + "2026-01-08:18-00", + ) + + def test_day_rollover(self): + # 23:30 UTC + 2h (CEST) crosses midnight into the next day. + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:23-30", "Europe/Brussels"), + "2026-06-09:01-30", + ) + + def test_underscore_input_returns_colon_dash(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08_17-00", "Europe/Brussels"), + "2026-06-08:19-00", + ) + + def test_utc_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "UTC"), + "2026-06-08:17-00", + ) + + def test_none_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", None), + "2026-06-08:17-00", + ) + + def test_unknown_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "Mars/Phobos"), + "2026-06-08:17-00", + ) + + def test_utc_to_brussels_from_unix_epoch(self): + epoch = str(int(datetime(2026, 6, 8, 17, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + convert_timestamp_to_provider_tz(epoch, "Europe/Brussels"), + "2026-06-08:19-00", + ) + + def test_garbage_timestamp_passthrough(self): + self.assertEqual( + convert_timestamp_to_provider_tz("garbage", "Europe/Brussels"), + "garbage", + ) + + +class GetProgrammeDurationTests(TestCase): + """Duration window resolution: programme length + buffer, capped, with a + safe default whenever the EPG lookup cannot resolve.""" + + def _channel_with_programme(self, minutes): + from datetime import datetime, timedelta, timezone as dt_timezone + from unittest.mock import MagicMock + + start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc) + programme = MagicMock( + start_time=start, end_time=start + timedelta(minutes=minutes) + ) + channel = MagicMock() + channel.epg_data.programs.filter.return_value.first.return_value = programme + return channel + + def test_duration_is_programme_length_plus_buffer(self): + from apps.timeshift.helpers import get_programme_duration + # 40-minute programme + 5-minute buffer. + self.assertEqual( + get_programme_duration(self._channel_with_programme(40), "2026-06-08:17-00"), + 45, + ) + + def test_duration_capped_at_max(self): + from apps.timeshift.helpers import get_programme_duration + self.assertEqual( + get_programme_duration(self._channel_with_programme(1000), "2026-06-08:17-00"), + 480, + ) + + def test_no_epg_data_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + channel = MagicMock(epg_data=None) + self.assertEqual(get_programme_duration(channel, "2026-06-08:17-00"), 120) + + def test_no_matching_programme_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + channel = MagicMock() + channel.epg_data.programs.filter.return_value.first.return_value = None + self.assertEqual(get_programme_duration(channel, "2026-06-08:17-00"), 120) + + def test_garbage_timestamp_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py new file mode 100644 index 00000000..1e45f53b --- /dev/null +++ b/apps/timeshift/tests/test_views.py @@ -0,0 +1,2050 @@ +"""Tests for the timeshift proxy view, focused on upstream status mapping.""" + +import fnmatch +import time +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase, override_settings + +from apps.timeshift import views +from apps.proxy.utils import check_user_stream_limits as _check_user_stream_limits +from apps.proxy.utils import find_ts_sync as _find_ts_sync + +TEST_SESSION_ID = "timeshift_testsession1" +TEST_MEDIA_ID = "timeshift_8_2026-06-08-17-00" + + +def _proxy_url(session_id=TEST_SESSION_ID): + base = "/timeshift/u/p/8/2026-06-08:17-00/8.ts" + return f"{base}?session_id={session_id}" if session_id else base + + +def _seed_pool_session( + redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + *, + busy="1", + serving_range=None, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", +): + views._create_pool_session( + redis, + session_id=session_id, + media_id=media_id, + user_id=user_id, + client_ip=client_ip, + client_user_agent=client_user_agent, + account_id=1, + profile_id=31, + stream_id="111", + provider_timestamp="2026-06-08:19-00", + ) + if serving_range is not None: + redis.hset(f"timeshift_pool:{session_id}", "serving_range", serving_range) + if busy is not None: + redis.hset(f"timeshift_pool:{session_id}", "busy", busy) + + +class FindTsSyncTests(TestCase): + """Locate the first MPEG-TS sync chain so a leading HTML/PHP preamble + can be skipped before the bytes reach the strict demuxer (ExoPlayer).""" + + def test_returns_zero_when_buffer_already_aligned(self): + buf = b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + self.assertEqual(_find_ts_sync(buf), 0) + + def test_returns_offset_of_first_chain_after_preamble(self): + preamble = b"
\nWarning" + aligned = b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + self.assertEqual(_find_ts_sync(preamble + aligned), len(preamble)) + + def test_returns_minus_one_when_no_chain_exists(self): + # Three lone 0x47 bytes that are NOT spaced at 188 — must not be + # mistaken for a sync chain. + self.assertEqual(_find_ts_sync(b"\x47\x00\x00\x47\x00\x00\x47" * 50), -1) + + def test_returns_minus_one_for_short_buffer(self): + self.assertEqual(_find_ts_sync(b"\x47" * 10), -1) + + + +def _make_ts_payload(size=1024): + """Build a minimal valid MPEG-TS byte sequence with 0x47 sync markers.""" + packet = b"\x47" + b"\x00" * 187 + return (packet * ((size // 188) + 1))[:size] + + +def _fake_upstream(status_code, *, content_type="video/mp2t", body=b""): + resp = MagicMock() + resp.status_code = status_code + resp.headers = {"Content-Type": content_type} + resp.iter_content = MagicMock(return_value=iter([body] if body else [])) + resp.close = MagicMock() + # Simulate raw.read() for the TS sync peek in _stream_from_provider. + # For 200 responses, return valid TS bytes so the peek check passes. + if status_code in (200, 206) and not body: + ts_peek = _make_ts_payload() + resp.raw = MagicMock() + resp.raw.read = MagicMock(return_value=ts_peek) + elif status_code in (200, 206): + resp.raw = MagicMock() + resp.raw.read = MagicMock(return_value=body) + return resp + + +class StreamFromProviderStatusMappingTests(TestCase): + """`_stream_from_provider` must translate upstream HTTP status codes into + semantically correct Django responses so downstream IPTV clients react + the right way (notably: stop retrying on 404).""" + + def setUp(self): + self.factory = RequestFactory() + self.kwargs = dict( + candidate_urls=[ + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12:17-00", + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12 17:00:00", + "http://example.test/timeshift/u/p/60/2026-05-12:17-00/1.ts", + ], + user_agent="test-agent", + range_header=None, + virtual_channel_id="timeshift_1_2026-05-12-17-00_1", + client_id="timeshift_test123", + client_ip="127.0.0.1", + user=None, + channel_display_name="Test", + timestamp_utc="2026-05-12:17-00", + channel_logo_id=None, + m3u_profile_id=None, + debug=False, + ) + + @patch.object(views, "_open_upstream") + def test_all_candidates_404_returns_404(self, mocked_open): + mocked_open.return_value = _fake_upstream(404) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 404) + # Every candidate is attempted before giving up. + self.assertEqual(mocked_open.call_count, 3) + + @patch.object(views, "_open_upstream") + def test_upstream_403_short_circuits_loop(self, mocked_open): + # 403 is decisive (auth) — no retry of further candidates. + mocked_open.return_value = _fake_upstream(403) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 403) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_upstream_302_short_circuits_loop(self, mocked_open): + # Any 3xx is decisive: for XC providers a 302 is the first sign of + # an IP ban, so the cascade must STOP hammering immediately instead + # of retrying other URL shapes (which escalates the ban). + mocked_open.return_value = _fake_upstream(302) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_upstream_500_continues_to_next_candidate(self, mocked_open): + # A 5xx is format-specific on many XC servers (PHP fatal with + # display_errors off turns an "Undefined array key" warning into a + # hard 500), so the cascade must keep trying — the next timestamp + # shape often succeeds. Regression: providers that 500 on the first + # shape used to fail outright because the loop short-circuited. + mocked_open.side_effect = [ + _fake_upstream(500), + _fake_upstream(200, body=_make_ts_payload()), + ] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_all_candidates_500_returns_error(self, mocked_open): + # Every shape 500s → all candidates attempted, then a clean error. + mocked_open.return_value = _fake_upstream(500) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 3) + + @patch.object(views, "_open_upstream") + def test_first_candidate_succeeds(self, mocked_open): + mocked_open.side_effect = [_fake_upstream(200, body=_make_ts_payload())] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_second_candidate_succeeds_after_404(self, mocked_open): + # Primary 404 → second candidate 200 → streams successfully. + mocked_open.side_effect = [ + _fake_upstream(404), + _fake_upstream(200, body=_make_ts_payload()), + ] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_third_candidate_succeeds_after_400_then_404(self, mocked_open): + mocked_open.side_effect = [ + _fake_upstream(400), + _fake_upstream(404), + _fake_upstream(200, body=_make_ts_payload()), + ] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 3) + + @override_settings(CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} + }) + @patch.object(views, "_open_upstream") + def test_cache_promotes_winning_index_to_first(self, mocked_open): + """Once a candidate succeeds for an account, the next request reorders + the list so the cached winner is tried first — saving cascade + overhead on fast-forward.""" + # locmem cache: isolates this test from the shared Redis-backed django + # cache (which persists across runs and parallel test sessions). + from django.core.cache import cache as django_cache + django_cache.delete(views._FORMAT_CACHE_KEY.format(999)) + + # First request: candidate index 1 wins after index 0 returns 404. + mocked_open.side_effect = [ + _fake_upstream(404), + _fake_upstream(200, body=_make_ts_payload()), + ] + kwargs = dict(self.kwargs, account_id=999) + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + r1 = views._stream_from_provider(**kwargs) + self.assertEqual(r1.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + + # Second request: cached winner (index 1) is tried first, succeeds + # immediately — no cascade. + mocked_open.reset_mock() + mocked_open.side_effect = [_fake_upstream(200, body=_make_ts_payload())] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + r2 = views._stream_from_provider(**kwargs) + self.assertEqual(r2.status_code, 200) + self.assertEqual(mocked_open.call_count, 1) + # Confirm the URL used is the SQL-datetime candidate (index 1 in the + # original list set up in setUp), not the dash-only one (index 0). + self.assertIn("17:00:00", mocked_open.call_args_list[0][0][0]) + + @patch.object(views, "_open_upstream") + def test_php_error_200_cascades_to_next_candidate(self, mocked_open): + """When the provider returns HTTP 200 but the body is PHP error text + (no TS sync), the cascade should try the next candidate URL.""" + php_error = b'
\nWarning: Invalid argument supplied for foreach()' + php_resp = _fake_upstream(200, body=php_error) + php_resp.raw = MagicMock() + php_resp.raw.read = MagicMock(return_value=php_error) + + ts_resp = _fake_upstream(200, body=_make_ts_payload()) + + mocked_open.side_effect = [php_resp, ts_resp] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + # PHP response was rejected, second candidate accepted + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_416_range_not_satisfiable_passes_through(self, mocked_open): + # A tail/seek probe past EOF must go back to the client verbatim, + # never cascaded to other URL shapes (byte offsets are file-specific, + # so cascading only multiplies upstream connections). + resp = _fake_upstream(416) + resp.headers = {"Content-Type": "video/mp2t", "Content-Range": "bytes */1000"} + mocked_open.return_value = resp + kwargs = dict(self.kwargs, range_header="bytes=999999-") + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 416) + self.assertEqual(response["Content-Range"], "bytes */1000") + self.assertTrue(getattr(response, "timeshift_passthrough", False)) + # No cascade: the first (and only) candidate decided the outcome. + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_partial_206_to_range_request_accepted_mid_packet(self, mocked_open): + # A 206 answering a Range request legitimately starts mid-TS-packet + # (no 0x47 sync at offset 0). It must be served, not rejected as a + # PHP error and cascaded across every URL shape and provider account. + mid_packet = b"\x00" * 300 + self.assertEqual(_find_ts_sync(mid_packet), -1) + resp = _fake_upstream(206, body=mid_packet) + resp.raw = MagicMock() + resp.raw.read = MagicMock(return_value=mid_packet) + mocked_open.return_value = resp + kwargs = dict(self.kwargs, range_header="bytes=1000-") + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_partial_206_html_error_still_rejected(self, mocked_open): + # The mid-packet allowance is gated on content type: a 206 whose body + # is an HTML/PHP error page must still be rejected and cascaded. + html = b"error" + bad = _fake_upstream(206, content_type="text/html", body=html) + bad.raw = MagicMock() + bad.raw.read = MagicMock(return_value=html) + good = _fake_upstream(206, body=_make_ts_payload()) + mocked_open.side_effect = [bad, good] + kwargs = dict(self.kwargs, range_header="bytes=0-") + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_206_without_range_header_still_requires_sync(self, mocked_open): + # Without a Range header a 206 is unexpected; it must still pass the + # TS-sync probe (the mid-packet allowance is range-only). + mid_packet = b"\x00" * 300 + bad = _fake_upstream(206, body=mid_packet) + bad.raw = MagicMock() + bad.raw.read = MagicMock(return_value=mid_packet) + good = _fake_upstream(206, body=_make_ts_payload()) + mocked_open.side_effect = [bad, good] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 2) + + +class RedactUrlTests(TestCase): + """`_redact_url` is the guard that keeps XC credentials out of logs — + both URL forms carry them (query params in format A, path segments in + format B).""" + + def test_redacts_query_credentials(self): + url = "http://example.test/streaming/timeshift.php?username=u&password=p&stream=1" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_redacts_path_credentials(self): + url = "http://example.test/timeshift/user/pass/60/2026-05-12:17-00/1.ts" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_redacts_userinfo_credentials(self): + url = "http://user:pass@example.test/timeshift/1.ts" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_passes_through_non_urls(self): + self.assertEqual(views._redact_url("not a url"), "not a url") + self.assertIsNone(views._redact_url(None)) + + +def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9, + stream_id="22372", account_type="XC", profile_id=31, + extra_profiles=()): + """Build a mocked catch-up Stream with its own provider context. + + The default (tz-bearing) profile leads the active-profile list the view + walks; ``extra_profiles`` appends alternate (non-default) profiles for + capacity-walk tests. + """ + profile = MagicMock() + profile.id = profile_id + profile.is_default = True + profile.custom_properties = {"server_info": {"timezone": provider_tz}} + m3u_account = MagicMock() + m3u_account.account_type = account_type + m3u_account.id = account_id + m3u_account.profiles.filter.return_value = [profile, *extra_profiles] + stream = MagicMock() + stream.m3u_account = m3u_account + stream.custom_properties = {"stream_id": stream_id} if stream_id else {} + return stream + + +def _make_alt_profile(profile_id): + """A non-default active profile for the capacity walk.""" + profile = MagicMock() + profile.id = profile_id + profile.is_default = False + profile.custom_properties = {} + return profile + + +class _FakeRedis: + """Just enough of the redis-py surface for the idle-session pool: setex/get/ + delete plus a transactional pipeline doing GET+DEL, and the hash, set and + lock primitives the pool entries rely on.""" + + def __init__(self): + self.store = {} + + def setex(self, key, ttl, value): + self.store[key] = str(value) + + def set(self, key, value): + self.store[key] = str(value) + + def get(self, key): + return self.store.get(key) + + def delete(self, *keys): + return sum(1 for k in keys if self.store.pop(k, None) is not None) + + def exists(self, key): + return 1 if key in self.store else 0 + + def pipeline(self, transaction=False): + return _FakeRedisPipeline(self) + + # --- hash + lock surface for the session slot --- + def hgetall(self, key): + value = self.store.get(key) + return dict(value) if isinstance(value, dict) else {} + + def hset(self, key, field=None, value=None, mapping=None, **kwargs): + hash_value = self.store.get(key) + if not isinstance(hash_value, dict): + hash_value = {} + self.store[key] = hash_value + if field is not None and value is not None: + hash_value[str(field)] = str(value) + for f, v in (mapping or {}).items(): + hash_value[str(f)] = str(v) + for f, v in kwargs.items(): + hash_value[str(f)] = str(v) + return len(hash_value) + + def hincrby(self, key, field, amount=1): + hash_value = self.store.get(key) + if not isinstance(hash_value, dict): + hash_value = {} + self.store[key] = hash_value + new_value = int(hash_value.get(field, 0)) + amount + hash_value[field] = str(new_value) + return new_value + + def hget(self, key, field): + hash_value = self.store.get(key) + return hash_value.get(field) if isinstance(hash_value, dict) else None + + def expire(self, key, ttl): + return 1 if key in self.store else 0 + + # --- set surface for the idle-session pool --- + def sadd(self, key, *members): + existing = self.store.get(key) + if not isinstance(existing, set): + existing = set() + self.store[key] = existing + before = len(existing) + existing.update(str(m) for m in members) + return len(existing) - before + + def srem(self, key, *members): + existing = self.store.get(key) + if not isinstance(existing, set): + return 0 + removed = 0 + for member in members: + if str(member) in existing: + existing.discard(str(member)) + removed += 1 + return removed + + def smembers(self, key): + existing = self.store.get(key) + return set(existing) if isinstance(existing, set) else set() + + def scard(self, key): + existing = self.store.get(key) + return len(existing) if isinstance(existing, set) else 0 + + def lock(self, name, timeout=None, blocking_timeout=None): + return _FakeRedisLock() + + def scan(self, cursor=0, match=None, count=100): + keys = sorted( + k for k in self.store + if match is None or fnmatch.fnmatch(k, match) + ) + if cursor >= len(keys): + return 0, [] + batch = keys[cursor:cursor + count] + next_cursor = cursor + len(batch) + if next_cursor >= len(keys): + return 0, batch + return next_cursor, batch + + +class _FakeRedisLock: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _FakeRedisPipeline: + def __init__(self, redis): + self._redis = redis + self._ops = [] + + def get(self, key): + self._ops.append(("get", key)) + + def delete(self, key): + self._ops.append(("delete", key)) + + def execute(self): + results = [] + for op, key in self._ops: + if op == "get": + results.append(self._redis.get(key)) + else: + results.append(self._redis.delete(key)) + self._ops = [] + return results + + +def _fake_creds(acc, prof): + """Distinguishable per-account credentials, mirroring what + get_transformed_credentials returns for the reserved profile.""" + return (f"http://a{acc.id}.test", f"u{acc.id}", "p") + + +class TimeshiftProxyTimestampWiringTests(TestCase): + """`timeshift_proxy` must convert the client's UTC timestamp to the + serving provider's zone for the upstream URL, while keeping the ORIGINAL + UTC timestamp for the EPG duration lookup — the only timezone conversion + in the chain.""" + + def setUp(self): + self.factory = RequestFactory() + + def _call(self, timestamp, provider_tz="Europe/Brussels"): + request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts?session_id={TEST_SESSION_ID}") + sentinel = MagicMock(status_code=200) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream(provider_tz)]), \ + patch.object(views, "get_programme_duration", return_value=40) as duration_mock, \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_stream_from_provider", return_value=sentinel) as stream_mock: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy(request, "u", "p", "8", timestamp, "8.ts") + return response, sentinel, build_mock, duration_mock, stream_mock + + def test_candidates_get_provider_local_timestamp(self): + # June → CEST: 17:00 UTC must reach the URL builder as 19:00 Brussels. + response, sentinel, build_mock, duration_mock, _ = self._call("2026-06-08:17-00") + self.assertIs(response, sentinel) + self.assertEqual(build_mock.call_args[0][2], "2026-06-08:19-00") + + def test_duration_lookup_keeps_original_utc_timestamp(self): + # The EPG is stored in UTC — the duration lookup must NOT receive the + # provider-converted value. + _, _, _, duration_mock, _ = self._call("2026-06-08:17-00") + self.assertEqual(duration_mock.call_args[0][1], "2026-06-08:17-00") + + def test_utc_provider_passes_timestamp_unchanged(self): + _, _, build_mock, _, _ = self._call("2026-06-08:17-00", provider_tz="UTC") + self.assertEqual(build_mock.call_args[0][2], "2026-06-08:17-00") + + def test_colon_seconds_timestamp_accepted(self): + response, sentinel, build_mock, duration_mock, _ = self._call( + "2026-06-23:04:00:00" + ) + self.assertIs(response, sentinel) + self.assertEqual(duration_mock.call_args[0][1], "2026-06-23:04:00:00") + + def test_invalid_timestamp_rejected_before_upstream(self): + request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams") as catchup_mock, \ + patch.object(views, "_stream_from_provider") as stream_mock: + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy(request, "u", "p", "8", "garbage", "8.ts") + self.assertEqual(response.status_code, 400) + catchup_mock.assert_not_called() + stream_mock.assert_not_called() + + def test_network_access_denied_returns_403(self): + # Same network gate as other XC API endpoints (player_api, xmltv, etc.). + request = self.factory.get(_proxy_url()) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=False) as gate, \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_stream_from_provider") as stream_mock: + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(gate.call_args[0][1], "XC_API") + channel_cls.objects.get.assert_not_called() + stream_mock.assert_not_called() + + +class TimeshiftProxyFailoverTests(TestCase): + """When the first catch-up stream's provider cannot serve the archive, + the proxy must fail over to the channel's next catch-up stream — each + attempt with its own provider context.""" + + def setUp(self): + self.factory = RequestFactory() + + def _call(self, streams, provider_responses): + request = self.factory.get(_proxy_url()) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=True) as limits_mock, \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", + side_effect=_fake_creds) as creds_mock, \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_stream_from_provider", + side_effect=provider_responses) as stream_mock: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + self.creds_mock = creds_mock + return response, stream_mock, build_mock, limits_mock + + def test_second_stream_serves_after_first_fails(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + ok = MagicMock(status_code=200) + response, stream_mock, build_mock, _ = self._call( + streams, [MagicMock(status_code=404), ok] + ) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + # Each attempt used its own provider context: credentials resolved per + # account/profile (via get_transformed_credentials) and its stream id. + self.assertEqual( + [c.args[0] for c in build_mock.call_args_list], + [("http://a1.test", "u1", "p"), ("http://a2.test", "u2", "p")], + ) + self.assertEqual( + [c.args[0] for c in self.creds_mock.call_args_list], + [streams[0].m3u_account, streams[1].m3u_account], + ) + self.assertEqual( + [c.args[1] for c in build_mock.call_args_list], ["111", "222"] + ) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2] + ) + + def test_all_streams_fail_returns_last_failure(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + last = MagicMock(status_code=404) + response, stream_mock, _, _ = self._call( + streams, [MagicMock(status_code=400), last] + ) + self.assertIs(response, last) + self.assertEqual(stream_mock.call_count, 2) + + def test_non_xc_and_missing_stream_id_are_skipped(self): + streams = [ + _make_catchup_stream(account_id=1, account_type="M3U"), + _make_catchup_stream(account_id=2, stream_id=None), + _make_catchup_stream(account_id=3, stream_id="333"), + ] + ok = MagicMock(status_code=200) + response, stream_mock, _, _ = self._call(streams, [ok]) + self.assertIs(response, ok) + # Only the eligible third stream produced an upstream attempt. + self.assertEqual(stream_mock.call_count, 1) + self.assertEqual(stream_mock.call_args.kwargs["account_id"], 3) + + def test_stream_limits_checked_once_for_the_request(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + _, _, _, limits_mock = self._call( + streams, [MagicMock(status_code=404), MagicMock(status_code=200)] + ) + self.assertEqual(limits_mock.call_count, 1) + + def test_passthrough_is_not_failed_over_to_other_accounts(self): + # A terminal range answer (e.g. 416 past EOF) must be returned as-is; + # the loop must NOT try the next account, whose byte offsets would not + # match this file and which would just burn another provider slot. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + passthrough = MagicMock(status_code=416) + passthrough.timeshift_passthrough = True + response, stream_mock, _, _ = self._call(streams, [passthrough]) + self.assertIs(response, passthrough) + self.assertEqual(stream_mock.call_count, 1) + + +class _ProxyLoopTestMixin: + """Shared driver for tests exercising the failover loop end to end — + pool reservation, credential resolution and Redis are all controlled.""" + + def setUp(self): + self.factory = RequestFactory() + + def _call(self, streams, provider_responses, limits=True, reserve_results=None, + build_side_effect=None): + request = self.factory.get(_proxy_url()) + self.fake_redis = _FakeRedis() + reserve_kwargs = ( + {"side_effect": reserve_results} + if reserve_results is not None + else {"return_value": (True, 1, None)} + ) + build_kwargs = ( + {"side_effect": build_side_effect} + if build_side_effect is not None + else {"return_value": ["http://example.test/x.ts"]} + ) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "build_timeshift_candidate_urls", + **build_kwargs) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=limits), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", **reserve_kwargs) as reserve_mock, \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "get_transformed_credentials", + side_effect=_fake_creds) as creds_mock, \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_stream_from_provider", + side_effect=provider_responses) as stream_mock: + redis_cls.get_client.return_value = self.fake_redis + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + # Exposed before the call so raising tests can still assert on them. + self.reserve_mock = reserve_mock + self.release_mock = release_mock + self.creds_mock = creds_mock + self.stream_mock = stream_mock + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + return response, stream_mock, build_mock + + +class TimeshiftProxyFailoverHardeningTests(_ProxyLoopTestMixin, TestCase): + """Ban-safety and per-provider context guarantees of the failover loop.""" + + def test_decisive_failure_skips_same_accounts_other_streams(self): + # Account 1 carries two variants (e.g. FHD + HD). A decisive + # (auth/ban-class) failure on the first must NOT retry account 1's + # second stream — that would hammer a banning provider — but a + # DIFFERENT account stays fair game. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=1, stream_id="112"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + decisive = MagicMock(status_code=403, timeshift_decisive=True) + ok = MagicMock(status_code=200) + response, stream_mock, _ = self._call(streams, [decisive, ok]) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2] + ) + + def test_soft_failure_still_tries_same_accounts_other_streams(self): + # A soft failure (404: this stream's archive missing) is stream- + # specific — the same account's other variant may still have it. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=1, stream_id="112"), + ] + soft = MagicMock(status_code=404, timeshift_decisive=False) + ok = MagicMock(status_code=200) + response, stream_mock, _ = self._call(streams, [soft, ok]) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 1] + ) + + def test_each_stream_uses_its_own_provider_timezone(self): + # June: 17:00 UTC = 19:00 Brussels (CEST) but 13:00 New York (EDT). + # The converted timestamp must be recomputed per attempt. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", + provider_tz="Europe/Brussels"), + _make_catchup_stream(account_id=2, stream_id="222", + provider_tz="America/New_York"), + ] + response, _, build_mock = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False), + MagicMock(status_code=200)], + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + [c.args[2] for c in build_mock.call_args_list], + ["2026-06-08:19-00", "2026-06-08:13-00"], + ) + + def test_stream_limit_exceeded_returns_403_before_upstream(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111")] + response, stream_mock, _ = self._call(streams, [], limits=False) + self.assertEqual(response.status_code, 403) + stream_mock.assert_not_called() + + def test_no_catchup_streams_returns_400(self): + response, stream_mock, _ = self._call([], []) + self.assertEqual(response.status_code, 400) + stream_mock.assert_not_called() + + def test_all_streams_ineligible_returns_400(self): + streams = [ + _make_catchup_stream(account_id=1, account_type="M3U"), + _make_catchup_stream(account_id=2, stream_id=None), + ] + response, stream_mock, _ = self._call(streams, []) + self.assertEqual(response.status_code, 400) + stream_mock.assert_not_called() + + +class XcServerInfoUtcTests(TestCase): + """The XC server_info 'timezone triple' guarantee the timeshift chain + relies on: server_info.timezone is always UTC and time_now is UTC + wall-clock. (Tested here because catch-up seek correctness depends on + it: clients build the timeshift URL from this declared zone.)""" + + def test_server_info_is_strictly_utc(self): + from datetime import datetime, timezone as dt_timezone + from apps.output.views import _build_xc_server_info + + request = MagicMock(scheme="http") + info = _build_xc_server_info(request, "example.test", "9191") + self.assertEqual(info["timezone"], "UTC") + reported = datetime.strptime(info["time_now"], "%Y-%m-%d %H:%M:%S") + now_utc = datetime.now(dt_timezone.utc).replace(tzinfo=None) + self.assertLess(abs((now_utc - reported).total_seconds()), 60) + self.assertIsInstance(info["timestamp_now"], int) + + +class StreamFromProviderDecisiveEdgeTests(TestCase): + """Remaining decisive-status and transport-error paths of the cascade.""" + + def setUp(self): + self.kwargs = dict( + candidate_urls=[ + "http://example.test/timeshift/u/p/60/2026-05-12:17-00/1.ts", + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12_17-00", + ], + user_agent="test-agent", + range_header=None, + virtual_channel_id="timeshift_1_2026-05-12-17-00_1", + client_id="timeshift_test456", + client_ip="127.0.0.1", + user=None, + channel_display_name="Test", + timestamp_utc="2026-05-12:17-00", + channel_logo_id=None, + m3u_profile_id=None, + debug=False, + ) + + @patch.object(views, "_open_upstream") + def test_406_is_decisive_and_marks_response(self, mocked_open): + # 406 = IP-wide block in the XC ban escalation — single attempt, + # generic 400 to the client, and the failover loop must see the + # decisive marker so it skips this account's other streams. + mocked_open.return_value = _fake_upstream(406) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + self.assertTrue(response.timeshift_decisive) + + @patch.object(views, "_open_upstream") + def test_404_failure_is_not_decisive(self, mocked_open): + mocked_open.return_value = _fake_upstream(404) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 404) + self.assertFalse(response.timeshift_decisive) + + @patch.object(views, "_open_upstream") + def test_connection_error_returns_400_after_single_attempt(self, mocked_open): + import requests as _requests + mocked_open.side_effect = _requests.exceptions.ConnectionError("boom") + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + # Transport errors are host-level, not auth/ban-class: the failover + # loop may still try a different account. + self.assertFalse(getattr(response, "timeshift_decisive", False)) + + +class CatchupStreamsDbTests(TestCase): + """get_channel_catchup_streams: the function that defines the failover + order — channelstream order, catch-up streams only, active accounts only.""" + + @classmethod + def setUpTestData(cls): + from apps.channels.models import Channel, ChannelStream, Stream + from apps.m3u.models import M3UAccount + + cls.active = M3UAccount.objects.create( + name="ts-test-active", server_url="http://example.test", + account_type="XC", is_active=True, + ) + cls.inactive = M3UAccount.objects.create( + name="ts-test-inactive", server_url="http://example.test", + account_type="XC", is_active=False, + ) + cls.channel = Channel.objects.create(name="ts-test-channel", is_catchup=True) + + def add(name, account, *, catchup, order): + s = Stream.objects.create( + name=name, url=f"http://example.test/{name}", + m3u_account=account, is_catchup=catchup, + ) + ChannelStream.objects.create(channel=cls.channel, stream=s, order=order) + return s + + cls.s_inactive = add("s-inactive", cls.inactive, catchup=True, order=0) + cls.s_second = add("s-second", cls.active, catchup=True, order=2) + cls.s_first = add("s-first", cls.active, catchup=True, order=1) + cls.s_live_only = add("s-live-only", cls.active, catchup=False, order=3) + + def test_ordered_active_catchup_streams_only(self): + from apps.channels.utils import get_channel_catchup_streams + + result = get_channel_catchup_streams(self.channel) + # Inactive-account and non-catchup streams excluded; channelstream order. + self.assertEqual([s.id for s in result], [self.s_first.id, self.s_second.id]) + + def test_channel_without_catchup_flag_returns_empty(self): + from apps.channels.models import Channel + from apps.channels.utils import get_channel_catchup_streams + + ch = Channel.objects.create(name="ts-test-nocatchup", is_catchup=False) + self.assertEqual(get_channel_catchup_streams(ch), []) + + +class AuthHelpersDbTests(TestCase): + """_authenticate_user (xc_password custom property) and + _user_can_access_channel (user_level gate) — exercised against real models + instead of being mocked away.""" + + @classmethod + def setUpTestData(cls): + from apps.accounts.models import User + from apps.channels.models import Channel + + cls.viewer = User.objects.create( + username="ts-test-viewer", user_level=0, + custom_properties={"xc_password": "right-pass"}, + ) + cls.no_xc = User.objects.create( + username="ts-test-noxc", user_level=10, + custom_properties={}, + ) + cls.basic_channel = Channel.objects.create(name="ts-test-basic", user_level=0) + cls.admin_channel = Channel.objects.create(name="ts-test-adult", user_level=10) + + def test_valid_xc_password_authenticates(self): + user = views._authenticate_user("ts-test-viewer", "right-pass") + self.assertIsNotNone(user) + self.assertEqual(user.id, self.viewer.id) + + def test_wrong_xc_password_rejected(self): + self.assertIsNone(views._authenticate_user("ts-test-viewer", "wrong")) + + def test_user_without_xc_password_rejected(self): + # Accounts with no xc_password set (e.g. admins) must be denied even + # if the caller guesses any string — there is nothing to compare to. + self.assertIsNone(views._authenticate_user("ts-test-noxc", "")) + self.assertIsNone(views._authenticate_user("ts-test-noxc", "anything")) + + def test_unknown_username_rejected(self): + self.assertIsNone(views._authenticate_user("ts-test-ghost", "x")) + + def test_user_level_gate(self): + # Level-0 viewer with no profiles: allowed on level-0, denied on level-10. + self.assertTrue(views._user_can_access_channel(self.viewer, self.basic_channel)) + self.assertFalse(views._user_can_access_channel(self.viewer, self.admin_channel)) + + +class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): + """Provider pool participation: a profile slot is reserved before any + upstream attempt and released exactly once afterwards, the same accounting + contract live (Channel.get_stream) and VOD follow. Each active stream + reserves its own slot so concurrent provider connections stay capped by + max_streams.""" + + POOL_KEY = f"timeshift_pool:{TEST_SESSION_ID}" + + def _pool_entry_ids(self): + return [k for k in self.fake_redis.store if k.startswith("timeshift_pool:")] + + def test_reserve_called_with_default_profile_before_upstream(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, stream_mock, _ = self._call(streams, [MagicMock(status_code=200)]) + self.assertEqual(response.status_code, 200) + self.reserve_mock.assert_called_once() + reserved_profile = self.reserve_mock.call_args.args[0] + self.assertEqual(reserved_profile.id, 31) + # The reserved profile's id is what reaches the stats metadata. + self.assertEqual(stream_mock.call_args.kwargs["m3u_profile_id"], 31) + + def test_slot_released_after_failed_attempt(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, _, _ = self._call( + streams, [MagicMock(status_code=404, timeshift_decisive=False)] + ) + self.assertEqual(response.status_code, 404) + # The failed attempt's slot was released and its pool entry removed. + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._pool_entry_ids(), []) + + def test_slot_kept_on_success_for_the_streaming_session(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, _, _ = self._call(streams, [MagicMock(status_code=200)]) + self.assertEqual(response.status_code, 200) + # Slot still owned by the (mocked) streaming session: a busy pool entry + # remains for the next request to reuse, nothing released yet. + self.release_mock.assert_not_called() + self.assertEqual(len(self._pool_entry_ids()), 1) + + def test_decisive_failure_releases_slot_and_skips_account(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), + ] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=403, timeshift_decisive=True)] + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(stream_mock.call_count, 1) + self.release_mock.assert_called_once_with(31, self.fake_redis) + # Decisive skip means the second stream never reserved a slot. + self.assertEqual(self.reserve_mock.call_count, 1) + + def test_profile_full_walks_to_next_profile_same_account(self): + alt = _make_alt_profile(32) + streams = [_make_catchup_stream( + account_id=1, stream_id="111", profile_id=31, extra_profiles=(alt,) + )] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=200)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 200) + # Default profile full -> alternate profile reserved and used. + self.assertEqual( + [c.args[0].id for c in self.reserve_mock.call_args_list], [31, 32] + ) + self.assertEqual(stream_mock.call_args.kwargs["m3u_profile_id"], 32) + # Credentials were resolved for the RESERVED (alternate) profile. + self.assertIs(self.creds_mock.call_args.args[1], alt) + + def test_all_profiles_full_returns_503_without_upstream_attempt(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, stream_mock, _ = self._call( + streams, [], + reserve_results=[ + (False, 1, "profile_full"), + (False, 1, "credential_full"), + ], + ) + # Pool capacity exhausted everywhere: 503 (VOD's pool-exhausted + # status), and crucially the provider was never contacted. + self.assertEqual(response.status_code, 503) + stream_mock.assert_not_called() + self.release_mock.assert_not_called() + + def test_capacity_failure_is_not_decisive_for_the_account(self): + # profile_full on account 1's first stream must NOT mark account 1 + # decisive — capacity is transient, unlike a ban-class status. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), + ] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=200)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 200) + # Second stream of the SAME account still got its reservation attempt. + self.assertEqual(self.reserve_mock.call_count, 2) + self.assertEqual(stream_mock.call_count, 1) + + def test_account_without_active_default_profile_is_skipped(self): + # Mirrors live dispatch: no active default profile -> skip the account + # without reserving anything. + stream = _make_catchup_stream(account_id=1, stream_id="111") + stream.m3u_account.profiles.filter.return_value = [_make_alt_profile(32)] + response, stream_mock, _ = self._call([stream], []) + self.assertEqual(response.status_code, 400) + self.reserve_mock.assert_not_called() + stream_mock.assert_not_called() + + def test_exception_from_provider_releases_slot(self): + # An unexpected exception between reserve and response construction + # must release the slot before propagating — otherwise the counter + # (no TTL) leaks until the next Redis flush. + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with self.assertRaises(RuntimeError): + self._call(streams, RuntimeError("boom")) + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._pool_entry_ids(), []) + + def test_exception_before_upstream_releases_slot(self): + # Same guarantee for failures BEFORE the upstream call (URL building, + # credential resolution, user-agent lookup) — the guarded window + # starts right after the reservation. + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with self.assertRaises(RuntimeError): + self._call(streams, [], build_side_effect=RuntimeError("boom")) + self.stream_mock.assert_not_called() + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._pool_entry_ids(), []) + + def test_mixed_capacity_then_upstream_failure_returns_failure(self): + # Mixed outcome: one stream capacity-blocked, another actually tried + # upstream and failed -> the REAL upstream failure wins over 503 + # (capacity was not the sole blocker). + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, _, _ = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 404) + + def test_mixed_upstream_failure_then_capacity_returns_failure(self): + # Same in the opposite order. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, _, _ = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False)], + reserve_results=[(True, 1, None), (False, 1, "profile_full")], + ) + self.assertEqual(response.status_code, 404) + + +class TimeshiftPoolReleaseTests(TestCase): + """Pool slot release and response close paths for a pooled session.""" + + def setUp(self): + self.redis = _FakeRedis() + self.session_id = TEST_SESSION_ID + + def _pool_key(self): + return f"timeshift_pool:{self.session_id}" + + def test_release_callback_frees_slot_exactly_once(self): + _seed_pool_session(self.redis, session_id=self.session_id) + release = views._make_release_once(self.redis, self.session_id, 31) + with patch.object(views, "release_profile_slot") as release_mock: + release() + release() + release_mock.assert_called_once_with(31, self.redis) + self.assertEqual(self.redis.hget(self._pool_key(), "busy"), "0") + self.assertTrue(self.redis.exists(self._pool_key())) + + def test_discard_frees_slot_and_removes_entry(self): + _seed_pool_session(self.redis, session_id=self.session_id) + with patch.object(views, "release_profile_slot") as release_mock: + views._discard_pool_session(self.redis, self.session_id, 31) + release_mock.assert_called_once_with(31, self.redis) + self.assertFalse(self.redis.exists(self._pool_key())) + + def test_release_without_redis_is_noop(self): + release = views._make_release_once(None, self.session_id, 31) + with patch.object(views, "release_profile_slot") as release_mock: + release() + release_mock.assert_not_called() + + def test_wrapper_close_releases_even_when_generator_never_started(self): + # The WSGI layer can close the response before the first chunk is + # pulled; closing a never-started generator runs NO body code, so the + # generator's own finally cannot be the only release point. + finally_ran = [] + + def gen(): + try: + yield b"x" + finally: + finally_ran.append(True) + + on_close = MagicMock() + wrapper = views._SlotReleasingStream(gen(), on_close) + wrapper.close() + on_close.assert_called_once() + self.assertEqual(finally_ran, []) # proves the leak this wrapper fixes + + def test_streaming_response_close_invokes_wrapper_close(self): + # Locks the Django contract the wrapper relies on: an iterator with a + # close() method is registered as a resource closer of the response. + from django.http import StreamingHttpResponse + + on_close = MagicMock() + wrapper = views._SlotReleasingStream(iter([b"x"]), on_close) + response = StreamingHttpResponse(wrapper, content_type="video/mp2t") + response.close() + on_close.assert_called_once() + + +class TimeshiftTakeoverTests(TestCase): + """A new request displaces the user's previous catch-up session(s) on the + same channel at a DIFFERENT position (stats unregister + stop key, leaving + the displaced generator to free its own slot), while leaving sibling range + requests of the same playback alone, and never touching other users, + channels, or live.""" + + def setUp(self): + self.redis = _FakeRedis() + self.user = MagicMock(id=5) + + def _conn(self, media_id, client_id, conn_type="timeshift"): + return { + "media_id": media_id, + "client_id": client_id, + "connected_at": 0.0, + "type": conn_type, + } + + def test_displaces_other_positions_on_same_channel(self): + connections = [ + self._conn("timeshift_8_2026-06-08-17-00_111", "timeshift_old1"), + self._conn("timeshift_9_2026-06-08-17-00_222", "timeshift_other"), + self._conn("42", "live_client", conn_type="live"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections) as conns_mock, \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-09-20-00", "timeshift_current", + ) + conns_mock.assert_called_once_with(5) + # Takeover defers slot release to the displaced generator's stop path; + # it only drops stats and signals the stop key. + release_mock.assert_not_called() + unregister_mock.assert_called_once_with( + self.redis, "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" + ) + from apps.proxy.live_proxy.redis_keys import RedisKeys + stop_key = RedisKeys.client_stop( + "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" + ) + self.assertIn(stop_key, self.redis.store) + # Channel 9's session untouched: no stop key set for it. + other_stop = RedisKeys.client_stop( + "timeshift_9_2026-06-08-17-00_222", "timeshift_other" + ) + self.assertNotIn(other_stop, self.redis.store) + + def test_leaves_sibling_requests_of_current_playback(self): + # Concurrent range/probe requests of the SAME playback must not + # displace one another. + connections = [ + self._conn("timeshift_8_2026-06-08-17-00_111", "timeshift_sibling"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-08-17-00", + "timeshift_sibling", + ) + release_mock.assert_not_called() + unregister_mock.assert_not_called() + + def test_channel_id_prefix_cannot_match_other_channels(self): + # Channel 8 must not displace channel 80/81 sessions (prefix ends + # with an underscore). + connections = [ + self._conn("timeshift_80_2026-06-08-17-00_111", "timeshift_c80"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-08-17-00", + "timeshift_new", + ) + release_mock.assert_not_called() + unregister_mock.assert_not_called() + + def test_noop_without_redis_or_user(self): + with patch.object(views, "get_user_active_connections") as conns_mock: + views._terminate_previous_timeshift_sessions( + None, self.user, 8, "timeshift_8_ts", "timeshift_s" + ) + views._terminate_previous_timeshift_sessions( + self.redis, None, 8, "timeshift_8_ts", "timeshift_s" + ) + conns_mock.assert_not_called() + + def test_proxy_runs_takeover_before_stream_limit_check(self): + # Order matters: with terminate_on_limit_exceeded=False a seek must + # displace its own predecessor BEFORE the limit check counts it, or + # the user's own seek gets denied. + call_order = [] + request = RequestFactory().get(_proxy_url()) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "_terminate_previous_timeshift_sessions", + side_effect=lambda *a: call_order.append("takeover")) as takeover_mock, \ + patch.object(views, "check_user_stream_limits", + side_effect=lambda *a, **k: call_order.append("limits") or False): + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(call_order, ["takeover", "limits"]) + self.assertEqual(takeover_mock.call_args.args[2], 8) + + +class TimeshiftSessionReuseTests(TestCase): + """Per-client session pool acquire/reuse paths.""" + + SESSION = TEST_SESSION_ID + + def setUp(self): + self.redis = _FakeRedis() + self.factory = RequestFactory() + self.channel = MagicMock(id=8, name="Test") + self.user = MagicMock(id=5) + + def _pool_key(self): + return f"timeshift_pool:{self.SESSION}" + + def _make_idle_entry(self): + _seed_pool_session(self.redis, session_id=self.SESSION) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, self.SESSION, 31) + + def test_wait_returns_none_without_blocking_when_pool_empty(self): + start = time.monotonic() + acquired = views._wait_for_idle_pool_session(self.redis, self.SESSION) + self.assertIsNone(acquired) + self.assertLess(time.monotonic() - start, 0.5) + + def test_acquire_reuses_idle_entry_and_reserves_slot(self): + self._make_idle_entry() + profile = MagicMock(id=31) + with patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock: + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=5, + ) + self.assertIsNotNone(acquired) + descriptor, got_profile = acquired + self.assertEqual(descriptor["stream_id"], "111") + self.assertIs(got_profile, profile) + reserve_mock.assert_called_once_with(profile, self.redis) + self.assertEqual(self.redis.hget(self._pool_key(), "busy"), "1") + + def test_acquire_skips_busy_entry(self): + _seed_pool_session(self.redis, session_id=self.SESSION, busy="1") + with patch.object(views.M3UAccountProfile.objects, "get") as prof_mock, \ + patch.object(views, "reserve_profile_slot") as reserve_mock: + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=5, + ) + self.assertIsNone(acquired) + prof_mock.assert_not_called() + reserve_mock.assert_not_called() + + def test_acquire_rejects_foreign_user(self): + self._make_idle_entry() + profile = MagicMock(id=31) + with patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock: + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=99, + ) + self.assertIsNone(acquired) + reserve_mock.assert_not_called() + + def test_foreign_session_id_redirects_instead_of_reusing_pool(self): + victim_session = "timeshift_victim_session" + _seed_pool_session(self.redis, session_id=victim_session, user_id=99) + request = self.factory.get(_proxy_url(victim_session)) + attacker = MagicMock(id=5) + with patch.object(views, "_authenticate_user", return_value=attacker), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "_acquire_idle_pool_session") as acquire_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + self.assertIn("session_id=timeshift_", response["Location"]) + self.assertNotIn(victim_session, response["Location"]) + acquire_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_find_matching_idle_session_requires_ip_and_user_agent(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=5, client_ip="1.2.3.4", client_user_agent="test-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertEqual(matched, "timeshift_other") + + def test_find_matching_idle_session_rejects_ip_only_partial_fingerprint(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=5, client_ip="1.2.3.4", client_user_agent="other-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertIsNone(matched) + + def test_find_matching_idle_session_rejects_different_user(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=99, client_ip="1.2.3.4", client_user_agent="test-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertIsNone(matched) + + def test_legacy_pool_entry_exists_helper_removed(self): + self.assertFalse(hasattr(views, "_pool_entry_exists")) + + def test_new_session_uses_single_hgetall_before_pool_create(self): + redis = _FakeRedis() + request = self.factory.get(_proxy_url("timeshift_newsession1")) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "_find_matching_idle_session", return_value=None), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=MagicMock(status_code=200)), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]): + redis_cls.get_client.return_value = redis + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + with patch.object(redis, "hgetall", wraps=redis.hgetall) as hgetall_mock: + views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + pool_key = "timeshift_pool:timeshift_newsession1" + self.assertEqual( + sum(1 for c in hgetall_mock.call_args_list if c.args == (pool_key,)), + 1, + ) + + +class TimeshiftSessionRedirectTests(TestCase): + """First request must establish a session via 301 redirect (VOD-style).""" + + def setUp(self): + self.factory = RequestFactory() + + def test_missing_session_id_redirects(self): + request = self.factory.get(_proxy_url(session_id=None)) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + self.assertIn("session_id=timeshift_", response["Location"]) + + def test_redirect_preserves_existing_query_params(self): + request = self.factory.get( + "/timeshift/u/p/8/2026-06-08:17-00/8.ts?foo=bar&baz=1", + ) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + location = response["Location"] + self.assertIn("session_id=timeshift_", location) + self.assertIn("foo=bar", location) + self.assertIn("baz=1", location) + + +class TimeshiftStreamLimitExemptionTests(TestCase): + """Timeshift stream-limit bypass requires the same client session.""" + + MEDIA = TEST_MEDIA_ID + + def setUp(self): + self.user = MagicMock(id=5, username="viewer", stream_limit=1) + + def _limits_settings(self, ignore_same_channel=True): + return { + "ignore_same_channel_connections": ignore_same_channel, + "terminate_on_limit_exceeded": False, + } + + def test_same_session_probe_allowed_at_limit(self): + connections = [{ + "media_id": f"{self.MEDIA}_111", + "client_id": TEST_SESSION_ID, + "connected_at": 0.0, + "type": "timeshift", + }] + with patch("apps.proxy.utils.get_user_active_connections", + return_value=connections), \ + patch("apps.proxy.utils.CoreSettings.get_user_limits_settings", + return_value=self._limits_settings()): + allowed = _check_user_stream_limits( + self.user, TEST_SESSION_ID, media_id=self.MEDIA, + ) + self.assertTrue(allowed) + + def test_different_session_same_programme_counts_against_limit(self): + connections = [{ + "media_id": f"{self.MEDIA}_111", + "client_id": "timeshift_other_session", + "connected_at": 0.0, + "type": "timeshift", + }] + with patch("apps.proxy.utils.get_user_active_connections", + return_value=connections), \ + patch("apps.proxy.utils.CoreSettings.get_user_limits_settings", + return_value=self._limits_settings()): + allowed = _check_user_stream_limits( + self.user, TEST_SESSION_ID, media_id=self.MEDIA, + ) + self.assertFalse(allowed) + + +class FakeRedisScanTests(TestCase): + """FakeRedis SCAN matches redis-py glob semantics used by the pool scanner.""" + + def setUp(self): + self.redis = _FakeRedis() + self.redis.store["timeshift_pool:timeshift_a"] = {"busy": "0"} + self.redis.store["timeshift_pool:timeshift_b"] = {"busy": "0"} + self.redis.store["timeshift_pool:other_c"] = {"busy": "0"} + self.redis.store["vod_persistent_connection:x"] = {} + + def test_scan_glob_filters_pool_keys(self): + cursor = 0 + seen = [] + while True: + cursor, keys = self.redis.scan( + cursor, match="timeshift_pool:timeshift_*", count=1, + ) + seen.extend(keys) + if cursor == 0: + break + self.assertEqual( + seen, + ["timeshift_pool:timeshift_a", "timeshift_pool:timeshift_b"], + ) + + +class TimeshiftRangeClassificationTests(TestCase): + """Startup probes must not be treated as scrubs.""" + + def test_full_file_request_is_not_displacing(self): + self.assertFalse(views._should_displace_busy_playback(None)) + + def test_bytes_zero_displaces_full_file_probe(self): + self.assertTrue( + views._should_displace_busy_playback("bytes=0-", busy_serving_range="none") + ) + + def test_bytes_zero_does_not_displace_active_start_stream(self): + self.assertFalse( + views._should_displace_busy_playback("bytes=0-", busy_serving_range="start") + ) + + def test_bytes_zero_without_busy_context_is_not_displacing(self): + self.assertFalse(views._should_displace_busy_playback("bytes=0-")) + + def test_near_eof_probe_is_not_displacing(self): + self.assertTrue(views._is_near_eof_probe("bytes=2527702896-")) + self.assertFalse(views._should_displace_busy_playback("bytes=2527702896-")) + + def test_near_eof_probe_uses_cached_content_length(self): + # 5 MB into a 10 MB file is a scrub, not a tail probe. + self.assertFalse( + views._is_near_eof_probe("bytes=5000000-", content_length="10000000") + ) + self.assertTrue( + views._should_displace_busy_playback("bytes=5000000-", content_length="10000000") + ) + # Within 512 KB of EOF is a tail probe once length is known. + self.assertTrue( + views._is_near_eof_probe("bytes=9990000-", content_length="10000000") + ) + + def test_midfile_seek_is_displacing(self): + self.assertTrue(views._should_displace_busy_playback("bytes=5000000-")) + + def test_small_nonzero_range_is_displacing(self): + self.assertTrue(views._should_displace_busy_playback("bytes=1000-")) + + +class TimeshiftScrubPreemptTests(TestCase): + """Scrub/range requests must stop the in-flight stream and reuse the pooled + provider slot instead of opening parallel upstream connections.""" + + def setUp(self): + self.redis = _FakeRedis() + self.user = MagicMock(id=5) + self.factory = RequestFactory() + + def _conn(self, media_id, client_id): + return { + "media_id": media_id, + "client_id": client_id, + "connected_at": 0.0, + "type": "timeshift", + } + + def test_preempt_stops_sibling_clients_of_same_playback(self): + connections = [ + self._conn(f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID), + self._conn("timeshift_9_2026-06-08-17-00_222", "timeshift_other"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._preempt_playback_streams(self.redis, TEST_SESSION_ID, self.user) + unregister_mock.assert_called_once_with( + self.redis, f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID, + ) + from apps.proxy.live_proxy.redis_keys import RedisKeys + stop_key = RedisKeys.client_stop(f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID) + self.assertIn(stop_key, self.redis.store) + + def test_preempt_leaves_other_playbacks_alone(self): + connections = [ + self._conn("timeshift_8_2026-06-09-20-00_111", "timeshift_other_pos"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._preempt_playback_streams(self.redis, TEST_SESSION_ID, self.user) + unregister_mock.assert_not_called() + + def test_busy_pool_returns_503_instead_of_second_provider_connection(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=1000-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_wait_for_idle_pool_session", return_value=None), \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_called_once() + attempt_mock.assert_not_called() + self.assertEqual(len(self._pool_entry_ids()), 1) + + def _pool_entry_ids(self): + return [k for k in self.redis.store if k.startswith("timeshift_pool:")] + + def test_startup_bytes_zero_deferred_without_preempt(self): + _seed_pool_session( + self.redis, session_id=TEST_SESSION_ID, serving_range="start", + ) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=0-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_eof_probe_deferred_without_preempt(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=2527702896-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_create_pool_session_rejects_duplicate_entry(self): + first = views._create_pool_session( + self.redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + account_id=1, + profile_id=31, + stream_id="111", + provider_timestamp="2026", + ) + second = views._create_pool_session( + self.redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + account_id=2, + profile_id=41, + stream_id="222", + provider_timestamp="2026", + ) + self.assertTrue(first) + self.assertFalse(second) + self.assertTrue(self.redis.exists(f"timeshift_pool:{TEST_SESSION_ID}")) + + def test_scrub_reuses_idle_pool_without_opening_failover(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, TEST_SESSION_ID, 31) + + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=5000-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + profile = MagicMock(id=31) + ok = MagicMock(status_code=206) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock, \ + patch.object(views, "release_profile_slot"), \ + patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_stream_reused_session", return_value=ok) as reuse_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertIs(response, ok) + preempt_mock.assert_not_called() + reuse_mock.assert_called_once() + attempt_mock.assert_not_called() + # Pool acquire re-reserves the idle slot once; failover must not add another. + reserve_mock.assert_called_once_with(profile, self.redis) + + + +class RollupSelfHealDbTests(TestCase): + """Catch-up flag consistency after stream removal. + + The ChannelStream signal handles bulk deletes (locked by a regression test). + The account-scoped rollup self-heals stale flags on channels still linked + to that account. + """ + + @classmethod + def setUpTestData(cls): + from apps.m3u.models import M3UAccount + + cls.account = M3UAccount.objects.create( + name="ts-rollup-account", server_url="http://example.test", + account_type="XC", is_active=True, + ) + + def _make_channel_with_catchup_stream(self, name, days=5): + from apps.channels.models import Channel, ChannelStream, Stream + + channel = Channel.objects.create(name=name) + stream = Stream.objects.create( + name=f"{name}-stream", url=f"http://example.test/{name}", + m3u_account=self.account, is_catchup=True, catchup_days=days, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + return channel, stream + + def test_bulk_stream_delete_resets_channel_flags_via_signal(self): + # cleanup_streams() removes stale streams with a queryset bulk delete; + # the cascaded ChannelStream rows still fire post_delete (signal + # listeners disable Django's fast-delete path), which must reset the + # channel's denormalized catch-up fields. + from apps.channels.models import Stream + + channel, stream = self._make_channel_with_catchup_stream("ts-rollup-bulk") + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 5) + + Stream.objects.filter(id=stream.id).delete() + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_self_heals_stale_channel_with_non_catchup_stream(self): + # Channel still linked to the account but no active catch-up streams + # (e.g. catch-up flag cleared on import). Rollup must reset stale flags. + from apps.channels.models import Channel, ChannelStream, Stream + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel = Channel.objects.create(name="ts-rollup-stale") + stream = Stream.objects.create( + name="ts-rollup-stale-stream", + url="http://example.test/ts-rollup-stale", + m3u_account=self.account, + is_catchup=False, + catchup_days=0, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(self.account.id) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_self_heal_skips_channels_not_linked_to_account(self): + from apps.channels.models import Channel + from apps.m3u.models import M3UAccount + from apps.m3u.tasks import rollup_channel_catchup_fields + + other_account = M3UAccount.objects.create( + name="ts-rollup-other", + server_url="http://example.test/other", + account_type="XC", + is_active=True, + ) + channel = Channel.objects.create(name="ts-rollup-unrelated") + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(other_account.id) + + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 9) + + def test_rollup_keeps_and_corrects_channels_with_catchup_streams(self): + # The self-heal pass must not touch channels that legitimately have + # catch-up streams. The account-scoped pass still corrects their values. + from apps.channels.models import Channel + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel, _ = self._make_channel_with_catchup_stream("ts-rollup-valid", days=7) + # Knock the denormalized values out of sync (bypasses signals). + Channel.objects.filter(pk=channel.pk).update(is_catchup=False, catchup_days=0) + + rollup_channel_catchup_fields(self.account.id) + + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 7) diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py new file mode 100644 index 00000000..0ba3a5a7 --- /dev/null +++ b/apps/timeshift/views.py @@ -0,0 +1,1355 @@ +"""XC catch-up (timeshift) proxy with multi-provider failover.""" + +import hmac +import itertools +import logging +import secrets +import time +from urllib.parse import urlencode + +import requests +from django.core.cache import cache +from django.http import ( + Http404, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + StreamingHttpResponse, +) + +from apps.accounts.models import User +from apps.channels.models import Channel +from apps.channels.utils import get_channel_catchup_streams +from apps.m3u.connection_pool import release_profile_slot, reserve_profile_slot +from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.m3u.tasks import get_transformed_credentials +from apps.proxy.live_proxy.config_helper import ConfigHelper +from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState +from apps.proxy.live_proxy.redis_keys import RedisKeys +from apps.proxy.live_proxy.utils import get_client_ip +from apps.proxy.utils import ( + check_user_stream_limits, + find_ts_sync, + get_user_active_connections, +) +from core.utils import RedisClient +from dispatcharr.utils import network_access_allowed + +from .helpers import ( + TimeshiftCredentials, + build_timeshift_candidate_urls, + convert_timestamp_to_provider_tz, + get_programme_duration, + parse_catchup_timestamp, +) + +logger = logging.getLogger(__name__) + +CLIENT_TTL_SECONDS = 60 +_MATCH_SCORE_THRESHOLD = 8 # client_ip (5) + client_user_agent (3) + + +def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id + """Proxy an XC catch-up request to the provider with multi-stream failover. + + URL shape (iPlayTV / TiviMate): + ``stream_id``: EPG channel number (ignored here). + ``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id). + ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM`` or XC colon form + ``YYYY-MM-DD:HH:MM:SS``). + """ + raw_id = duration[:-3] if duration.endswith(".ts") else duration + + user = _authenticate_user(username, password) + if user is None: + return HttpResponseForbidden("Invalid credentials") + + if not network_access_allowed(request, "XC_API", user): + return HttpResponseForbidden("Access denied") + + try: + channel = Channel.objects.get(id=int(raw_id)) + except (Channel.DoesNotExist, ValueError, TypeError): + raise Http404("Channel not found") from None + + if not _user_can_access_channel(user, channel): + return HttpResponseForbidden("Access denied") + + # Shape helpers pass through on parse failure; reject bad input before upstream. + if parse_catchup_timestamp(timestamp) is None: + return HttpResponseBadRequest("Invalid timestamp") + + catchup_streams = get_channel_catchup_streams(channel) + if not catchup_streams: + return HttpResponseBadRequest("Timeshift not supported for this channel") + + debug = logger.isEnabledFor(logging.DEBUG) + + # EPG duration lookup stays in UTC; provider TZ conversion is per-attempt below. + duration_minutes = get_programme_duration(channel, timestamp) + + safe_ts = timestamp.replace(":", "-").replace("/", "-") + client_ip = get_client_ip(request) + client_user_agent = request.META.get("HTTP_USER_AGENT", "") or "" + range_header = request.META.get("HTTP_RANGE") + channel_logo_id = getattr(channel, "logo_id", None) + + redis_client = RedisClient.get_client() + + # Content identity (channel + catch-up position). Provider slot sharing is + # scoped per client session; never assume all requests for the same + # programme belong to one viewer. + media_id = f"timeshift_{channel.id}_{safe_ts}" + + session_id = request.GET.get("session_id") + if not session_id: + logger.debug("Timeshift session redirect: %s (new session)", request.path) + return _redirect_with_new_session(request) + + session_entry = _get_pool_entry(redis_client, session_id) + if session_entry and not _pool_entry_owned_by_user(session_entry, user.id): + logger.info( + "Timeshift: rejecting foreign session_id for user %s", user.id, + ) + return _redirect_with_new_session(request) + + # Stable client identity for stats, stop keys, and the provider pool. + effective_session_id = session_id + client_id = session_id + + # Reuse an idle pool owned by this session, or fingerprint-match a prior + # idle session from the same client (VOD-style) before opening upstream. + if not session_entry: + matched = _find_matching_idle_session( + redis_client, + media_id=media_id, + user_id=user.id, + client_ip=client_ip, + client_user_agent=client_user_agent, + ) + if matched: + logger.info( + "Timeshift fingerprint matched idle session %s for %s", + matched, session_id, + ) + effective_session_id = matched + client_id = matched + + if debug: + if effective_session_id != session_id: + logger.debug( + "Timeshift request: channel=%s media=%s session=%s " + "effective=%s user=%s range=%s ip=%s", + channel.name, media_id, session_id, effective_session_id, + user.id, range_header or "(none)", client_ip, + ) + else: + logger.debug( + "Timeshift request: channel=%s media=%s session=%s " + "user=%s range=%s ip=%s", + channel.name, media_id, effective_session_id, user.id, + range_header or "(none)", client_ip, + ) + + # Displace this user's prior catch-up on other positions of this channel. + _terminate_previous_timeshift_sessions( + redis_client, user, channel.id, media_id, effective_session_id, + ) + + if not check_user_stream_limits(user, client_id, media_id=media_id): + return HttpResponseForbidden("Stream limit exceeded") + + if effective_session_id == session_id: + pool = _snapshot_from_entry(session_entry) + else: + pool = _pool_snapshot(redis_client, effective_session_id) + pool_exists = pool is not None + pool_busy = pool["busy"] if pool else False + pool_content_length = pool["content_length"] if pool else None + busy_serving_range = pool["serving_range"] if pool else None + + acquired = None + if pool_exists: + if pool_busy: + if _should_displace_busy_playback( + range_header, pool_content_length, busy_serving_range, + ): + _preempt_playback_streams(redis_client, effective_session_id, user) + acquired = _wait_for_idle_pool_session( + redis_client, + effective_session_id, + user_id=user.id, + wait_seconds=_POOL_PREEMPT_WAIT_SECONDS, + ) + else: + acquired = _acquire_idle_pool_session( + redis_client, effective_session_id, user_id=user.id, + ) + + if acquired is not None: + descriptor, profile = acquired + reuse_response = _stream_reused_session( + redis_client, + session_id=effective_session_id, + descriptor=descriptor, + profile=profile, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, + duration_minutes=duration_minutes, + client_id=client_id, + client_ip=client_ip, + range_header=range_header, + channel_logo_id=channel_logo_id, + user=user, + debug=debug, + ) + if reuse_response is not None: + return reuse_response + + if pool_exists and pool_busy and not _should_displace_busy_playback( + range_header, pool_content_length, busy_serving_range, + ): + logger.debug( + "Timeshift: deferring non-displacing request for session %s range=%s", + effective_session_id, range_header or "(none)", + ) + return HttpResponse("Stream slot busy", status=503) + + if pool_exists and pool_busy: + logger.warning( + "Timeshift: session %s did not become idle in time", + effective_session_id, + ) + return HttpResponse("Stream slot busy", status=503) + + last_response = None + decisive_accounts = set() + capacity_blocked = False + for catchup_stream in catchup_streams: + m3u_account = catchup_stream.m3u_account + if m3u_account is None or m3u_account.account_type != "XC": + continue + if m3u_account.id in decisive_accounts: + continue + + stream_id_value = (catchup_stream.custom_properties or {}).get("stream_id") + if stream_id_value is None: + continue + + m3u_profiles = list(m3u_account.profiles.filter(is_active=True)) + default_profile = next((p for p in m3u_profiles if p.is_default), None) + if default_profile is None: + logger.debug( + "Timeshift: account %s has no active default profile, skipping", + m3u_account.id, + ) + continue + profile_walk = [default_profile] + [ + p for p in m3u_profiles if not p.is_default + ] + + # Providers index archives in their own timezone (from server_info on auth). + provider_tz_name = None + _server_info = (default_profile.custom_properties or {}).get("server_info") or {} + if isinstance(_server_info, dict): + provider_tz_name = _server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) + + # Reserve a provider profile slot before connecting (same contract as live/VOD). + reserved_profile = None + for profile in profile_walk: + if redis_client is None: + reserved_profile = profile + break + reserved, _count, reason = reserve_profile_slot(profile, redis_client) + if reserved: + reserved_profile = profile + break + logger.info( + "Timeshift: profile %s %s on account %s, trying next profile", + profile.id, reason or "unavailable", m3u_account.id, + ) + if reserved_profile is None: + capacity_blocked = True + logger.warning( + "Timeshift: all profiles at capacity on account %s for channel %s", + m3u_account.id, channel.name, + ) + continue + + if not _create_pool_session( + redis_client, + session_id=effective_session_id, + media_id=media_id, + user_id=user.id, + client_ip=client_ip, + client_user_agent=client_user_agent, + account_id=m3u_account.id, + profile_id=reserved_profile.id, + stream_id=stream_id_value, + provider_timestamp=provider_timestamp, + ): + try: + release_profile_slot(reserved_profile.id, redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed after pool race on profile %s: %s", + reserved_profile.id, exc, + ) + logger.debug( + "Timeshift: pool entry already exists for session %s, deferring", + effective_session_id, + ) + return HttpResponse("Stream slot busy", status=503) + release_cb = _make_release_once( + redis_client, effective_session_id, reserved_profile.id + ) + + try: + response = _attempt_timeshift_stream( + m3u_account=m3u_account, + profile=reserved_profile, + stream_id_value=stream_id_value, + provider_timestamp=provider_timestamp, + provider_tz_name=provider_tz_name, + duration_minutes=duration_minutes, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, + client_id=client_id, + client_ip=client_ip, + range_header=range_header, + channel_logo_id=channel_logo_id, + user=user, + redis_client=redis_client, + debug=debug, + release_cb=release_cb, + pool_session_id=effective_session_id, + ) + except Exception: + _discard_pool_session(redis_client, effective_session_id, reserved_profile.id) + raise + if response.status_code < 400: + # Streaming: the generator's close path frees the slot via release_cb. + return response + + if getattr(response, "timeshift_passthrough", False) is True: + # Terminal range answer (e.g. 416 past EOF): the upstream session is + # healthy, so free the slot but keep the entry idle for the next + # probe, and return verbatim without failing over to other accounts. + release_cb() + return response + + # Real failure: drop this session entirely and fail over. + _discard_pool_session(redis_client, effective_session_id, reserved_profile.id) + last_response = response + if getattr(response, "timeshift_decisive", False): + decisive_accounts.add(m3u_account.id) + logger.warning( + "Timeshift attempt failed (HTTP %d%s) on account %s for channel %s, " + "trying next catch-up stream", + response.status_code, + ", decisive: skipping this account's other streams" + if m3u_account.id in decisive_accounts else "", + m3u_account.id, channel.name, + ) + + if last_response is not None: + return last_response + if capacity_blocked: + return HttpResponse("No available stream slot", status=503) + return HttpResponseBadRequest("Cannot build timeshift URL") + + +def _authenticate_user(username, password): + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return None + expected = (user.custom_properties or {}).get("xc_password") + if not expected: + return None + if not hmac.compare_digest(str(expected), str(password)): + return None + return user + + +def _user_can_access_channel(user, channel): + if user.user_level < channel.user_level: + return False + if user.user_level >= User.UserLevel.ADMIN: + return True + profile_count = user.channel_profiles.count() + if profile_count == 0: + return True + return ( + type(channel).objects.filter( + id=channel.id, + channelprofilemembership__enabled=True, + channelprofilemembership__channel_profile__in=user.channel_profiles.all(), + ) + .exists() + ) + + +# Per-client session pool (keyed by session_id from the 301 redirect). Each +# viewer gets their own provider slot even when watching the same catch-up +# programme. Idle sessions can be fingerprint-matched (VOD-style) when a client +# returns without its prior session_id. +_POOL_KEY = "timeshift_pool:{session_id}" +_POOL_LOCK_KEY = "timeshift_pool_lock:{session_id}" +_POOL_ENTRY_TTL = 6 * 3600 +_POOL_IDLE_TTL = 30 +_POOL_WAIT_SECONDS = 1.0 +_POOL_PREEMPT_WAIT_SECONDS = 5.0 +_POOL_POLL_INTERVAL = 0.05 +_EOF_PROBE_TAIL_BYTES = 512_000 +_EOF_PROBE_UNKNOWN_LENGTH_MIN = 100_000_000 + + +def _pool_key(session_id): + return _POOL_KEY.format(session_id=session_id) + + +def _parse_range_start(range_header): + """Return the byte offset from a ``Range: bytes=START-`` header, or None.""" + if not range_header or not range_header.startswith("bytes="): + return None + start_part = range_header[6:].split("-", 1)[0] + if not start_part: + return None + try: + return int(start_part) + except (TypeError, ValueError): + return None + + +def _is_near_eof_probe(range_header, content_length=None): + """True for tail/duration probes IPTV clients fire during startup.""" + start = _parse_range_start(range_header) + if start is None: + return False + if content_length is not None: + try: + total = int(content_length) + except (TypeError, ValueError): + total = None + else: + return start >= max(0, total - _EOF_PROBE_TAIL_BYTES) + return start >= _EOF_PROBE_UNKNOWN_LENGTH_MIN + + +def _should_displace_busy_playback( + range_header, content_length=None, busy_serving_range=None, +): + """True when this request should stop the in-flight stream (actual scrub).""" + if not range_header: + return False + start = _parse_range_start(range_header) + if start is None: + return False + if _is_near_eof_probe(range_header, content_length): + return False + if start == 0: + # Only displace a known full-file probe; unknown busy context is not a scrub. + return busy_serving_range == "none" + return True + + +def _score_pool_fingerprint(entry, client_ip, client_user_agent): + """Score IP/UA overlap for fingerprint adoption (user and media pre-filtered).""" + score = 0 + if entry.get("client_ip") and entry.get("client_ip") == client_ip: + score += 5 + if entry.get("client_user_agent") and entry.get("client_user_agent") == client_user_agent: + score += 3 + return score + + +def _mint_timeshift_session_id(): + return f"timeshift_{secrets.token_urlsafe(16)}" + + +def _redirect_with_new_session(request): + session_id = _mint_timeshift_session_id() + query_params = {k: request.GET.getlist(k) for k in request.GET} + query_params["session_id"] = [session_id] + redirect_url = f"{request.path}?{urlencode(query_params, doseq=True)}" + return HttpResponse(status=301, headers={"Location": redirect_url}) + + +def _pool_entry_owned_by_user(entry, user_id): + """True when *entry* is unclaimed or owned by *user_id*.""" + if not entry or not entry.get("profile_id"): + return True + owner = entry.get("user_id") + if owner is None or owner == "": + return False + return str(owner) == str(user_id) + + +def _find_matching_idle_session( + redis_client, *, media_id, user_id, client_ip, client_user_agent, +): + """Find an idle pooled session that likely belongs to the same client.""" + if redis_client is None: + return None + matches = [] + try: + cursor = 0 + while True: + cursor, keys = redis_client.scan( + cursor, match="timeshift_pool:timeshift_*", count=100, + ) + for key in keys: + try: + data = redis_client.hgetall(key) + if not data or data.get("busy") == "1": + continue + if str(data.get("user_id") or "") != str(user_id): + continue + if str(data.get("media_id") or "") != str(media_id): + continue + session_id = key.rsplit(":", 1)[-1] + score = _score_pool_fingerprint( + data, client_ip, client_user_agent, + ) + if score >= _MATCH_SCORE_THRESHOLD: + last_activity = float(data.get("last_activity") or "0") + matches.append((session_id, score, last_activity)) + except Exception as exc: + logger.debug("Timeshift pool scan skip %s: %s", key, exc) + if cursor == 0: + break + except Exception as exc: + logger.warning("Timeshift idle session search failed: %s", exc) + return None + + if not matches: + return None + matches.sort(key=lambda item: (item[1], item[2]), reverse=True) + best = matches[0][0] + logger.debug( + "Timeshift idle match: session=%s score=%s media=%s", + best, matches[0][1], media_id, + ) + return best + + +def _get_pool_entry(redis_client, session_id): + if redis_client is None or not session_id: + return {} + try: + return redis_client.hgetall(_pool_key(session_id)) or {} + except Exception: + return {} + + +def _snapshot_from_entry(entry): + if not entry: + return None + busy = entry.get("busy") == "1" + return { + "entry": entry, + "busy": busy, + "serving_range": (entry.get("serving_range") or "none") if busy else None, + "content_length": entry.get("content_length"), + } + + +def _pool_snapshot(redis_client, session_id): + """Single HGETALL view of pool state for request handling.""" + return _snapshot_from_entry(_get_pool_entry(redis_client, session_id)) + + +def _store_pool_serving_range(redis_client, session_id, range_header): + if redis_client is None or not session_id: + return + start = _parse_range_start(range_header) + if not range_header: + serving_range = "none" + elif start == 0: + serving_range = "start" + else: + serving_range = "range" + try: + redis_client.hset(_pool_key(session_id), "serving_range", serving_range) + except Exception as exc: + logger.debug("Timeshift pool serving_range store failed: %s", exc) + + +def _store_pool_content_length(redis_client, session_id, upstream_response): + if redis_client is None or not session_id or upstream_response is None: + return + content_length = upstream_response.headers.get("Content-Length") + content_range = upstream_response.headers.get("Content-Range", "") + if content_range and "/" in content_range: + total = content_range.rsplit("/", 1)[-1] + if total != "*": + content_length = total + if not content_length: + return + try: + redis_client.hset( + _pool_key(session_id), "content_length", str(content_length), + ) + except Exception as exc: + logger.debug("Timeshift pool content_length store failed: %s", exc) + + +def _pool_lock(redis_client, session_id): + return redis_client.lock( + _POOL_LOCK_KEY.format(session_id=session_id), + timeout=10, + blocking_timeout=5, + ) + + +def _acquire_idle_pool_session(redis_client, session_id, *, user_id=None): + """Re-reserve an idle session's profile slot and mark it busy.""" + if redis_client is None or not session_id: + return None + key = _pool_key(session_id) + try: + with _pool_lock(redis_client, session_id): + data = redis_client.hgetall(key) + if not data or not data.get("profile_id"): + return None + if user_id is not None and not _pool_entry_owned_by_user(data, user_id): + return None + if data.get("busy") == "1": + return None + try: + profile = M3UAccountProfile.objects.get(id=int(data["profile_id"])) + except M3UAccountProfile.DoesNotExist: + redis_client.delete(key) + return None + reserved, _count, _reason = reserve_profile_slot(profile, redis_client) + if not reserved: + return None + redis_client.hset(key, mapping={ + "busy": "1", + "last_activity": str(time.time()), + }) + redis_client.expire(key, _POOL_ENTRY_TTL) + return dict(data), profile + except Exception as exc: + logger.warning("Timeshift pool acquire failed for %s: %s", session_id, exc) + return None + + +def _wait_for_idle_pool_session( + redis_client, session_id, *, user_id=None, wait_seconds=_POOL_WAIT_SECONDS, +): + if redis_client is None or not session_id: + return None + deadline = time.time() + wait_seconds + while True: + acquired = _acquire_idle_pool_session( + redis_client, session_id, user_id=user_id, + ) + if acquired is not None: + return acquired + if not _get_pool_entry(redis_client, session_id): + return None + if time.time() >= deadline: + return None + time.sleep(_POOL_POLL_INTERVAL) + + +def _create_pool_session( + redis_client, + *, + session_id, + media_id, + user_id, + client_ip, + client_user_agent, + account_id, + profile_id, + stream_id, + provider_timestamp, +): + """Register an already-reserved slot for this client session.""" + if redis_client is None or not session_id: + return False + key = _pool_key(session_id) + now = str(time.time()) + try: + with _pool_lock(redis_client, session_id): + if redis_client.exists(key): + return False + redis_client.hset(key, mapping={ + "media_id": str(media_id), + "user_id": str(user_id), + "client_ip": str(client_ip or ""), + "client_user_agent": str(client_user_agent or ""), + "account_id": str(account_id), + "profile_id": str(profile_id), + "stream_id": str(stream_id), + "provider_timestamp": str(provider_timestamp), + "busy": "1", + "last_activity": now, + }) + redis_client.expire(key, _POOL_ENTRY_TTL) + return True + except Exception as exc: + logger.warning("Timeshift pool create failed for %s: %s", session_id, exc) + return False + + +def _release_pool_session(redis_client, session_id, profile_id): + if redis_client is None: + return + if profile_id is not None: + try: + release_profile_slot(int(profile_id), redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed for profile %s: %s", profile_id, exc + ) + if not session_id: + return + key = _pool_key(session_id) + try: + with _pool_lock(redis_client, session_id): + if redis_client.exists(key): + redis_client.hset(key, mapping={ + "busy": "0", + "last_activity": str(time.time()), + }) + redis_client.expire(key, _POOL_IDLE_TTL) + except Exception as exc: + logger.warning("Timeshift pool release failed for %s: %s", session_id, exc) + + +def _discard_pool_session(redis_client, session_id, profile_id): + if redis_client is None: + return + if profile_id is not None: + try: + release_profile_slot(int(profile_id), redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed for profile %s: %s", profile_id, exc + ) + if not session_id: + return + try: + with _pool_lock(redis_client, session_id): + redis_client.delete(_pool_key(session_id)) + except Exception as exc: + logger.warning("Timeshift pool discard failed for %s: %s", session_id, exc) + + +def _make_release_once(redis_client, session_id, profile_id): + state = {"done": False} + + def _release(): + if state["done"]: + return + state["done"] = True + _release_pool_session(redis_client, session_id, profile_id) + + return _release + + +def _preempt_playback_streams(redis_client, session_id, user): + """Stop in-flight streams for this client session only.""" + if redis_client is None or not session_id or user is None: + return + try: + for conn in get_user_active_connections(user.id): + if conn.get("type") != "timeshift": + continue + if conn.get("client_id") != session_id: + continue + conn_media_id = str(conn.get("media_id") or "") + old_client_id = conn.get("client_id") + logger.debug( + "Timeshift preempt: stopping client %s on %s for reuse", + old_client_id, conn_media_id, + ) + _unregister_stats_client(redis_client, conn_media_id, old_client_id) + stop_key = RedisKeys.client_stop(conn_media_id, old_client_id) + redis_client.setex(stop_key, 60, "true") + except Exception as exc: + logger.warning("Timeshift preempt failed: %s", exc) + + +def _terminate_previous_timeshift_sessions( + redis_client, user, channel_id, current_media_id, current_session_id, +): + """Displace this user's other catch-up positions on the same channel.""" + if redis_client is None or user is None: + return + prefix = f"timeshift_{channel_id}_" + try: + for conn in get_user_active_connections(user.id): + if conn.get("type") != "timeshift": + continue + if conn.get("client_id") == current_session_id: + continue + conn_media_id = str(conn.get("media_id") or "") + if not conn_media_id.startswith(prefix): + continue + if conn_media_id.startswith(f"{current_media_id}_") or conn_media_id == current_media_id: + continue + old_client_id = conn.get("client_id") + logger.info( + "Timeshift takeover: displacing session %s on %s", + old_client_id, conn_media_id, + ) + _unregister_stats_client(redis_client, conn_media_id, old_client_id) + stop_key = RedisKeys.client_stop(conn_media_id, old_client_id) + redis_client.setex(stop_key, 60, "true") + except Exception as exc: + logger.warning("Timeshift takeover check failed: %s", exc) + + +def _attempt_timeshift_stream( + *, + m3u_account, + profile, + stream_id_value, + provider_timestamp, + provider_tz_name, + duration_minutes, + channel, + safe_ts, + timestamp, + client_id, + client_ip, + range_header, + channel_logo_id, + user, + redis_client, + debug, + release_cb=None, + pool_session_id=None, +): + """Build the provider URL set for one (account, profile, stream) and stream it.""" + server_url, xc_username, xc_password = get_transformed_credentials( + m3u_account, profile + ) + creds = TimeshiftCredentials(server_url, xc_username, xc_password) + candidate_urls = build_timeshift_candidate_urls( + creds, stream_id_value, provider_timestamp, duration_minutes + ) + + try: + user_agent = m3u_account.get_user_agent().user_agent + except AttributeError: + user_agent = "" + + virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" + + if debug: + logger.debug( + "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " + "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s", + channel.name, timestamp, provider_tz_name, provider_timestamp, + m3u_account.id, profile.id, stream_id_value, + virtual_channel_id, client_id, range_header or "(none)", + ) + + return _stream_from_provider( + candidate_urls=candidate_urls, + user_agent=user_agent, + range_header=range_header, + virtual_channel_id=virtual_channel_id, + client_id=client_id, + client_ip=client_ip, + user=user, + channel_display_name=channel.name, + timestamp_utc=timestamp, + channel_logo_id=channel_logo_id, + m3u_profile_id=profile.id, + debug=debug, + account_id=m3u_account.id, + redis_client=redis_client, + release_cb=release_cb, + pool_session_id=pool_session_id, + ) + + +def _stream_reused_session( + redis_client, + *, + session_id, + descriptor, + profile, + channel, + safe_ts, + timestamp, + duration_minutes, + client_id, + client_ip, + range_header, + channel_logo_id, + user, + debug, +): + """Stream an idle pooled session that was just re-reserved for this request.""" + try: + m3u_account = M3UAccount.objects.get(id=int(descriptor["account_id"])) + except (M3UAccount.DoesNotExist, ValueError, TypeError): + _discard_pool_session(redis_client, session_id, profile.id) + return None + + provider_timestamp = descriptor.get("provider_timestamp") + if not provider_timestamp: + provider_tz_name = None + server_info = (profile.custom_properties or {}).get("server_info") or {} + if isinstance(server_info, dict): + provider_tz_name = server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz( + timestamp, provider_tz_name + ) + + release_cb = _make_release_once(redis_client, session_id, profile.id) + try: + response = _attempt_timeshift_stream( + m3u_account=m3u_account, + profile=profile, + stream_id_value=descriptor["stream_id"], + provider_timestamp=provider_timestamp, + provider_tz_name=None, + duration_minutes=duration_minutes, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, + client_id=client_id, + client_ip=client_ip, + range_header=range_header, + channel_logo_id=channel_logo_id, + user=user, + redis_client=redis_client, + debug=debug, + release_cb=release_cb, + pool_session_id=session_id, + ) + except Exception: + _discard_pool_session(redis_client, session_id, profile.id) + raise + + if response.status_code < 400: + return response + + if getattr(response, "timeshift_passthrough", False) is True: + release_cb() + return response + + _discard_pool_session(redis_client, session_id, profile.id) + return None + + +class _SlotReleasingStream: + """Iterator wrapper that releases the pool slot when WSGI closes the response.""" + + def __init__(self, generator, on_close): + self._generator = generator + self._on_close = on_close + + def __iter__(self): + return self + + def __next__(self): + return next(self._generator) + + def close(self): + try: + self._generator.close() + finally: + self._on_close() + + +def _register_stats_client( + redis_client, + virtual_channel_id, + client_id, + client_ip, + user_agent, + user, + *, + channel_display_name, + timestamp_utc, + primary_url, + channel_logo_id=None, + m3u_profile_id=None, +): + """Write Redis keys so catch-up viewers appear on ``/stats``.""" + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + now = str(time.time()) + client_payload = { + "user_agent": user_agent or "unknown", + "ip_address": client_ip, + "connected_at": now, + "last_active": now, + "user_id": str(user.id) if user is not None else "0", + "username": user.username if user is not None else "unknown", + } + metadata_payload = { + ChannelMetadataField.STATE: ChannelState.ACTIVE, + ChannelMetadataField.INIT_TIME: now, + ChannelMetadataField.OWNER: "timeshift", + ChannelMetadataField.CHANNEL_NAME: channel_display_name or "Timeshift", + ChannelMetadataField.STREAM_NAME: f"Catch-up @ {timestamp_utc} UTC" if timestamp_utc else "Catch-up", + ChannelMetadataField.URL: _redact_url(primary_url) if primary_url else "", + ChannelMetadataField.IS_TIMESHIFT: "1", + } + if channel_logo_id is not None: + metadata_payload[ChannelMetadataField.LOGO_ID] = str(channel_logo_id) + if m3u_profile_id is not None: + metadata_payload[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, mapping=client_payload) + pipe.expire(client_key, CLIENT_TTL_SECONDS) + pipe.sadd(client_set_key, client_id) + pipe.expire(client_set_key, CLIENT_TTL_SECONDS) + pipe.hset(metadata_key, mapping=metadata_payload) + pipe.expire(metadata_key, CLIENT_TTL_SECONDS) + pipe.execute() + except Exception as exc: + logger.warning("Timeshift stats register failed: %s", exc) + + +def _heartbeat_stats_client(redis_client, virtual_channel_id, client_id, bytes_delta=0): + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, "last_active", str(time.time())) + pipe.expire(client_key, CLIENT_TTL_SECONDS) + pipe.expire(client_set_key, CLIENT_TTL_SECONDS) + if bytes_delta > 0: + pipe.hincrby(metadata_key, ChannelMetadataField.TOTAL_BYTES, bytes_delta) + pipe.expire(metadata_key, CLIENT_TTL_SECONDS) + pipe.execute() + except Exception as exc: + logger.debug("Timeshift stats heartbeat failed: %s", exc) + + +def _unregister_stats_client(redis_client, virtual_channel_id, client_id): + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + try: + redis_client.srem(client_set_key, client_id) + redis_client.delete(client_key) + if (redis_client.scard(client_set_key) or 0) == 0: + redis_client.delete(client_set_key) + redis_client.delete(metadata_key) + except Exception as exc: + logger.warning("Timeshift stats unregister failed: %s", exc) + + +def _open_upstream(url, user_agent, range_header): + """Open upstream HTTP; redirects are followed (XC load-balancer nodes).""" + # identity: raw peek bytes are not gzip-transparent. + headers = {"Accept-Encoding": "identity"} + if user_agent: + headers["User-Agent"] = user_agent + if range_header: + headers["Range"] = range_header + return requests.get( + url, + headers=headers, + stream=True, + timeout=ConfigHelper.connection_timeout(), + ) + + +_FORMAT_CACHE_KEY = "timeshift:format_idx:{}" +_FORMAT_CACHE_TTL = 3600 # 1 hour + + +def _get_cached_format_index(account_id): + """Index of the URL shape that last worked for this account, or None.""" + if account_id is None: + return None + return cache.get(_FORMAT_CACHE_KEY.format(account_id)) + + +def _set_cached_format_index(account_id, index): + if account_id is None: + return + cache.set(_FORMAT_CACHE_KEY.format(account_id), index, _FORMAT_CACHE_TTL) + + +def _passthrough_response(status, content_range=None): + """A terminal response handed straight to the client (no streaming). + + Marked so the failover loop and reuse path return it verbatim instead of + cascading other URL shapes or failing over to another provider. + """ + response = HttpResponse(status=status) + if content_range: + response["Content-Range"] = content_range + response.timeshift_passthrough = True + return response + + +def _stream_from_provider( + *, + candidate_urls, + user_agent, + range_header, + virtual_channel_id, + client_id, + client_ip, + user, + channel_display_name, + timestamp_utc, + channel_logo_id, + m3u_profile_id, + debug, + account_id=None, + redis_client=None, + release_cb=None, + pool_session_id=None, +): + """Try each upstream URL until one returns streamable MPEG-TS. + + Sets ``timeshift_decisive`` on auth/ban-class failures (401/403/406) so the + failover loop skips the rest of that account's streams. ``release_cb`` frees + the provider slot when the streaming response is closed. + """ + chunk_size = max(ConfigHelper.chunk_size(), 262144) + if release_cb is None: + release_cb = lambda: None # noqa: E731 + + cached_index = _get_cached_format_index(account_id) + if cached_index is not None and 0 <= cached_index < len(candidate_urls): + ordered_urls = [candidate_urls[cached_index]] + [ + u for i, u in enumerate(candidate_urls) if i != cached_index + ] + original_indices = [cached_index] + [ + i for i in range(len(candidate_urls)) if i != cached_index + ] + else: + ordered_urls = list(candidate_urls) + original_indices = list(range(len(candidate_urls))) + + # Peek for MPEG-TS sync; some providers return HTTP 200 with PHP/HTML errors. + upstream = None + last_status = None + last_url = ordered_urls[0] + winning_index = None + decisive_failure = False + for url, orig_idx in zip(ordered_urls, original_indices): + try: + response = _open_upstream(url, user_agent, range_header) + except requests.exceptions.RequestException as exc: + logger.error( + "Timeshift provider unreachable (%s): %s", + _redact_url(url), type(exc).__name__, + ) + return HttpResponseBadRequest("Provider connection error") + last_status = response.status_code + last_url = url + if debug: + logger.debug( + "Timeshift cascade[%d]: status=%d type=%s url=%s", + orig_idx, response.status_code, + response.headers.get("Content-Type", "?"), + _redact_url(url), + ) + if response.status_code == 416: + # Range Not Satisfiable: a seek/tail probe past EOF. Hand it back to + # the client verbatim. Byte offsets are file-specific, so trying + # other URL shapes or failing over to another provider is pointless + # and only multiplies upstream connections. + content_range = response.headers.get("Content-Range") + response.close() + return _passthrough_response(416, content_range) + if response.status_code in (200, 206): + peek = response.raw.read(1024) + sync_offset = find_ts_sync(peek) if peek else -1 + if sync_offset >= 0: + response._peek_data = peek[sync_offset:] + upstream = response + winning_index = orig_idx + break + # A 206 to a Range request legitimately starts mid-packet, so the + # sync byte rarely lands at offset 0. Trust the partial status and + # content type rather than the sync probe; only a full 200 carrying + # a PHP/HTML error page must be rejected here. + content_type = response.headers.get("Content-Type", "") + is_partial = response.status_code == 206 and bool(range_header) + if is_partial and peek and "html" not in content_type and "json" not in content_type: + response._peek_data = peek + upstream = response + winning_index = orig_idx + break + snippet = peek[:200].decode("utf-8", errors="replace") if peek else "(empty)" + logger.warning( + "Timeshift upstream returned %d but no TS sync in first %d " + "bytes (likely PHP error): %s, url=%s", + response.status_code, + len(peek) if peek else 0, + snippet.replace("\n", " ")[:120], + _redact_url(url), + ) + response.close() + last_status = 404 # Treat as soft rejection for cascade + continue + response.close() + # Auth/ban-class statuses stop trying more shapes on this account; 5xx does not. + code = response.status_code + if code in (401, 403, 406) or 300 <= code < 400: + decisive_failure = True + break + + if winning_index is not None: + _set_cached_format_index(account_id, winning_index) + + if upstream is None: + logger.error("Timeshift upstream rejected: status=%s url=%s", + last_status, _redact_url(last_url)) + # Map 404/403 to meaningful client responses; other failures stay 400. + if last_status == 404: + failure = HttpResponseNotFound("Catch-up not available yet") + elif last_status == 403: + failure = HttpResponseForbidden("Provider denied access") + else: + failure = HttpResponseBadRequest("Provider error") + failure.timeshift_decisive = decisive_failure + return failure + + content_type = upstream.headers.get("Content-Type", "video/mp2t") + content_range = upstream.headers.get("Content-Range", "") + status = upstream.status_code + + _store_pool_content_length(redis_client, pool_session_id, upstream) + _store_pool_serving_range(redis_client, pool_session_id, range_header) + + _register_stats_client( + redis_client, + virtual_channel_id, + client_id, + client_ip, + user_agent, + user, + channel_display_name=channel_display_name, + timestamp_utc=timestamp_utc, + primary_url=last_url, + channel_logo_id=channel_logo_id, + m3u_profile_id=m3u_profile_id, + ) + + peek_data = getattr(upstream, "_peek_data", None) + chunks_iter = upstream.iter_content(chunk_size=chunk_size) + if peek_data: + chunks_iter = itertools.chain([peek_data], chunks_iter) + + session_closed = {"done": False} + + def _finish_session(*, close_upstream=False): + if session_closed["done"]: + return + session_closed["done"] = True + if close_upstream: + try: + upstream.close() + except Exception: + pass + _unregister_stats_client(redis_client, virtual_channel_id, client_id) + release_cb() + + def stream_generator(): + last_heartbeat = time.time() + bytes_since_heartbeat = 0 + total_yielded = 0 + loop_start = time.time() + stop_key = RedisKeys.client_stop(virtual_channel_id, client_id) + stream_started_logged = False + try: + for data in chunks_iter: + if not data: + continue + if debug and not stream_started_logged: + stream_started_logged = True + logger.debug( + "Timeshift stream started: client=%s vid=%s range=%s status=%d", + client_id, virtual_channel_id, range_header or "(none)", status, + ) + yield data + bytes_since_heartbeat += len(data) + total_yielded += len(data) + + now = time.time() + if redis_client and redis_client.exists(stop_key): + logger.info("Timeshift client %s received stop signal", client_id) + redis_client.delete(stop_key) + break + # Refresh stats every 5 seconds. + if now - last_heartbeat >= 5: + if debug and total_yielded > 0: + elapsed = now - loop_start + mbps = (total_yielded * 8) / elapsed / 1_000_000 if elapsed > 0 else 0 + logger.debug( + "Timeshift streaming: client=%s range=%s total=%d bytes " + "in %.1fs (%.2f Mbps avg)", + client_id, range_header or "(none)", + total_yielded, elapsed, mbps, + ) + _heartbeat_stats_client( + redis_client, virtual_channel_id, client_id, + bytes_delta=bytes_since_heartbeat, + ) + last_heartbeat = now + bytes_since_heartbeat = 0 + except GeneratorExit: + pass + except Exception: + logger.exception("Timeshift stream loop error") + finally: + elapsed = time.time() - loop_start + if bytes_since_heartbeat > 0: + _heartbeat_stats_client( + redis_client, virtual_channel_id, client_id, + bytes_delta=bytes_since_heartbeat, + ) + if debug and total_yielded > 0: + mbps = (total_yielded * 8) / elapsed / 1_000_000 if elapsed > 0 else 0 + logger.debug( + "Timeshift disconnect: vid=%s client=%s yielded=%d bytes in %.1fs (%.2f Mbps avg)", + virtual_channel_id, client_id, total_yielded, elapsed, mbps, + ) + _finish_session(close_upstream=True) + + stream_iter = _SlotReleasingStream(stream_generator(), _finish_session) + response = StreamingHttpResponse( + stream_iter, + content_type=content_type, + status=status, + ) + response["X-Accel-Buffering"] = "no" # avoid nginx throttling the stream + if content_range: + response["Content-Range"] = content_range + response["Accept-Ranges"] = "bytes" + return response + + +def _redact_url(url): + """Truncate *url* to ``scheme://host/...`` for safe logging (drops credentials).""" + if not url or "://" not in url: + return url + scheme, rest = url.split("://", 1) + if "@" in rest: + rest = rest.split("@", 1)[1] + host = rest.split("/", 1)[0] + return f"{scheme}://{host}/..." diff --git a/core/api_views.py b/core/api_views.py index ec66d69a..ee7d1db1 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -225,7 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, } settings_obj, created = CoreSettings.objects.get_or_create( diff --git a/core/migrations/0026_add_channel_client_wait_period.py b/core/migrations/0026_add_channel_client_wait_period.py new file mode 100644 index 00000000..6119eb66 --- /dev/null +++ b/core/migrations/0026_add_channel_client_wait_period.py @@ -0,0 +1,51 @@ +from django.db import migrations + +PROXY_SETTINGS_KEY = "proxy_settings" + + +def add_channel_client_wait_period(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) + except CoreSettings.DoesNotExist: + return + + value = obj.value if isinstance(obj.value, dict) else {} + + # Add the new client-connect grace period default. + value.setdefault("channel_client_wait_period", 5) + + # channel_init_grace_period was repurposed in 0.27.1 as the channel startup + # timeout (replacing a hardcoded 10s). Values below the new 60s default are + # too short when a channel has many failover streams to cycle through. + current_init = value.get("channel_init_grace_period", 5) + if current_init < 60: + value["channel_init_grace_period"] = 60 + + obj.value = value + obj.save() + + +def remove_channel_client_wait_period(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + try: + obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) + except CoreSettings.DoesNotExist: + return + + value = obj.value if isinstance(obj.value, dict) else {} + value.pop("channel_client_wait_period", None) + obj.value = value + obj.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0025_move_preferred_region_and_auto_import_to_system_settings"), + ] + + operations = [ + migrations.RunPython(add_channel_client_wait_period, remove_channel_client_wait_period), + ] diff --git a/core/models.py b/core/models.py index 60be2f0e..676ef02d 100644 --- a/core/models.py +++ b/core/models.py @@ -280,8 +280,18 @@ class CoreSettings(models.Model): "epg_match_ignore_prefixes": [], "epg_match_ignore_suffixes": [], "epg_match_ignore_custom": [], + # XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30). + "xmltv_prev_days_override": 0, }) + @classmethod + def get_xmltv_prev_days_override(cls): + """Global XC XMLTV prev_days default (0 = auto-detect from provider archives).""" + try: + return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0) + except (TypeError, ValueError): + return 0 + @classmethod def _safe_string_list(cls, value): """Return a list of strings, filtering out non-list or non-string values.""" @@ -394,7 +404,8 @@ class CoreSettings(models.Model): "buffering_speed": 1.0, "redis_chunk_ttl": 60, "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, + "channel_init_grace_period": 60, + "channel_client_wait_period": 5, "new_client_behind_seconds": 5, }) diff --git a/core/serializers.py b/core/serializers.py index a0f27baf..1398b194 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -96,7 +96,8 @@ class ProxySettingsSerializer(serializers.Serializer): buffering_speed = serializers.FloatField(min_value=0.1, max_value=10.0) redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600) channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300) - channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60) + channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=300) + channel_client_wait_period = serializers.IntegerField(min_value=0, max_value=300, required=False, default=5) new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5) def validate_buffering_timeout(self, value): @@ -120,9 +121,16 @@ class ProxySettingsSerializer(serializers.Serializer): return value def validate_channel_init_grace_period(self, value): - if value < 0 or value > 60: + if value < 0 or value > 300: raise serializers.ValidationError( - "Channel initialization timeout must be between 0 and 60 seconds" + "Channel initialization timeout must be between 0 and 300 seconds" + ) + return value + + def validate_channel_client_wait_period(self, value): + if value < 0 or value > 300: + raise serializers.ValidationError( + "Client connect grace period must be between 0 and 300 seconds" ) return value diff --git a/core/utils.py b/core/utils.py index 44504b6a..16d9ed85 100644 --- a/core/utils.py +++ b/core/utils.py @@ -565,13 +565,15 @@ def trim_c_allocator_heap(): return False -def cleanup_memory(log_usage=False, force_collection=True): +def cleanup_memory(log_usage=False, force_collection=True, trim_heap=False): """ Comprehensive memory cleanup function to reduce memory footprint Args: log_usage: Whether to log memory usage before and after cleanup force_collection: Whether to force garbage collection + trim_heap: Return freed C heap pages to the OS. Only use after DB + connections are closed (e.g. Celery task_postrun). """ logger.trace("Starting memory cleanup django memory cleanup") # Skip logging if log level is not set to debug or more verbose (like trace) @@ -606,6 +608,8 @@ def cleanup_memory(log_usage=False, force_collection=True): logger.debug(f"Memory after cleanup: {after_mem:.2f} MB (change: {after_mem-before_mem:.2f} MB)") except (ImportError, Exception): pass + if trim_heap: + trim_c_allocator_heap() logger.trace("Memory cleanup complete for django") @@ -618,8 +622,7 @@ def spawn_memory_trim(close_connections=False): so the pooled DB connection is released first. """ def _run(): - cleanup_memory(force_collection=True) - trim_c_allocator_heap() + cleanup_memory(force_collection=True, trim_heap=True) if close_connections: from django.db import close_old_connections diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 18be81eb..69bb1221 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -2,7 +2,7 @@ import os from celery import Celery import logging -from celery.signals import task_postrun, task_prerun, worker_ready +from celery.signals import task_postrun, task_prerun, worker_process_init, worker_ready logger = logging.getLogger(__name__) @@ -42,13 +42,26 @@ app.autodiscover_tasks() # Plugins live outside INSTALLED_APPS, so autodiscover_tasks() never imports # them. Without an eager import, workers reject plugin @shared_tasks with # "Received unregistered task" until a lazy event import warms the module. -@worker_ready.connect(weak=False) -def discover_plugins_on_worker_ready(**_kwargs): +# Discovery runs in worker_process_init (each prefork child / thread worker) +# rather than worker_ready (the long-lived arbiter) so the parent process +# never opens DB connections that autoscale children would inherit via fork(). +@worker_process_init.connect(weak=False) +def init_worker_process(**_kwargs): + from django.db import connections + + # Standard Celery + Django guidance for prefork pools: discard any + # connection state inherited from the parent across fork(). + try: + connections.close_all() + except Exception: + logger.warning("Failed to close inherited DB connections after fork", exc_info=True) + try: from apps.plugins.loader import PluginManager PluginManager.get().discover_plugins(sync_db=False) except Exception: - logger.exception("plugin discovery on worker_ready failed") + logger.exception("plugin discovery on worker_process_init failed") + # Use environment variable for log level with fallback to INFO CELERY_LOG_LEVEL = os.environ.get('DISPATCHARR_LOG_LEVEL', 'INFO').upper() @@ -99,6 +112,7 @@ def cleanup_task_memory(**kwargs): memory_intensive_tasks = [ 'apps.m3u.tasks.refresh_single_m3u_account', 'apps.m3u.tasks.refresh_m3u_accounts', + 'apps.m3u.tasks.refresh_m3u_groups', 'apps.m3u.tasks.process_m3u_batch', 'apps.m3u.tasks.process_xc_category', 'apps.m3u.tasks.sync_auto_channels', @@ -121,7 +135,7 @@ def cleanup_task_memory(**kwargs): from core.utils import cleanup_memory # Use the comprehensive cleanup function - cleanup_memory(log_usage=True, force_collection=True) + cleanup_memory(log_usage=True, force_collection=True, trim_heap=True) # Log memory usage if psutil is installed try: diff --git a/dispatcharr/db/process_label.py b/dispatcharr/db/process_label.py index 209ad291..137d3b21 100644 --- a/dispatcharr/db/process_label.py +++ b/dispatcharr/db/process_label.py @@ -39,5 +39,16 @@ def get_process_role(argv: list[str] | None = None) -> str: return "django" +def uses_geventpool_database_backend(argv: list[str] | None = None) -> bool: + """ + True for uWSGI/Daphne/manage (gevent or request-scoped pooling). + + Celery prefork/thread workers must use Django's standard PostgreSQL backend: + django-db-geventpool keeps a process-wide warm-connection pool that fork() + duplicates across autoscale children, which corrupts Postgres session state. + """ + return get_process_role(argv) not in ("celery-worker", "celery-dvr", "celery-beat") + + def db_application_name() -> str: return f"Dispatcharr-{get_process_role()}-{os.getpid()}" diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index a22e5f9c..5b8de973 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -5,6 +5,8 @@ from datetime import timedelta from urllib.parse import quote_plus from django.core.exceptions import ImproperlyConfigured +from dispatcharr.db.process_label import db_application_name, uses_geventpool_database_backend + def _validate_tls_cert_paths(paths, service_name): """Validate that configured TLS certificate file paths exist on disk. @@ -99,6 +101,7 @@ INSTALLED_APPS = [ "django_filters", "django_celery_beat", "apps.plugins", + "apps.timeshift.apps.TimeshiftConfig", ] # EPG Processing optimization settings @@ -227,24 +230,39 @@ if os.getenv("DB_ENGINE", None) == "sqlite": } } else: + _use_geventpool_db = uses_geventpool_database_backend() + _pg_options = {"pool": False} if _use_geventpool_db else { + "application_name": db_application_name(), + } + if _use_geventpool_db: + _pg_options.update({ + "MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100 + "REUSE_CONNS": 3, # Connections to keep warm between requests + "CONN_MAX_LIFETIME": DATABASE_POOL_CONN_MAX_LIFETIME or None, + }) + DATABASES = { "default": { - "ENGINE": "dispatcharr.db.backends.postgresql_psycopg3", + "ENGINE": ( + "dispatcharr.db.backends.postgresql_psycopg3" + if _use_geventpool_db + else "django.db.backends.postgresql" + ), "NAME": os.environ.get("POSTGRES_DB", "dispatcharr"), "USER": os.environ.get("POSTGRES_USER", "dispatch"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, - "OPTIONS": { - "MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100 - "REUSE_CONNS": 3, # Connections to keep warm between requests - "pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections - "CONN_MAX_LIFETIME": DATABASE_POOL_CONN_MAX_LIFETIME or None, - }, + "OPTIONS": _pg_options, } } + if not _use_geventpool_db: + print( + f"PostgreSQL: standard backend for Celery ({_pg_options.get('application_name')})" + ) + if POSTGRES_SSL: _validate_tls_cert_paths([ ("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT), diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 9123f3da..cdedb0c4 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -7,6 +7,7 @@ from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.live_proxy.views import stream_xc from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode +from apps.timeshift.views import timeshift_proxy urlpatterns = [ # API Routes @@ -41,6 +42,11 @@ urlpatterns = [ stream_xc, name="xc_stream_endpoint", ), + path( + "timeshift/////", + timeshift_proxy, + name="timeshift_proxy", + ), # XC VOD endpoints path( "movie///.", diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 8e24bdb3..f1966295 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -52,6 +52,22 @@ const M3uSetupSuccess = ({ data }) => { }; // One-line outcome summary for the notification body. +const buildStreamSummary = (data) => { + if (data.streams_processed == null && data.streams_created == null) { + return null; + } + const created = data.streams_created || 0; + const updated = data.streams_updated || 0; + const stale = data.streams_stale || 0; + const removed = data.streams_deleted || 0; + const processed = data.streams_processed || 0; + return ( + `Streams: ${created} created, ${updated} updated, ` + + `${stale} marked stale, ${removed} removed. ` + + `Total processed: ${processed}.` + ); +}; + const buildAutoSyncSummary = (data) => { const created = data.channels_created || 0; const updated = data.channels_updated || 0; @@ -211,21 +227,29 @@ export default function M3URefreshNotification() { let body = message; let autoClose = 2000; - // Surface auto-sync counts attached to the parsing-complete event - // so the channel-side outcome appears in the notification body. + // Surface stream and auto-sync counts attached to the parsing-complete + // event so the outcome appears in the notification body. if (data.progress == 100 && data.action === 'parsing') { + const streamSummary = buildStreamSummary(data); const autoSyncSummary = buildAutoSyncSummary(data); const failed = data.channels_failed || 0; const failedDetails = Array.isArray(data.failed_stream_details) ? data.failed_stream_details : []; - if (autoSyncSummary) { + if (streamSummary || autoSyncSummary) { body = ( {message} - - {autoSyncSummary} - + {streamSummary && ( + + {streamSummary} + + )} + {autoSyncSummary && ( + + {autoSyncSummary} + + )} {failed > 0 && failedDetails.length > 0 && ( + + + + ); +}); + +export default EpgSettingsForm; diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index 52440769..91b36bb7 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -5,80 +5,119 @@ import { updateSetting } from '../../../utils/pages/SettingsUtils.js'; import { Alert, Button, + Collapse, Flex, NumberInput, Stack, TextInput, } from '@mantine/core'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; import { getProxySettingDefaults, getProxySettingsFormInitialValues, } from '../../../utils/forms/settings/ProxySettingsFormUtils.js'; +const isNumericField = (key) => { + return [ + 'buffering_timeout', + 'redis_chunk_ttl', + 'channel_shutdown_delay', + 'channel_init_grace_period', + 'channel_client_wait_period', + 'new_client_behind_seconds', + ].includes(key); +}; + +const isFloatField = (key) => key === 'buffering_speed'; + +const getNumericFieldMax = (key) => { + if (key === 'buffering_timeout') return 300; + if (key === 'redis_chunk_ttl') return 3600; + if (key === 'channel_shutdown_delay') return 300; + if (key === 'channel_client_wait_period') return 300; + if (key === 'new_client_behind_seconds') return 120; + return 300; +}; + +const renderProxySettingField = (key, config, proxySettingsForm) => { + if (isNumericField(key)) { + return ( + + ); + } + + if (isFloatField(key)) { + return ( + + ); + } + + return ( + + ); +}; + const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { - const isNumericField = (key) => { - // Determine if this field should be a NumberInput - return [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - 'new_client_behind_seconds', - ].includes(key); - }; - const isFloatField = (key) => { - return key === 'buffering_speed'; - }; - const getNumericFieldMax = (key) => { - return key === 'buffering_timeout' - ? 300 - : key === 'redis_chunk_ttl' - ? 3600 - : key === 'channel_shutdown_delay' - ? 300 - : key === 'new_client_behind_seconds' - ? 120 - : 60; - }; + const [advancedOpen, setAdvancedOpen] = useState(false); + const entries = Object.entries(PROXY_SETTINGS_OPTIONS); + const mainEntries = entries.filter(([, config]) => !config.advanced); + const advancedEntries = entries.filter(([, config]) => config.advanced); + return ( <> - {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => { - if (isNumericField(key)) { - return ( - - ); - } else if (isFloatField(key)) { - return ( - - ); - } else { - return ( - - ); - } - })} + {mainEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + {advancedEntries.length > 0 && ( + <> + + + + {advancedEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + + + )} ); }); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index c874b3e6..2b0dc55f 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -14,6 +14,21 @@ vi.mock('../../../../constants.js', () => ({ description: 'Speed multiplier', }, redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, + redis_chunk_ttl: { + label: 'Buffer Chunk TTL', + advanced: true, + description: 'Chunk TTL', + }, + channel_init_grace_period: { + label: 'Channel Initialization Timeout', + advanced: true, + description: 'Init timeout', + }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + advanced: true, + description: 'Advanced grace period', + }, }, })); @@ -71,6 +86,8 @@ vi.mock('@mantine/core', () => ({ ), Stack: ({ children }) =>
{children}
, + Collapse: ({ in: isOpen, children }) => + isOpen ?
{children}
: null, TextInput: ({ label, description, ...rest }) => (
@@ -353,11 +370,38 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + it('binds main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); }); + + it('hides advanced settings until expanded', () => { + render(); + expect( + screen.queryByTestId('number-input-Client Connect Grace Period') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Channel Initialization Timeout') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Buffer Chunk TTL') + ).not.toBeInTheDocument(); + expect(screen.getByText('Show Advanced Settings')).toBeInTheDocument(); + }); + + it('shows advanced settings when expanded', () => { + render(); + fireEvent.click(screen.getByText('Show Advanced Settings')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Client Connect Grace Period') + ).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Channel Initialization Timeout') + ).toBeInTheDocument(); + expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index a38795a2..71da2cee 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -1,64 +1,49 @@ -import React, { - useMemo, - useState, - useEffect, - useCallback, - useRef, -} from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { copyToClipboard } from '../../utils'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; +import { ChevronDown, ChevronRight, Eye, GripHorizontal, SquareMinus, } from 'lucide-react'; import { - GripHorizontal, - SquareMinus, - ChevronDown, - ChevronRight, - Eye, -} from 'lucide-react'; -import { - Box, ActionIcon, - Flex, - Text, - useMantineTheme, - Center, Badge, - Group, - Tooltip, - Collapse, + Box, Button, + Center, + Collapse, + Flex, + Group, + Text, + Tooltip, + useMantineTheme, } from '@mantine/core'; -import { - useReactTable, - getCoreRowModel, - flexRender, -} from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; import usePlaylistsStore from '../../store/playlists'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; import { + closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, - closestCenter, useDraggable, useSensor, useSensors, } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; -import { - arrayMove, - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { useSortable } from '@dnd-kit/sortable'; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { shallow } from 'zustand/shallow'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; +import { + categorizeStreamStats, + formatStatKey, + formatStatValue, + getChannelStreamStats, + reorderChannelStreams, +} from '../../utils/tables/ChannelTableStreamsUtils.js'; // ── Static values (created once, shared across all instances) ──────────────── @@ -69,103 +54,6 @@ const defaultColumnConfig = { minSize: 0, }; -const categoryMapping = { - basic: [ - 'resolution', - 'video_codec', - 'source_fps', - 'audio_codec', - 'audio_channels', - ], - video: [ - 'video_bitrate', - 'pixel_format', - 'width', - 'height', - 'aspect_ratio', - 'frame_rate', - ], - audio: [ - 'audio_bitrate', - 'sample_rate', - 'audio_format', - 'audio_channels_layout', - ], - technical: [ - 'stream_type', - 'container_format', - 'duration', - 'file_size', - 'ffmpeg_output_bitrate', - 'input_bitrate', - ], - other: [], -}; - -const categorizeStreamStats = (stats) => { - if (!stats) - return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; - - const categories = { - basic: {}, - video: {}, - audio: {}, - technical: {}, - other: {}, - }; - - Object.entries(stats).forEach(([key, value]) => { - let categorized = false; - for (const [category, keys] of Object.entries(categoryMapping)) { - if (keys.includes(key)) { - categories[category][key] = value; - categorized = true; - break; - } - } - if (!categorized) { - categories.other[key] = value; - } - }); - - return categories; -}; - -const formatStatValue = (key, value) => { - if (value === null || value === undefined) return 'N/A'; - - switch (key) { - case 'video_bitrate': - case 'audio_bitrate': - case 'ffmpeg_output_bitrate': - return `${value} kbps`; - case 'source_fps': - case 'frame_rate': - return `${value} fps`; - case 'sample_rate': - return `${value} Hz`; - case 'file_size': - if (typeof value === 'number') { - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`; - if (value < 1024 * 1024 * 1024) - return `${(value / (1024 * 1024)).toFixed(2)} MB`; - return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - return value; - case 'duration': - if (typeof value === 'number') { - const hours = Math.floor(value / 3600); - const minutes = Math.floor((value % 3600) / 60); - const seconds = Math.floor(value % 60); - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return value; - default: - return value.toString(); - } -}; - // ── Sub-components ─────────────────────────────────────────────────────────── const RowDragHandleCell = ({ rowId }) => { @@ -219,27 +107,25 @@ const DraggableRow = React.memo( }), }} > - {row.getVisibleCells().map((cell) => { - return ( - - - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - - - ); - })} + {row.getVisibleCells().map((cell) => ( + + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + + ))} ); }, @@ -260,7 +146,7 @@ const StatsCategory = ({ categoryName, stats }) => { {Object.entries(stats).map(([key, value]) => ( - {key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '} + {formatStatKey(key)}:{' '} {formatStatValue(key, value)} @@ -549,7 +435,7 @@ const ChannelStreams = ({ channel }) => { if (t && (since === null || t > since)) since = t; } const ids = opts && opts.ids; - API.getChannelStreamStats(channelId, since, ids).then((updates) => { + getChannelStreamStats(channelId, since, ids).then((updates) => { if (!updates || updates.length === 0) return; patchChannelStreamStats(channelId, updates); }); @@ -596,7 +482,7 @@ const ChannelStreams = ({ channel }) => { const removeStream = useCallback(async (stream) => { const newStreamList = dataRef.current.filter((s) => s.id !== stream.id); setData(newStreamList); - await API.reorderChannelStreams( + await reorderChannelStreams( channelRef.current.id, newStreamList.map((s) => s.id) ); @@ -709,7 +595,7 @@ const ChannelStreams = ({ channel }) => { const newIndex = currentIds.indexOf(over.id); const retval = arrayMove(prevData, oldIndex, newIndex); - API.reorderChannelStreams( + reorderChannelStreams( channel.id, retval.map((row) => row.id) ); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 797471ae..e21a003c 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1,71 +1,59 @@ -import React, { - useEffect, - useMemo, - useState, - useCallback, - useRef, -} from 'react'; -import { - DndContext, - closestCenter, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; +import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import useChannelsStore from '../../store/channels'; -import API from '../../api'; import ChannelForm from '../forms/Channel'; import ChannelBatchForm from '../forms/ChannelBatch'; import RecordingForm from '../forms/Recording'; -import { useDebounce, copyToClipboard } from '../../utils'; +import { copyToClipboard, useDebounce } from '../../utils'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; import { - Tv2, - ScreenShare, - Scroll, - SquareMinus, - CirclePlay, - SquarePen, - Copy, - ScanEye, - EllipsisVertical, - ArrowUpNarrowWide, - ArrowUpDown, ArrowDownWideNarrow, - Search, + ArrowUpDown, + ArrowUpNarrowWide, + CirclePlay, + Copy, + EllipsisVertical, EyeOff, Pencil, + ScanEye, + ScreenShare, + Scroll, + Search, + SquareMinus, + SquarePen, + Tv2, } from 'lucide-react'; -import { listOverriddenFields } from '../../utils/forms/ChannelUtils.js'; +import { listOverriddenFields, requeryChannels, } from '../../utils/forms/ChannelUtils.js'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; import { - Box, - TextInput, - Popover, ActionIcon, + Box, Button, - Paper, - Flex, - Text, - Group, - useMantineTheme, Center, - Switch, + Flex, + Group, Menu, + MenuDropdown, + MenuItem, + MenuTarget, MultiSelect, - Pagination, NativeSelect, - UnstyledButton, - Stack, - Select, NumberInput, + Pagination, + Paper, + Popover, + PopoverDropdown, + PopoverTarget, + Select, + Stack, + Switch, + Text, + TextInput, Tooltip, - Skeleton, + UnstyledButton, + useMantineTheme, } from '@mantine/core'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; @@ -79,21 +67,33 @@ import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding'; import ChannelTableHeader from './ChannelsTable/ChannelTableHeader'; import useOutputProfilesStore from '../../store/outputProfiles'; import { - EditableTextCell, - EditableNumberCell, - EditableGroupCell, EditableEPGCell, + EditableGroupCell, EditableLogoCell, + EditableNumberCell, + EditableTextCell, } from './ChannelsTable/EditableCell'; -import { DraggableRow } from './ChannelsTable/DraggableRow'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; - -const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; -const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; -const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; +import { getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js'; +import { + buildEPGUrl, + buildFetchParams, + buildHDHRUrl, + buildM3UUrl, + deleteChannel, + deleteChannels, + epgUrlBase, + getAllChannelIds, + hdhrUrlBase, + m3uUrlBase, + queryChannels, + reorderChannel, + updateProfileChannel, + updateProfileChannels, +} from '../../utils/tables/ChannelsTableUtils.js'; const ChannelEnabledSwitch = React.memo( ({ rowId, selectedProfileId, selectedTableIds }) => { @@ -109,13 +109,13 @@ const ChannelEnabledSwitch = React.memo( const handleToggle = () => { if (selectedTableIds.length > 1) { - API.updateProfileChannels( + updateProfileChannels( selectedTableIds, selectedProfileId, !isEnabled ); } else { - API.updateProfileChannel(rowId, selectedProfileId, !isEnabled); + updateProfileChannel(rowId, selectedProfileId, !isEnabled); } }; @@ -208,22 +208,22 @@ const ChannelRowActions = React.memo( - + - + - - }> + + }> copyToClipboard(getChannelURL(row.original))} > Copy URL - - + Record - - + + @@ -421,130 +421,47 @@ const ChannelsTable = ({ onReady }) => { /** * Functions */ + const handleFetchSuccess = useCallback((ids) => { + setTablePrefs((prev) => ({ ...prev, pageSize: pagination.pageSize })); + setAllRowIds(ids); + hasFetchedData.current = true; + if (!hasSignaledReady.current && onReady && tvgsLoaded) { + hasSignaledReady.current = true; + onReady(); + } + }, [pagination.pageSize, setTablePrefs, setAllRowIds, onReady, tvgsLoaded]); + const fetchData = useCallback(async () => { - // Build params first to check for duplicates - const params = new URLSearchParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - params.append('include_streams', 'true'); - if (selectedProfileId !== '0') { - params.append('channel_profile_id', selectedProfileId); - } - if (showDisabled === true) { - params.append('show_disabled', true); - } - if (showOnlyStreamlessChannels === true) { - params.append('only_streamless', true); - } - if (showOnlyStaleChannels === true) { - params.append('only_stale', true); - } - if (showOnlyOverriddenChannels === true) { - params.append('only_has_overrides', true); - } - // The backend defaults to "active"; send other choices explicitly so - // hidden rows surface when the user opts into "Hidden Only" or "Show All". - if (visibilityFilter && visibilityFilter !== 'active') { - params.append('visibility_filter', visibilityFilter); - } - - // Apply sorting - if (sorting.length > 0) { - let sortField = sorting[0].id; - // Map frontend column ids to backend ordering field names - const fieldMapping = { - channel_group: 'channel_group__name', - epg: 'epg_data__name', - }; - if (fieldMapping[sortField]) { - sortField = fieldMapping[sortField]; - } - const sortDirection = sorting[0].desc ? '-' : ''; - params.append('ordering', `${sortDirection}${sortField}`); - } - - // Apply debounced filters - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (value) { - if (Array.isArray(value)) { - // Convert null values to "null" string for URL parameter - const processedValue = value - .map((v) => (v === null ? 'null' : v)) - .join(','); - params.append(key, processedValue); - } else { - params.append(key, value); - } - } + const params = buildFetchParams({ + pagination, sorting, debouncedFilters, selectedProfileId, + showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, + showOnlyOverriddenChannels, visibilityFilter, }); - const paramsString = params.toString(); - // Skip if same fetch is already in progress (prevents StrictMode double-fetch) - if ( - fetchInProgressRef.current && - lastFetchParamsRef.current === paramsString - ) { - return; - } + if (fetchInProgressRef.current && lastFetchParamsRef.current === paramsString) return; - // Increment fetch version to track this specific fetch request const currentFetchVersion = ++fetchVersionRef.current; lastFetchParamsRef.current = paramsString; fetchInProgressRef.current = true; - setIsLoading(true); try { - const [, ids] = await Promise.all([ - API.queryChannels(params), - API.getAllChannelIds(params), - ]); - + const [, ids] = await Promise.all([queryChannels(params), getAllChannelIds(params)]); fetchInProgressRef.current = false; - - // Skip state updates if a newer fetch has been initiated - if (currentFetchVersion !== fetchVersionRef.current) { - return; - } - + if (currentFetchVersion !== fetchVersionRef.current) return; setIsLoading(false); - hasFetchedData.current = true; - - setTablePrefs((prev) => ({ - ...prev, - pageSize: pagination.pageSize, - })); - setAllRowIds(ids); - - // Signal ready after first successful data fetch AND EPG data is loaded - // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading - if (!hasSignaledReady.current && onReady && tvgsLoaded) { - hasSignaledReady.current = true; - onReady(); - } + handleFetchSuccess(ids); } catch (error) { fetchInProgressRef.current = false; - - // Skip state updates if a newer fetch has been initiated - if (currentFetchVersion !== fetchVersionRef.current) { - return; - } + if (currentFetchVersion !== fetchVersionRef.current) return; setIsLoading(false); - // API layer handles "Invalid page" errors by resetting and retrying - // Just re-throw to show notification for actual errors throw error; } }, [ - pagination, - sorting, - debouncedFilters, - showDisabled, - selectedProfileId, - showOnlyStreamlessChannels, - showOnlyStaleChannels, - showOnlyOverriddenChannels, - visibilityFilter, + pagination, sorting, debouncedFilters, selectedProfileId, + showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, + showOnlyOverriddenChannels, visibilityFilter, handleFetchSuccess, ]); const stopPropagation = useCallback((e) => { @@ -604,7 +521,7 @@ const ChannelsTable = ({ onReady }) => { } }, []); - const deleteChannel = async (id) => { + const handleDeleteChannel = async (id) => { console.log(`Deleting channel with ID: ${id}`); const rows = table.getRowModel().rows; @@ -642,15 +559,15 @@ const ChannelsTable = ({ onReady }) => { const executeDeleteChannel = async (id) => { setDeleting(true); try { - await API.deleteChannel(id); - API.requeryChannels(); + await deleteChannel(id); + requeryChannels(); } finally { setDeleting(false); setConfirmDeleteOpen(false); } }; - const deleteChannels = async () => { + const handleDeleteChannels = async () => { if (isWarningSuppressed('delete-channels')) { // Skip warning if suppressed return executeDeleteChannels(); @@ -664,8 +581,8 @@ const ChannelsTable = ({ onReady }) => { setIsLoading(true); setDeleting(true); try { - await API.deleteChannels(table.selectedTableIds); - await API.requeryChannels(); + await deleteChannels(table.selectedTableIds); + await requeryChannels(); setSelectedChannelIds([]); table.setSelectedTableIds([]); } finally { @@ -688,9 +605,9 @@ const ChannelsTable = ({ onReady }) => { return ''; } - const path = `/proxy/ts/stream/${channel.uuid}`; + const path = getShowVideoUrl(channel, env_mode); if (env_mode == 'dev') { - return `${window.location.protocol}//${window.location.hostname}:5656${path}`; + return path; } return `${window.location.protocol}//${window.location.host}${path}`; }, @@ -754,51 +671,23 @@ const ChannelsTable = ({ onReady }) => { setRecordingModalOpen(false); }; - // Build URLs with parameters - const buildM3UUrl = () => { - const params = new URLSearchParams(); - if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); - if (m3uParams.direct) params.append('direct', 'true'); - if (m3uParams.tvg_id_source !== 'channel_number') - params.append('tvg_id_source', m3uParams.tvg_id_source); - if (m3uParams.output_format) - params.append('output_format', m3uParams.output_format); - if (m3uParams.output_profile) - params.append('output_profile', m3uParams.output_profile); - - const baseUrl = m3uUrl; - return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; - }; - - const buildEPGUrl = () => { - const params = new URLSearchParams(); - if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); - if (epgParams.tvg_id_source !== 'channel_number') - params.append('tvg_id_source', epgParams.tvg_id_source); - if (epgParams.days > 0) params.append('days', epgParams.days.toString()); - if (epgParams.prev_days > 0) - params.append('prev_days', epgParams.prev_days.toString()); - - const baseUrl = epgUrl; - return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; - }; // Example copy URLs const copyM3UUrl = async () => { - await copyToClipboard(buildM3UUrl(), { + await copyToClipboard(buildM3UUrl(m3uParams, m3uUrl), { successTitle: 'M3U URL Copied!', successMessage: 'The M3U URL has been copied to your clipboard.', }); }; const copyEPGUrl = async () => { - await copyToClipboard(buildEPGUrl(), { + await copyToClipboard(buildEPGUrl(epgParams, epgUrl), { successTitle: 'EPG URL Copied!', successMessage: 'The EPG URL has been copied to your clipboard.', }); }; const copyHDHRUrl = async () => { - await copyToClipboard(buildHDHRUrl(), { + await copyToClipboard(buildHDHRUrl(hdhrOutputProfileId, hdhrUrl), { successTitle: 'HDHR URL Copied!', successMessage: 'The HDHR URL has been copied to your clipboard.', }); @@ -854,7 +743,7 @@ const ChannelsTable = ({ onReady }) => { useChannelsTableStore.setState({ channels: reorderedData }); // Call backend to reorder - await API.reorderChannel( + await reorderChannel( activeChannel.id, overIndex > activeIndex ? overChannel.id @@ -862,11 +751,11 @@ const ChannelsTable = ({ onReady }) => { ); // Refetch to get updated channel numbers - await API.requeryChannels(); + await requeryChannels(); } catch (error) { // Revert on error console.error('Failed to reorder channel:', error); - await API.requeryChannels(); + await requeryChannels(); } }; @@ -885,13 +774,6 @@ const ChannelsTable = ({ onReady }) => { setM3UUrl(`${m3uUrlBase}${profileString}`); }, [selectedProfileId, profiles]); - const buildHDHRUrl = () => { - if (!hdhrOutputProfileId) return hdhrUrl; - // Insert output_profile segment before the trailing slash (or at end) - const base = hdhrUrl.replace(/\/$/, ''); - return `${base}/output_profile/${hdhrOutputProfileId}`; - }; - useEffect(() => { const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 const endItem = Math.min( @@ -1053,7 +935,7 @@ const ChannelsTable = ({ onReady }) => { row={row} table={table} editChannel={editChannel} - deleteChannel={deleteChannel} + deleteChannel={handleDeleteChannel} handleWatchStream={handleWatchStream} createRecording={createRecording} getChannelURL={getChannelURL} @@ -1298,7 +1180,7 @@ const ChannelsTable = ({ onReady }) => { position="bottom-start" withinPortal > - + - - + + { clients. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { channel list. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { clients. { } /> - + @@ -1626,7 +1508,7 @@ const ChannelsTable = ({ onReady }) => { { const [opened, setOpened] = useState(false); @@ -59,7 +67,7 @@ const CreateProfilePopover = React.memo(() => { }; const submit = async () => { - await API.addChannelProfile({ name }); + await addChannelProfile({ name }); setName(''); setOpened(false); }; @@ -72,7 +80,7 @@ const CreateProfilePopover = React.memo(() => { withArrow shadow="md" > - + { > - + - + { - + ); }); @@ -125,7 +133,6 @@ const ChannelTableHeader = ({ }) => { const theme = useMantineTheme(); - const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false); @@ -179,38 +186,13 @@ const ChannelTableHeader = ({ const executeDeleteProfile = async (id) => { setDeletingProfile(true); try { - await API.deleteChannelProfile(id); + await deleteChannelProfile(id); } finally { setDeletingProfile(false); setConfirmDeleteProfileOpen(false); } }; - const assignChannels = async () => { - try { - // Call our custom API endpoint - const result = await API.assignChannelNumbers( - selectedTableIds, - channelNumAssignmentStart - ); - - // We might get { message: "Channels have been auto-assigned!" } - notifications.show({ - title: result.message || 'Channels assigned', - color: 'green.5', - }); - - // Refresh the channel list - API.requeryChannels(); - } catch (err) { - console.error(err); - notifications.show({ - title: 'Failed to assign channels', - color: 'red.5', - }); - } - }; - const renderModalOption = renderProfileOption( theme, profiles, @@ -302,14 +284,14 @@ const ChannelTableHeader = ({ > - + - + - - + : @@ -319,9 +301,9 @@ const ChannelTableHeader = ({ {showDisabled ? 'Hide Disabled' : 'Show Disabled'} - + - Only Empty Channels - + - Has Stale Streams - + - Has Overrides - + - - + + Visibility - + {[ { value: 'active', label: 'Active Only' }, { value: 'hidden', label: 'Hidden Only' }, { value: 'all', label: 'Show All' }, ].map(({ value, label }) => ( - setVisibilityFilter && setVisibilityFilter(value) @@ -384,9 +366,9 @@ const ChannelTableHeader = ({ } > {label} - + ))} - + +
+ ) : null, +})); + +vi.mock('../../../forms/GroupManager', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../modals/ProfileModal', () => ({ + default: ({ opened, onClose }) => + opened ? ( +
+ +
+ ) : null, + renderProfileOption: vi.fn(() => () => null), +})); + +vi.mock('../../../modals/EPGMatchModal', () => ({ + default: ({ opened, onClose }) => + opened ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color, variant }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), + Item: ({ children, onClick, disabled }) => ( + + ), + Divider: () =>
, + Label: ({ children }) =>
{children}
, + } + ), + MenuDivider: () =>
, + MenuDropdown: ({ children }) => ( +
{children}
+ ), + MenuItem: ({ children, onClick, disabled }) => ( + + ), + MenuLabel: ({ children }) =>
{children}
, + MenuTarget: ({ children }) =>
{children}
, + Popover: ({ children, opened }) => ( +
+ {children} +
+ ), + PopoverDropdown: ({ children }) => ( +
{children}
+ ), + PopoverTarget: ({ children }) =>
{children}
, + Select: ({ value, onChange, data }) => ( + + ), + Text: ({ children, c }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder }) => ( + + ), + Tooltip: ({ children, label }) =>
{children}
, + useMantineTheme: () => ({ + tailwind: { + green: { 5: 'green.5' }, + yellow: { 5: 'yellow.5' }, + }, + palette: { custom: {} }, + }), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDown01: () => , + Binary: () => , + CircleCheck: () => , + EllipsisVertical: () => , + Eye: () => , + EyeOff: () => , + Filter: () => , + Lock: () => , + LockOpen: () => , + Pin: () => , + PinOff: () => , + Settings: () => , + Square: () => , + SquareCheck: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../../store/channels'; +import useChannelsTableStore from '../../../../store/channelsTable'; +import useAuthStore from '../../../../store/auth'; +import useWarningsStore from '../../../../store/warnings'; +import * as ChannelsTableUtils from '../../../../utils/tables/ChannelsTableUtils.js'; +import ChannelTableHeader from '../ChannelTableHeader'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const ADMIN = 10; +const STANDARD = 1; + +const makeProfiles = () => ({ + 0: { id: 0, name: 'All Channels' }, + 1: { id: 1, name: 'Profile A' }, + 2: { id: 2, name: 'Profile B' }, +}); + +const makeTable = (overrides = {}) => ({ + headerPinned: false, + setHeaderPinned: vi.fn(), + selectedTableIds: [], + setSelectedTableIds: vi.fn(), + ...overrides, +}); + +const makeDefaultProps = (overrides = {}) => ({ + rows: [], + editChannel: vi.fn(), + deleteChannels: vi.fn(), + selectedTableIds: [], + table: makeTable(), + showDisabled: true, + setShowDisabled: vi.fn(), + showOnlyStreamlessChannels: false, + setShowOnlyStreamlessChannels: vi.fn(), + showOnlyStaleChannels: false, + setShowOnlyStaleChannels: vi.fn(), + showOnlyOverriddenChannels: false, + setShowOnlyOverriddenChannels: vi.fn(), + visibilityFilter: 'active', + setVisibilityFilter: vi.fn(), + ...overrides, +}); + +const setupMocks = ({ + userLevel = ADMIN, + profiles = makeProfiles(), + selectedProfileId = '0', + isUnlocked = false, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), +} = {}) => { + const mockSetSelectedProfileId = vi.fn(); + const mockSetIsUnlocked = vi.fn(); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + profiles, + selectedProfileId, + setSelectedProfileId: mockSetSelectedProfileId, + }) + ); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + isUnlocked, + setIsUnlocked: mockSetIsUnlocked, + }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: { user_level: userLevel } }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + return { mockSetSelectedProfileId, mockSetIsUnlocked }; +}; + +describe('ChannelTableHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ChannelsTableUtils.addChannelProfile).mockResolvedValue(undefined); + vi.mocked(ChannelsTableUtils.deleteChannelProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the profile select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('profile-select')).toBeInTheDocument(); + }); + + it('renders profile options in the select', () => { + setupMocks(); + render(); + expect(screen.getByRole('option', { name: 'All Channels' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Profile A' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Profile B' })).toBeInTheDocument(); + }); + + it('renders the Edit button', () => { + setupMocks(); + render(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + }); + + it('renders the Delete button', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('renders the Add button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add')).toBeInTheDocument(); + }); + + it('does not show Editing Mode text when not unlocked', () => { + setupMocks({ isUnlocked: false }); + render(); + expect(screen.queryByText('Editing Mode')).not.toBeInTheDocument(); + }); + + it('shows Editing Mode text when unlocked', () => { + setupMocks({ isUnlocked: true }); + render(); + expect(screen.getByText('Editing Mode')).toBeInTheDocument(); + }); + }); + + // ── Profile select ───────────────────────────────────────────────────────── + + describe('profile select', () => { + it('calls setSelectedProfileId when profile is changed', () => { + const { mockSetSelectedProfileId } = setupMocks(); + render(); + const select = screen.getByTestId('profile-select'); + fireEvent.change(select, { target: { value: '1' } }); + expect(mockSetSelectedProfileId).toHaveBeenCalledWith('1'); + }); + }); + + // ── Filter menu ──────────────────────────────────────────────────────────── + + describe('filter menu', () => { + it('calls setShowDisabled when Hide/Show Disabled is clicked', () => { + const props = makeDefaultProps({ showDisabled: true }); + setupMocks({ selectedProfileId: '1' }); + render(); + fireEvent.click(screen.getByText('Hide Disabled')); + expect(props.setShowDisabled).toHaveBeenCalledWith(false); + }); + + it('shows "Show Disabled" when showDisabled is false', () => { + const props = makeDefaultProps({ showDisabled: false }); + setupMocks(); + render(); + expect(screen.getByText('Show Disabled')).toBeInTheDocument(); + }); + + it('calls setShowOnlyStreamlessChannels when Only Empty Channels is clicked', () => { + const props = makeDefaultProps({ showOnlyStreamlessChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Only Empty Channels')); + expect(props.setShowOnlyStreamlessChannels).toHaveBeenCalledWith(true); + }); + + it('clears stale toggle when enabling streamless-only', () => { + const props = makeDefaultProps({ + showOnlyStreamlessChannels: false, + showOnlyStaleChannels: true, + }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Only Empty Channels')); + expect(props.setShowOnlyStaleChannels).toHaveBeenCalledWith(false); + }); + + it('calls setShowOnlyStaleChannels when Has Stale Streams is clicked', () => { + const props = makeDefaultProps({ showOnlyStaleChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Stale Streams')); + expect(props.setShowOnlyStaleChannels).toHaveBeenCalledWith(true); + }); + + it('clears streamless toggle when enabling stale-only', () => { + const props = makeDefaultProps({ + showOnlyStaleChannels: false, + showOnlyStreamlessChannels: true, + }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Stale Streams')); + expect(props.setShowOnlyStreamlessChannels).toHaveBeenCalledWith(false); + }); + + it('calls setShowOnlyOverriddenChannels when Has Overrides is clicked', () => { + const props = makeDefaultProps({ showOnlyOverriddenChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Overrides')); + expect(props.setShowOnlyOverriddenChannels).toHaveBeenCalledWith(true); + }); + + it('calls setVisibilityFilter with "hidden" when Hidden Only is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Hidden Only')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('hidden'); + }); + + it('calls setVisibilityFilter with "all" when Show All is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Show All')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('all'); + }); + + it('calls setVisibilityFilter with "active" when Active Only is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Active Only')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('active'); + }); + }); + + // ── Edit / Delete / Add buttons ──────────────────────────────────────────── + + describe('edit / delete / add buttons', () => { + it('Edit button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Edit')).toBeDisabled(); + }); + + it('Edit button is enabled when rows are selected and user is admin', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + expect(screen.getByText('Edit')).not.toBeDisabled(); + }); + + it('Edit button is disabled for non-admin users even with selection', () => { + setupMocks({ userLevel: STANDARD }); + render( + + ); + expect(screen.getByText('Edit')).toBeDisabled(); + }); + + it('calls editChannel when Edit is clicked', () => { + const editChannel = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Edit')); + expect(editChannel).toHaveBeenCalled(); + }); + + it('Delete button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeDisabled(); + }); + + it('calls deleteChannels when Delete is clicked', () => { + const deleteChannels = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Delete')); + expect(deleteChannels).toHaveBeenCalled(); + }); + + it('Add button is disabled for non-admin users', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Add')).toBeDisabled(); + }); + + it('calls editChannel with forceAdd option when Add is clicked', () => { + const editChannel = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Add')); + expect(editChannel).toHaveBeenCalledWith(null, { forceAdd: true }); + }); + }); + + // ── Overflow menu (ellipsis) ─────────────────────────────────────────────── + + describe('overflow menu', () => { + it('calls setHeaderPinned when Pin/Unpin Headers is clicked', () => { + const setHeaderPinned = vi.fn(); + const table = makeTable({ headerPinned: false, setHeaderPinned }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Pin Headers')); + expect(setHeaderPinned).toHaveBeenCalledWith(true); + }); + + it('shows "Unpin Headers" when headerPinned is true', () => { + const table = makeTable({ headerPinned: true, setHeaderPinned: vi.fn() }); + setupMocks(); + render(); + expect(screen.getByText('Unpin Headers')).toBeInTheDocument(); + }); + + it('calls setIsUnlocked when Lock/Unlock Table is clicked', () => { + const { mockSetIsUnlocked } = setupMocks({ isUnlocked: false }); + render(); + fireEvent.click(screen.getByText('Unlock for Editing')); + expect(mockSetIsUnlocked).toHaveBeenCalledWith(true); + }); + + it('shows "Lock Table" when isUnlocked is true', () => { + setupMocks({ isUnlocked: true }); + render(); + expect(screen.getByText('Lock Table')).toBeInTheDocument(); + }); + + it('Assign #s menu item is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Assign #s').closest('button')).toBeDisabled(); + }); + + it('opens AssignChannelNumbersForm when Assign #s is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Assign #s')); + expect(screen.getByTestId('assign-numbers-modal')).toBeInTheDocument(); + }); + + it('closes AssignChannelNumbersForm when onClose fires', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Assign #s')); + fireEvent.click(screen.getByText('Close Assign')); + expect(screen.queryByTestId('assign-numbers-modal')).not.toBeInTheDocument(); + }); + + it('opens EPGMatchModal when Auto-Match EPG is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Auto-Match EPG')); + expect(screen.getByTestId('epg-match-modal')).toBeInTheDocument(); + }); + + it('shows selected count in Auto-Match label when rows are selected', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + expect(screen.getByText('Auto-Match (2 selected)')).toBeInTheDocument(); + }); + + it('opens GroupManager when Edit Groups is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Edit Groups')); + expect(screen.getByTestId('group-manager-modal')).toBeInTheDocument(); + }); + + it('closes GroupManager when onClose fires', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Edit Groups')); + fireEvent.click(screen.getByText('Close Group Manager')); + expect(screen.queryByTestId('group-manager-modal')).not.toBeInTheDocument(); + }); + }); + + // ── CreateProfilePopover ─────────────────────────────────────────────────── + + describe('CreateProfilePopover', () => { + it('calls addChannelProfile with the typed name on submit', async () => { + setupMocks({ userLevel: ADMIN }); + render(); + + const input = screen.getByTestId('text-input'); + fireEvent.change(input, { target: { value: 'New Profile' } }); + + // Click the CircleCheck action icon inside the popover dropdown + const actionIcons = screen.getAllByTestId('action-icon'); + // The submit button is the one inside the popover dropdown (last small one) + const submitIcon = actionIcons.find((btn) => + btn.querySelector('[data-testid="icon-circle-check"]') + ); + fireEvent.click(submitIcon); + + await waitFor(() => { + expect(ChannelsTableUtils.addChannelProfile).toHaveBeenCalledWith({ + name: 'New Profile', + }); + }); + }); + }); + + // ── Delete profile ───────────────────────────────────────────────────────── + + describe('delete profile', () => { + it('opens confirmation dialog when deleteProfile is triggered and warning not suppressed', async () => { + const isWarningSuppressed = vi.fn(() => false); + setupMocks({ isWarningSuppressed }); + + // We need to trigger deleteProfile — it's called by the ProfileModal's + // onDeleteProfile callback; we can spy on renderProfileOption to invoke it + // directly. Instead, we verify the confirmation dialog renders when the + // warning is not suppressed by calling executeDeleteProfile via the dialog. + // We simulate this via the ConfirmationDialog confirm flow. + render(); + // Dialog is not open by default + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannelProfile directly when warning is suppressed', async () => { + const isWarningSuppressed = vi.fn(() => true); + setupMocks({ isWarningSuppressed }); + render(); + // When warning suppressed, executeDeleteProfile runs immediately + // (tested indirectly - no dialog shown) + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannelProfile when confirmation dialog is confirmed', async () => { + const isWarningSuppressed = vi.fn(() => false); + setupMocks({ isWarningSuppressed }); + + // We can't easily open the dialog without triggering deleteProfile from + // a child; render the component and verify the ConfirmationDialog is + // wired with the correct title. + render(); + // The dialog title should reference "Profile Deletion" when opened + // This verifies the dialog props are set correctly + expect( + screen.queryByText('Confirm Profile Deletion') + ).not.toBeInTheDocument(); + }); + }); + + // ── ProfileModal ─────────────────────────────────────────────────────────── + + describe('ProfileModal', () => { + it('does not show ProfileModal on initial render', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('profile-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Unlock for Editing disabled for non-admin ────────────────────────────── + + describe('admin-only controls', () => { + it('Unlock for Editing menu item is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Unlock for Editing').closest('button')).toBeDisabled(); + }); + + it('Edit Groups is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Edit Groups').closest('button')).toBeDisabled(); + }); + + it('Auto-Match EPG is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Auto-Match EPG').closest('button')).toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx b/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx index a67cbe35..f004509a 100644 --- a/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx +++ b/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx @@ -8,12 +8,16 @@ vi.mock('../../../../utils/notificationUtils.js', () => ({ })); // Mock other heavy dependencies so the import doesn't pull in stores. -vi.mock('../../../../api', () => ({ default: {} })); vi.mock('../../../../store/channelsTable', () => ({ default: { getState: vi.fn() } })); vi.mock('../../../../store/logos', () => ({ default: vi.fn() })); vi.mock('../../../../utils/forms/ChannelUtils.js', () => ({ - OVERRIDABLE_FIELDS: new Set(['name', 'channel_number', 'tvg_id']), - normalizeFieldValue: (v) => v, + requeryChannels: vi.fn(), + updateChannel: vi.fn(), +})); +vi.mock('../../../../utils/tables/ChannelsTableUtils.js', () => ({ + buildInlinePatch: vi.fn(), + getEpgOptions: vi.fn(), + getLogoOptions: vi.fn(), })); import { notifyInlineSaveError } from '../EditableCell.jsx'; diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index d0dcdbd8..440b8412 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -1,6 +1,6 @@ -import { Box, Flex } from '@mantine/core'; +import { Box } from '@mantine/core'; import CustomTableHeader from './CustomTableHeader'; -import { useCallback, useState, useRef, useMemo } from 'react'; +import { useMemo } from 'react'; import CustomTableBody from './CustomTableBody'; const CustomTable = ({ table }) => { @@ -17,14 +17,13 @@ const CustomTable = ({ table }) => { const headerGroups = table.getHeaderGroups(); if (!headerGroups || headerGroups.length === 0) return 0; - const width = + return ( headerGroups[0]?.headers.reduce((total, header) => { const colDef = header.column.columnDef; const size = colDef.grow ? colDef.minSize || 0 : header.getSize(); return total + size; - }, 0) || 0; - - return width; + }, 0) || 0 + ); }, [table, columnSizing]); // CSS custom properties for each fixed-width column's current size. diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 0083abb4..bd52feb4 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -1,6 +1,6 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core'; import { flexRender } from '@tanstack/react-table'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper'; import useChannelsTableStore from '../../../store/channelsTable'; @@ -55,25 +55,6 @@ const CustomTableHeader = ({ return {content}; }; - // Get header groups for dependency tracking - const headerGroups = getHeaderGroups(); - - // Calculate minimum width based only on fixed-size columns - const minTableWidth = useMemo(() => { - if (!headerGroups || headerGroups.length === 0) return 0; - - const width = - headerGroups[0]?.headers.reduce((total, header) => { - // Only count columns with fixed sizes, flexible columns will expand - const columnSize = header.column.columnDef.size - ? header.getSize() - : header.column.columnDef.minSize || 150; // Default min for flexible columns - return total + columnSize; - }, 0) || 0; - - return width; - }, [headerGroups]); - // Memoize the style object to ensure it updates when headerPinned changes const headerStyle = useMemo( () => ({ @@ -122,7 +103,6 @@ const CustomTableHeader = ({ maxWidth: `var(--header-${header.id}-size)`, }), position: 'relative', - // ...(tableCellProps && tableCellProps({ cell: header })), }} > ({ + default: ({ headerPinned, enableDragDrop }) => ( +
+ ), +})); + +vi.mock('../CustomTableBody', () => ({ + default: ({ enableDragDrop }) => ( +
+ ), +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style }) => ( +
+ {children} +
+ ), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeHeader = (id, size, grow = false, minSize = 50) => ({ + id, + getSize: () => size, + column: { + columnDef: { grow, minSize }, + }, +}); + +const makeTable = (overrides = {}) => { + const headers = [makeHeader('col1', 100), makeHeader('col2', 200)]; + + return { + tableSize: 'default', + filters: {}, + allRowIds: [], + headerCellRenderFns: {}, + selectedTableIds: [], + tableCellProps: vi.fn(), + headerPinned: false, + enableDragDrop: false, + expandedRowIds: [], + expandedRowRenderer: null, + bodyCellRenderFns: {}, + getRowStyles: vi.fn(), + selectedTableIdsSet: new Set(), + handleRowClickRef: { current: vi.fn() }, + onSelectAllChange: null, + renderBodyCell: null, + getState: () => ({ columnSizing: {} }), + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + getRowModel: () => ({ rows: [] }), + ...overrides, + }; +}; + +describe('CustomTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders CustomTableHeader and CustomTableBody', () => { + render(); + expect(screen.getByTestId('custom-table-header')).toBeInTheDocument(); + expect(screen.getByTestId('custom-table-body')).toBeInTheDocument(); + }); + + it('applies default table-size class when tableSize is default', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-default'); + }); + + it('applies compact table-size class when tableSize is compact', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-compact'); + }); + + it('applies large table-size class when tableSize is large', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-large'); + }); + + it('uses default table size when tableSize is undefined', () => { + const table = makeTable(); + table.tableSize = undefined; + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-default'); + }); + }); + + // ── Min table width ──────────────────────────────────────────────────────── + + describe('minTableWidth calculation', () => { + it('calculates minTableWidth from fixed-width columns', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('col2', 200, false), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('300px'); + }); + + it('uses minSize for grow columns instead of full size', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('grow-col', 500, true, 80), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + // col1 (100) + grow-col minSize (80) = 180 + expect(box.style.minWidth).toBe('180px'); + }); + + it('returns minWidth of 0 when no header groups exist', () => { + const table = makeTable({ + getHeaderGroups: () => [], + getFlatHeaders: () => [], + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('0px'); + }); + + it('returns minWidth of 0 when header group has no headers', () => { + const table = makeTable({ + getHeaderGroups: () => [{ headers: [] }], + getFlatHeaders: () => [], + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('0px'); + }); + }); + + // ── Column size CSS vars ─────────────────────────────────────────────────── + + describe('columnSizeVars', () => { + it('injects CSS custom properties for fixed-width columns', () => { + const headers = [ + makeHeader('col1', 120, false), + makeHeader('col2', 80, false), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.getPropertyValue('--header-col1-size')).toBe('120px'); + expect(box.style.getPropertyValue('--header-col2-size')).toBe('80px'); + }); + + it('does not inject CSS custom properties for grow columns', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('grow-col', 500, true, 80), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.getPropertyValue('--header-grow-col-size')).toBe(''); + expect(box.style.getPropertyValue('--header-col1-size')).toBe('100px'); + }); + }); + + // ── Props forwarding ─────────────────────────────────────────────────────── + + describe('props forwarding', () => { + it('passes headerPinned to CustomTableHeader', () => { + render(); + const header = screen.getByTestId('custom-table-header'); + expect(header.dataset.headerPinned).toBe('true'); + }); + + it('passes enableDragDrop to CustomTableHeader', () => { + render(); + const header = screen.getByTestId('custom-table-header'); + expect(header.dataset.enableDragDrop).toBe('true'); + }); + + it('passes enableDragDrop to CustomTableBody', () => { + render(); + const body = screen.getByTestId('custom-table-body'); + expect(body.dataset.enableDragDrop).toBe('true'); + }); + + it('passes false for enableDragDrop by default', () => { + render(); + expect( + screen.getByTestId('custom-table-header').dataset.enableDragDrop + ).toBe('false'); + expect( + screen.getByTestId('custom-table-body').dataset.enableDragDrop + ).toBe('false'); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx new file mode 100644 index 00000000..24ed850e --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx @@ -0,0 +1,325 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() })); + +// ── @dnd-kit/sortable ───────────────────────────────────────────────────────── +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: vi.fn(() => ({ + attributes: { role: 'button' }, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + })), +})); + +// ── @dnd-kit/utilities ──────────────────────────────────────────────────────── +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: vi.fn(() => ''), + }, + }, +})); + +// ── @mantine/core ───────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style, onClick, onMouseDown, ...rest }) => ( +
+ {children} +
+ ), + Flex: ({ children, align, style }) => ( +
+ {children} +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + GripVertical: ({ size, opacity }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../../store/channelsTable'; +import { useSortable } from '@dnd-kit/sortable'; +import CustomTableBody from '../CustomTableBody'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeCell = (id, columnId, grow = false, maxSize = null) => ({ + id: `${id}-${columnId}`, + column: { + id: columnId, + columnDef: { + grow, + ...(maxSize && { maxSize }), + }, + }, +}); + +const makeRow = (id, cells = [], originalId = null) => ({ + id: `row-${id}`, + original: { id: originalId ?? id }, + getVisibleCells: () => cells, +}); + +const defaultProps = (overrides = {}) => { + const row1 = makeRow(1, [makeCell(1, 'name'), makeCell(1, 'actions')]); + const row2 = makeRow(2, [makeCell(2, 'name'), makeCell(2, 'actions')]); + + return { + getRowModel: vi.fn(() => ({ rows: [row1, row2] })), + expandedRowIds: [], + expandedRowRenderer: vi.fn(() =>
), + renderBodyCell: vi.fn(({ cell }) => ( + {cell.id} + )), + getRowStyles: null, + tableCellProps: null, + enableDragDrop: false, + selectedTableIdsSet: null, + handleRowClickRef: null, + ...overrides, + }; +}; + +const setupMocks = ({ isUnlocked = false } = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ isUnlocked }) + ); +}; + +describe('CustomTableBody', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders a tbody container', () => { + render(); + expect(document.querySelector('.tbody')).toBeInTheDocument(); + }); + + it('renders a row for each entry in getRowModel', () => { + render(); + expect(document.querySelectorAll('.tr')).toHaveLength(2); + }); + + it('renders no rows when getRowModel returns empty', () => { + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [] })), + }); + render(); + expect(document.querySelectorAll('.tr')).toHaveLength(0); + }); + + it('applies tr-even class to even-indexed rows', () => { + render(); + const rows = document.querySelectorAll('.tr'); + expect(rows[0].classList.contains('tr-even')).toBe(true); + }); + + it('applies tr-odd class to odd-indexed rows', () => { + render(); + const rows = document.querySelectorAll('.tr'); + expect(rows[1].classList.contains('tr-odd')).toBe(true); + }); + + it('calls renderBodyCell for each visible cell', () => { + const renderBodyCell = vi.fn(({ cell }) => {cell.id}); + render(); + // 2 rows × 2 cells each = 4 calls + expect(renderBodyCell).toHaveBeenCalledTimes(4); + }); + + it('renders td containers for each visible cell', () => { + render(); + expect(document.querySelectorAll('.td')).toHaveLength(4); + }); + }); + + // ── Row styles ───────────────────────────────────────────────────────────── + + describe('row styles', () => { + it('applies custom className from getRowStyles', () => { + const getRowStyles = vi.fn(() => ({ className: 'custom-row-class' })); + render(); + expect(document.querySelector('.custom-row-class')).toBeInTheDocument(); + }); + }); + + // ── Row click ────────────────────────────────────────────────────────────── + + describe('row click', () => { + it('calls handleRowClickRef.current with row id when row is clicked', () => { + const handleRowClick = vi.fn(); + const handleRowClickRef = { current: handleRowClick }; + const row = makeRow(1, [makeCell(1, 'name')], 42); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + handleRowClickRef, + }); + render(); + fireEvent.click(document.querySelector('.tr')); + expect(handleRowClick).toHaveBeenCalledWith(42, expect.any(Object)); + }); + + it('does not throw when handleRowClickRef is null', () => { + render( + + ); + expect(() => + fireEvent.click(document.querySelector('.tr')) + ).not.toThrow(); + }); + + it('prevents default on mousedown with shift key', () => { + render(); + const tr = document.querySelector('.tr'); + const event = new MouseEvent('mousedown', { + shiftKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + tr.dispatchEvent(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + // ── Expanded rows ────────────────────────────────────────────────────────── + + describe('expanded rows', () => { + it('renders expanded row content when row is in expandedRowIds', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() => ( +
Expanded!
+ )); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [1], + expandedRowRenderer, + }); + render(); + expect(screen.getByTestId('expanded-content')).toBeInTheDocument(); + }); + + it('does not render expanded row content when row is not expanded', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() => ( +
Expanded!
+ )); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [], + expandedRowRenderer, + }); + render(); + expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument(); + }); + + it('calls expandedRowRenderer with the row when expanded', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() =>
); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [1], + expandedRowRenderer, + }); + render(); + expect(expandedRowRenderer).toHaveBeenCalledWith({ row }); + }); + }); + + // ── Drag and drop ────────────────────────────────────────────────────────── + + describe('drag and drop', () => { + it('does not render grip handle when enableDragDrop is false', () => { + render(); + expect(screen.queryByTestId('grip-vertical')).not.toBeInTheDocument(); + }); + + it('does not render grip handle when enableDragDrop is true but table is locked', () => { + setupMocks({ isUnlocked: false }); + render(); + expect(screen.queryByTestId('grip-vertical')).not.toBeInTheDocument(); + }); + + it('renders grip handle when enableDragDrop is true and table is unlocked', () => { + setupMocks({ isUnlocked: true }); + vi.mocked(useSortable).mockReturnValue({ + attributes: { role: 'button' }, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + }); + const row = makeRow(1, [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + enableDragDrop: true, + }); + render(); + expect(screen.getByTestId('grip-vertical')).toBeInTheDocument(); + }); + + it('calls useSortable with row id', () => { + const row = makeRow('abc', [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + }); + render(); + expect(useSortable).toHaveBeenCalledWith( + expect.objectContaining({ id: 'row-abc' }) + ); + }); + + it('disables useSortable when enableDragDrop is false', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + enableDragDrop: false, + }); + render(); + expect(useSortable).toHaveBeenCalledWith( + expect.objectContaining({ disabled: true }) + ); + }); + }); + + // ── Memoization comparator ───────────────────────────────────────────────── + + describe('MemoizedTableRow comparator', () => { + it('does not re-render when row.original reference is unchanged', () => { + const renderBodyCell = vi.fn(({ cell }) => {cell.id}); + const original = { id: 1 }; + const row = { id: 'row-1', original, getVisibleCells: () => [] }; + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + renderBodyCell, + }); + const { rerender } = render(); + const callCount = renderBodyCell.mock.calls.length; + + // Rerender with same original reference + rerender(); + expect(renderBodyCell.mock.calls.length).toBe(callCount); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx new file mode 100644 index 00000000..b29bf7ef --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx @@ -0,0 +1,304 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() })); + +// ── @tanstack/react-table ───────────────────────────────────────────────────── +vi.mock('@tanstack/react-table', () => ({ + flexRender: vi.fn((content) => + typeof content === 'string' ? content : content?.() + ), +})); + +// ── MultiSelectHeaderWrapper ────────────────────────────────────────────────── +vi.mock('../MultiSelectHeaderWrapper', () => ({ + default: ({ children }) => ( +
{children}
+ ), +})); + +// ── @mantine/core ───────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style, 'data-header-pinned': pinned }) => ( +
+ {children} +
+ ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, indeterminate, onChange, size }) => ( + { + if (el) el.indeterminate = !!indeterminate; + }} + onChange={onChange} + data-size={size} + /> + ), + Flex: ({ children, align, style }) => ( +
+ {children} +
+ ), +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../../store/channelsTable'; +import CustomTableHeader from '../CustomTableHeader'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a minimal header group structure that CustomTableHeader expects. + * Each header entry maps an id to a column definition. + */ +const makeHeader = ( + id, + { grow = false, maxSize, canResize = false, isResizing = false, style } = {} +) => ({ + id, + column: { + id, + columnDef: { id, header: id.toUpperCase(), grow, maxSize, style }, + getCanResize: () => canResize, + getIsResizing: () => isResizing, + }, + getContext: () => ({}), + getResizeHandler: () => vi.fn(), + getSize: () => 100, +}); + +const makeHeaderGroups = (headers) => [{ id: 'hg-0', headers }]; + +const defaultProps = (overrides = {}) => ({ + getHeaderGroups: () => + makeHeaderGroups([makeHeader('name'), makeHeader('status')]), + allRowIds: ['1', '2', '3'], + selectedTableIds: [], + headerCellRenderFns: {}, + onSelectAllChange: vi.fn(), + tableCellProps: vi.fn(() => ({})), + headerPinned: true, + enableDragDrop: false, + ...overrides, +}); + +const setupStore = ({ isUnlocked = false } = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ isUnlocked }) + ); +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('CustomTableHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupStore(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders a thead element', () => { + render(); + expect(screen.getByTestId('thead')).toBeInTheDocument(); + }); + + it('renders header cells for each header in the group', () => { + render( + + makeHeaderGroups([makeHeader('name'), makeHeader('status')]), + })} + /> + ); + // flexRender returns the header string (id.toUpperCase()) for unknown headers + expect(screen.getByText('NAME')).toBeInTheDocument(); + expect(screen.getByText('STATUS')).toBeInTheDocument(); + }); + + it('wraps each cell in MultiSelectHeaderWrapper', () => { + render(); + expect( + screen.getAllByTestId('multi-select-wrapper').length + ).toBeGreaterThan(0); + }); + }); + + // ── headerPinned ────────────────────────────────────────────────────────── + + describe('headerPinned', () => { + it('sets data-header-pinned="true" when headerPinned is true', () => { + render(); + expect(screen.getByTestId('thead')).toHaveAttribute( + 'data-header-pinned', + 'true' + ); + }); + + it('sets data-header-pinned="false" when headerPinned is false', () => { + render(); + expect(screen.getByTestId('thead')).toHaveAttribute( + 'data-header-pinned', + 'false' + ); + }); + }); + + // ── select column ───────────────────────────────────────────────────────── + + describe('select column checkbox', () => { + const selectHeaderGroups = () => makeHeaderGroups([makeHeader('select')]); + + it('renders checkbox for the select column', () => { + render( + selectHeaderGroups(), + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).toBeInTheDocument(); + }); + + it('checkbox is unchecked when no rows are selected', () => { + render( + selectHeaderGroups(), + allRowIds: ['1', '2'], + selectedTableIds: [], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked(); + }); + + it('checkbox is unchecked when allRowIds is empty', () => { + render( + selectHeaderGroups(), + allRowIds: [], + selectedTableIds: [], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked(); + }); + + it('checkbox is checked when all rows are selected', () => { + render( + selectHeaderGroups(), + allRowIds: ['1', '2'], + selectedTableIds: ['1', '2'], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).toBeChecked(); + }); + + it('calls onSelectAllChange when checkbox is changed', () => { + const onSelectAllChange = vi.fn(); + render( + selectHeaderGroups(), + onSelectAllChange, + })} + /> + ); + fireEvent.click(screen.getByTestId('select-all-checkbox')); + expect(onSelectAllChange).toHaveBeenCalled(); + }); + }); + + // ── custom headerCellRenderFns ───────────────────────────────────────────── + + describe('headerCellRenderFns', () => { + it('uses custom render function when provided for a column id', () => { + const customRender = vi.fn(() => ( + Custom Name + )); + render( + makeHeaderGroups([makeHeader('name')]), + headerCellRenderFns: { name: customRender }, + })} + /> + ); + expect(customRender).toHaveBeenCalled(); + expect(screen.getByTestId('custom-header')).toBeInTheDocument(); + }); + + it('falls back to flexRender when no custom render fn provided', () => { + render( + makeHeaderGroups([makeHeader('name')]), + headerCellRenderFns: {}, + })} + /> + ); + expect(screen.getByText('NAME')).toBeInTheDocument(); + }); + }); + + // ── resize handle ───────────────────────────────────────────────────────── + + describe('resize handle', () => { + it('renders resize handle when column canResize is true', () => { + render( + + makeHeaderGroups([makeHeader('name', { canResize: true })]), + })} + /> + ); + // The resizer div is rendered — check for the class + const resizers = document.querySelectorAll('.resizer'); + expect(resizers.length).toBeGreaterThan(0); + }); + + it('does not render resize handle when column canResize is false', () => { + render( + + makeHeaderGroups([makeHeader('name', { canResize: false })]), + })} + /> + ); + const resizers = document.querySelectorAll('.resizer'); + expect(resizers.length).toBe(0); + }); + + it('applies isResizing class when column is being resized', () => { + render( + + makeHeaderGroups([ + makeHeader('name', { canResize: true, isResizing: true }), + ]), + })} + /> + ); + expect(document.querySelector('.resizer.isResizing')).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx new file mode 100644 index 00000000..74870c39 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx @@ -0,0 +1,360 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; + +// ── Mantine core mock ────────────────────────────────────────────────────────── +// We need MultiSelect to be identifiable by reference (element.type === MultiSelect), +// so we export it from the mock so the component under test can compare against it. +vi.mock('@mantine/core', async () => { + const MockMultiSelect = ({ + value = [], + data = [], + onChange, + label + }) => ( +
+ {label && } + onChange?.(e.target.value ? [e.target.value] : [])} + /> + {(data || []).map((opt) => ( + + ))} +
+ ); + MockMultiSelect.displayName = 'MultiSelect'; + + return { + MultiSelect: MockMultiSelect, + Box: ({ children, style, ...props }) => ( +
+ {children} +
+ ), + Flex: ({ children, style }) => ( +
+ {children} +
+ ), + Pill: ({ + children, + onRemove, + onClick, + withRemoveButton, + removeButtonProps, + }) => ( + + {children} + {withRemoveButton && ( + + )} + + ), + Tooltip: ({ children, label }) => ( +
+ {label} + {children} +
+ ), + }; +}); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import { MultiSelect } from '@mantine/core'; +import MultiSelectHeaderWrapper from '../MultiSelectHeaderWrapper'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeData = (n = 3) => + Array.from({ length: n }, (_, i) => ({ + value: `val-${i}`, + label: `Label ${i}`, + })); + +describe('MultiSelectHeaderWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Non-MultiSelect passthrough ──────────────────────────────────────────── + + describe('non-MultiSelect children', () => { + it('renders a plain div child unchanged', () => { + render( + +
Hello
+
+ ); + expect(screen.getByTestId('plain-child')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); + + it('renders a plain text node unchanged', () => { + render( + {'just text'} + ); + expect(screen.getByText('just text')).toBeInTheDocument(); + }); + + it('recursively passes through nested non-MultiSelect elements', () => { + render( + +
+ Nested +
+
+ ); + expect(screen.getByTestId('nested')).toBeInTheDocument(); + }); + }); + + // ── MultiSelect with no selections ──────────────────────────────────────── + + describe('MultiSelect with no selections', () => { + it('renders the MultiSelect directly when value is empty', () => { + render( + + + + ); + expect(screen.getByTestId('multiselect')).toBeInTheDocument(); + }); + + it('does not render pills when value is empty', () => { + render( + + + + ); + expect(screen.queryByTestId('pill')).not.toBeInTheDocument(); + }); + + it('does not render a tooltip when value is empty', () => { + render( + + + + ); + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument(); + }); + + it('handles undefined value as no selections', () => { + render( + + + + ); + expect(screen.queryByTestId('pill')).not.toBeInTheDocument(); + }); + }); + + // ── MultiSelect with one selection ──────────────────────────────────────── + + describe('MultiSelect with one selection', () => { + const data = makeData(3); + const value = ['val-0']; + + it('renders a pill showing the first selected label', () => { + render( + + + + ); + const pill = screen.getByTestId('pill'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveTextContent('Label 0'); + }); + + it('renders exactly one pill for a single selection', () => { + render( + + + + ); + expect(screen.getAllByTestId('pill')).toHaveLength(1); + }); + + it('renders a tooltip wrapping the pill area', () => { + render( + + + + ); + expect(screen.getByTestId('tooltip')).toBeInTheDocument(); + }); + + it('still renders the underlying MultiSelect', () => { + render( + + + + ); + expect(screen.getByTestId('multiselect')).toBeInTheDocument(); + }); + + it('falls back to the raw value when label is not found in data', () => { + render( + + + + ); + const pill = screen.getByTestId('pill'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveTextContent('unknown-val'); + }); + + it('calls onChange with remaining values when the remove button is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + fireEvent.click(screen.getByTestId('pill-remove')); + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + // ── MultiSelect with multiple selections ────────────────────────────────── + + describe('MultiSelect with multiple selections', () => { + const data = makeData(5); + const value = ['val-0', 'val-1', 'val-2']; + + it('renders two pills: first label and overflow count', () => { + render( + + + + ); + expect(screen.getAllByTestId('pill')).toHaveLength(2); + }); + + it('shows the first label in the first pill', () => { + render( + + + + ); + const pills = screen.getAllByTestId('pill'); + expect(pills[0]).toHaveTextContent('Label 0'); + }); + + it('shows "+N" overflow count in the second pill', () => { + render( + + + + ); + // 3 selections → "+2" + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('calls onChange with slice(1) when first pill remove is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + fireEvent.click(removeButtons[0]); + expect(onChange).toHaveBeenCalledWith(['val-1', 'val-2']); + }); + + it('calls onChange with [] when overflow pill remove is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + fireEvent.click(removeButtons[1]); + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + // ── Tooltip with more than 10 selections ────────────────────────────────── + + describe('tooltip overflow for > 10 selections', () => { + it('shows "+N more" text in tooltip area when more than 10 values are selected', () => { + const data = makeData(15); + const value = data.map((d) => d.value); + + render( + + + + ); + expect(screen.getByText('+5 more')).toBeInTheDocument(); + }); + + it('does not show "+N more" when selections are 10 or fewer', () => { + const data = makeData(10); + const value = data.map((d) => d.value); + + render( + + + + ); + expect(screen.queryByText(/more$/)).not.toBeInTheDocument(); + }); + }); + + // ── Recursive enhancement ────────────────────────────────────────────────── + + describe('recursive MultiSelect enhancement', () => { + it('enhances a MultiSelect nested inside a wrapper div', () => { + render( + +
+ +
+
+ ); + expect(screen.getByTestId('pill')).toBeInTheDocument(); + expect(screen.getByTestId('pill')).toHaveTextContent('Label 0'); + }); + }); + + // ── onChange absence ────────────────────────────────────────────────────── + + describe('onChange not provided', () => { + it('does not throw when onChange is undefined and remove is clicked', () => { + render( + + + + ); + expect(() => + fireEvent.click(screen.getByTestId('pill-remove')) + ).not.toThrow(); + }); + + it('does not throw when clearAll is triggered without onChange', () => { + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + expect(() => fireEvent.click(removeButtons[1])).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx new file mode 100644 index 00000000..cfbe315e --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx @@ -0,0 +1,846 @@ +import React from 'react'; +import { + renderHook, + act, + render, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── @tanstack/react-table ────────────────────────────────────────────────────── +vi.mock('@tanstack/react-table', () => ({ + getCoreRowModel: vi.fn(() => 'mock-core-row-model'), + useReactTable: vi.fn(), + flexRender: vi.fn(() => ), +})); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../../hooks/useTablePreferences', () => ({ + default: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../CustomTable', () => ({ + default: () =>
, +})); + +vi.mock('../CustomTableHeader', () => ({ + default: () =>
, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Center: ({ children, style, onClick }) => ( +
+ {children} +
+ ), + Checkbox: ({ checked, onChange }) => ( + {})} + /> + ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronRight: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import { useTable } from '../'; +import { useReactTable, flexRender } from '@tanstack/react-table'; +import useTablePreferences from '../../../../hooks/useTablePreferences'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupMocks = ({ headerPinned = false, tableSize = 'default' } = {}) => { + vi.mocked(useReactTable).mockReturnValue({ + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + }); + vi.mocked(useTablePreferences).mockReturnValue({ + headerPinned, + setHeaderPinned: vi.fn(), + tableSize, + setTableSize: vi.fn(), + }); +}; + +const makeRow = (id) => ({ original: { id } }); + +const makeCell = (columnId) => ({ + column: { id: columnId, columnDef: {} }, + getContext: () => ({}), +}); + +const makeClickEvent = (overrides = {}) => ({ + shiftKey: false, + ctrlKey: false, + metaKey: false, + target: { closest: () => null }, + ...overrides, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('useTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up any body class/style mutations made by keyboard handlers + document.body.classList.remove('shift-key-active'); + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + document.body.style.removeProperty('-ms-user-select'); + document.body.style.removeProperty('cursor'); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('starts with an empty selectedTableIds array', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + expect(result.current.selectedTableIds).toEqual([]); + }); + + it('starts with an empty expandedRowIds array', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + expect(result.current.expandedRowIds).toEqual([]); + }); + + it('returns renderBodyCell as a function', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(typeof result.current.renderBodyCell).toBe('function'); + }); + + it('exposes allRowIds in the returned object', () => { + setupMocks(); + const allRowIds = [10, 20, 30]; + const { result } = renderHook(() => + useTable({ allRowIds, columns: [], data: [] }) + ); + expect(result.current.allRowIds).toEqual(allRowIds); + }); + + it('returns headerPinned and tableSize from useTablePreferences', () => { + setupMocks({ headerPinned: true, tableSize: 'compact' }); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('compact'); + }); + + it('passes headerCellRenderFns through to the returned object', () => { + setupMocks(); + const headerCellRenderFns = { name: vi.fn() }; + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [], headerCellRenderFns }) + ); + expect(result.current.headerCellRenderFns).toBe(headerCellRenderFns); + }); + + it('defaults to an empty bodyCellRenderFns when not provided', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(result.current.bodyCellRenderFns).toEqual({}); + }); + }); + + // ── Keyboard event handling ──────────────────────────────────────────────── + + describe('keyboard event handling', () => { + it('adds shift-key-active class and disables text selection on Shift keydown', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(true); + expect(document.body.style.userSelect).toBe('none'); + }); + + it('removes shift-key-active class and restores text selection on Shift keyup', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + fireEvent.keyUp(window, { key: 'Shift' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + expect(document.body.style.userSelect).toBe(''); + }); + + it('removes shift-key-active class on window blur', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + fireEvent.blur(window); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + }); + + it('does not add shift-key-active class for non-Shift keydown', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'a' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + }); + }); + + // ── onSelectAllChange ────────────────────────────────────────────────────── + + describe('onSelectAllChange', () => { + it('selects all allRowIds when checked', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + + expect(result.current.selectedTableIds).toEqual([1, 2, 3]); + }); + + it('clears selectedTableIds when unchecked', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + await act(async () => { + result.current.onSelectAllChange({ target: { checked: false } }); + }); + + expect(result.current.selectedTableIds).toEqual([]); + }); + + it('calls onRowSelectionChange callback with all ids when selecting all', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + + expect(onRowSelectionChange).toHaveBeenCalledWith([1, 2]); + }); + + it('calls onRowSelectionChange with empty array when deselecting all', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + await act(async () => { + result.current.onSelectAllChange({ target: { checked: false } }); + }); + + expect(onRowSelectionChange).toHaveBeenLastCalledWith([]); + }); + }); + + // ── updateSelectedTableIds ───────────────────────────────────────────────── + + describe('updateSelectedTableIds', () => { + it('updates selectedTableIds to the given array', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([2, 3]); + }); + + expect(result.current.selectedTableIds).toEqual([2, 3]); + }); + + it('calls onRowSelectionChange with the new ids', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2, 3], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + + expect(onRowSelectionChange).toHaveBeenCalledWith([1]); + }); + + it('does not throw when onRowSelectionChange is not provided', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + await expect( + act(async () => result.current.updateSelectedTableIds([1])) + ).resolves.not.toThrow(); + }); + }); + + // ── handleRowClickRef ────────────────────────────────────────────────────── + + describe('handleRowClickRef', () => { + it('does nothing when the click target is an interactive element', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + const button = document.createElement('button'); + await act(async () => { + result.current.handleRowClickRef.current(1, { + shiftKey: true, + ctrlKey: false, + metaKey: false, + target: { + closest: (sel) => (sel.includes('button') ? button : null), + }, + }); + }); + + expect(onRowSelectionChange).not.toHaveBeenCalled(); + }); + + it('ctrl+click adds an unselected row to selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toContain(2); + }); + + it('ctrl+click removes an already-selected row from selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([2]); + }); + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + + expect(result.current.selectedTableIds).not.toContain(2); + }); + + it('meta+click adds an unselected row to selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.handleRowClickRef.current( + 3, + makeClickEvent({ metaKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toContain(3); + }); + + it('plain click (no modifier key) does not change selection', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.handleRowClickRef.current(1, makeClickEvent()); + }); + + expect(onRowSelectionChange).not.toHaveBeenCalled(); + }); + + it('shift+click selects the range between lastClickedId and the clicked row', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3, 4, 5], columns: [], data: [] }) + ); + + // Ctrl+click row 2 to establish lastClickedId + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + // Shift+click row 4 to select range [2, 3, 4] + await act(async () => { + result.current.handleRowClickRef.current( + 4, + makeClickEvent({ shiftKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toEqual( + expect.arrayContaining([2, 3, 4]) + ); + expect(result.current.selectedTableIds).toHaveLength(3); + }); + + it('shift+click preserves rows selected outside the shift-click range', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3, 4, 5], columns: [], data: [] }) + ); + + // Pre-select row 1 (will be outside the upcoming range) + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + // Ctrl+click row 5 to set lastClickedId=5 (also adds 5 to selection) + await act(async () => { + result.current.handleRowClickRef.current( + 5, + makeClickEvent({ ctrlKey: true }) + ); + }); + // Shift+click row 3 → range is [3, 4, 5]; row 1 is preserved + await act(async () => { + result.current.handleRowClickRef.current( + 3, + makeClickEvent({ shiftKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toEqual( + expect.arrayContaining([1, 3, 4, 5]) + ); + }); + }); + + // ── renderBodyCell ───────────────────────────────────────────────────────── + + describe('renderBodyCell', () => { + describe('bodyCellRenderFns override', () => { + it('calls the custom render fn and renders its output for the matching column id', () => { + setupMocks(); + const customRenderFn = vi.fn( + () => + ); + const { result } = renderHook(() => + useTable({ + allRowIds: [], + columns: [], + data: [], + bodyCellRenderFns: { 'my-col': customRenderFn }, + }) + ); + + const row = makeRow(1); + const cell = makeCell('my-col'); + const { getByTestId } = render( + result.current.renderBodyCell({ row, cell }) + ); + + expect(customRenderFn).toHaveBeenCalledWith({ row, cell }); + expect(getByTestId('custom-cell')).toBeInTheDocument(); + }); + }); + + describe('select column', () => { + it('renders a Checkbox for the select column', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).toBeInTheDocument(); + }); + + it('checkbox is unchecked for an unselected row', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + + it('checkbox is checked when the row is pre-selected', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).toBeChecked(); + }); + + it('checking the checkbox adds the row to selectedTableIds', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).toContain(1); + }); + + it('unchecking the checkbox removes the row from selectedTableIds', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + // Pre-select row 1 so checkbox renders as checked + await act(async () => { + tableRef.current.updateSelectedTableIds([1]); + }); + + // Click to uncheck + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).not.toContain(1); + }); + + it('checking a row does not affect other selected rows', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + // Pre-select rows 2 and 3 + await act(async () => { + tableRef.current.updateSelectedTableIds([2, 3]); + }); + + // Check row 1 + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).toEqual( + expect.arrayContaining([1, 2, 3]) + ); + }); + }); + + describe('expand column', () => { + it('renders ChevronRight for a non-expanded row', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId, queryByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + expect(getByTestId('icon-chevron-right')).toBeInTheDocument(); + expect(queryByTestId('icon-chevron-down')).not.toBeInTheDocument(); + }); + + it('clicking the expand cell adds the row id to expandedRowIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const rendered = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + await act(async () => { + fireEvent.click(rendered.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toContain(1); + }); + + it('clicking an already-expanded row clears expandedRowIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + expect(result.current.expandedRowIds).toEqual([1]); + rendered1.unmount(); + + // Click again to collapse + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered2.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toEqual([]); + }); + + it('shows ChevronDown after the row is expanded', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + // Expand the row + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + rendered1.unmount(); + + // Re-render with updated state — should now show ChevronDown + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + expect(rendered2.getByTestId('icon-chevron-down')).toBeInTheDocument(); + expect( + rendered2.queryByTestId('icon-chevron-right') + ).not.toBeInTheDocument(); + }); + + it('only one row can be expanded at a time (prior expanded row is collapsed)', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + + // Expand row 1 + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + expect(result.current.expandedRowIds).toEqual([1]); + rendered1.unmount(); + + // Expand row 2 — row 1 should no longer be expanded + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(2), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered2.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toEqual([2]); + }); + + it('calls onRowExpansionChange with the new expanded ids', async () => { + setupMocks(); + const onRowExpansionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1], + columns: [], + data: [], + onRowExpansionChange, + }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + await act(async () => { + fireEvent.click(getByTestId('center')); + }); + + expect(onRowExpansionChange).toHaveBeenCalledWith([1]); + }); + }); + + describe('default column', () => { + it('calls flexRender for an unrecognized column id', () => { + setupMocks(); + vi.mocked(flexRender).mockReturnValue( + + ); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + + const cell = makeCell('some-data-column'); + render( + result.current.renderBodyCell({ row: makeRow(1), cell }) + ); + + expect(flexRender).toHaveBeenCalledWith( + cell.column.columnDef.cell, + cell.getContext() + ); + }); + + it('renders the output returned by flexRender', () => { + setupMocks(); + vi.mocked(flexRender).mockReturnValue( + + ); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('data-col'), + }) + ); + + expect(getByTestId('flex-rendered')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index e691dc6e..482f87a7 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -2,13 +2,8 @@ import { Center, Checkbox } from '@mantine/core'; import CustomTable from './CustomTable'; import CustomTableHeader from './CustomTableHeader'; import useTablePreferences from '../../../hooks/useTablePreferences'; - -import { - useReactTable, - getCoreRowModel, - flexRender, -} from '@tanstack/react-table'; -import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; const useTable = ({ @@ -84,8 +79,6 @@ const useTable = ({ }; }, [handleKeyDown, handleKeyUp]); - const rowCount = allRowIds.length; - const table = useReactTable({ defaultColumn: { minSize: 0, @@ -172,7 +165,7 @@ const useTable = ({ return true; // Return true to indicate we've handled it }; - const handleRowClick = (rowId, e) => { + handleRowClickRef.current = (rowId, e) => { if ( e.target.closest( 'button, a, input, select, textarea, [role="menuitem"], [role="option"], [role="button"]' @@ -193,7 +186,6 @@ const useTable = ({ updateSelectedTableIds([...newSet]); } }; - handleRowClickRef.current = handleRowClick; const renderBodyCell = ({ row, cell }) => { if (bodyCellRenderFns[cell.column.id]) { diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 56a3de62..80da7c24 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -1,46 +1,50 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import API from '../../api'; +import { useEffect, useMemo, useState } from 'react'; import useEPGsStore from '../../store/epgs'; import EPGForm from '../forms/EPG'; import DummyEPGForm from '../forms/DummyEPG'; import { ActionIcon, - Text, - Tooltip, Box, - Paper, Button, Flex, - useMantineTheme, - Switch, + Menu, + MenuDropdown, + MenuItem, + MenuTarget, + Paper, Progress, Stack, - Group, - Menu, + Switch, + Text, + Tooltip, + useMantineTheme, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import { - ArrowDownWideNarrow, - ArrowUpDown, - ArrowUpNarrowWide, + ChevronDown, RefreshCcw, SquareMinus, SquarePen, SquarePlus, - ChevronDown, } from 'lucide-react'; -import { format } from '../../utils/dateTimeUtils.js'; +import { format, useDateTimeFormat } from '../../utils/dateTimeUtils.js'; import useLocalStorage from '../../hooks/useLocalStorage'; -import { useDateTimeFormat } from '../../utils/dateTimeUtils.js'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from './CustomTable'; - -// Helper function to format status text -const formatStatusText = (status) => { - if (!status) return 'Unknown'; - return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); -}; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + deleteEpg, + formatStatusText, + getProgressInfo, + getProgressLabel, + getSortedEpgs, + refreshEpg, + updateEpg, +} from '../../utils/tables/EPGsTableUtils.js'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from './M3uTableUtils.jsx'; // Helper function to get status text color const getStatusColor = (status) => { @@ -116,38 +120,10 @@ const EPGStatusCell = ({ epg }) => { progress.status === 'in_progress' || (progress.action === 'parsing_channels' && epg.status === 'parsing')) ) { - let label = ''; - switch (progress.action) { - case 'downloading': - label = 'Downloading'; - break; - case 'extracting': - label = 'Extracting'; - break; - case 'parsing_channels': - label = 'Parsing Channels'; - break; - case 'parsing_programs': - label = 'Parsing Programs'; - break; - default: - return null; - } + const label = getProgressLabel(progress.action); + if (!label) return null; - let additionalInfo = ''; - if (progress.message) { - additionalInfo = progress.message; - } else if ( - progress.processed !== undefined && - progress.channels !== undefined - ) { - additionalInfo = `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`; - } else if ( - progress.processed !== undefined && - progress.total !== undefined - ) { - additionalInfo = `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`; - } + const additionalInfo = getProgressInfo(progress); return ( @@ -225,7 +201,6 @@ const EPGsTable = () => { const [epg, setEPG] = useState(null); const [epgModalOpen, setEPGModalOpen] = useState(false); const [dummyEpgModalOpen, setDummyEpgModalOpen] = useState(false); - const [rowSelection, setRowSelection] = useState([]); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [epgToDelete, setEpgToDelete] = useState(null); @@ -251,11 +226,11 @@ const EPGsTable = () => { } // Send only the is_active field to trigger our special handling - await API.updateEPG( + await updateEpg( { - id: epg.id, is_active: !epg.is_active, }, + epg, true ); // Add a new parameter to indicate this is just a toggle } catch (error) { @@ -395,7 +370,6 @@ const EPGsTable = () => { [fullDateTimeFormat] ); - const [isLoading, setIsLoading] = useState(true); const [sorting, setSorting] = useState([]); const editEPG = async (epg = null) => { @@ -436,7 +410,7 @@ const EPGsTable = () => { const executeDeleteEPG = async (id) => { setDeleting(true); try { - await API.deleteEPG(id); + await deleteEpg(id); } finally { setDeleting(false); setConfirmDeleteOpen(false); @@ -444,8 +418,8 @@ const EPGsTable = () => { }; const refreshEPG = async (id, force = false) => { - await API.refreshEPG(id, force); - notifications.show({ + await refreshEpg(id, force); + showNotification({ title: 'EPG refresh initiated', }); }; @@ -502,74 +476,11 @@ const EPGsTable = () => { } }; - const renderHeaderCell = (header) => { - let sortingIcon = ArrowUpDown; - if (sorting[0]?.id == header.id) { - if (sorting[0].desc === false) { - sortingIcon = ArrowUpNarrowWide; - } else { - sortingIcon = ArrowDownWideNarrow; - } - } + const onSortingChange = makeSortingChangeHandler(sorting, setSorting, (col, desc) => + setData(getSortedEpgs(epgs, col, desc)) + ); - switch (header.id) { - default: - return ( - - - {header.column.columnDef.header} - - {header.column.columnDef.sortable && ( -
- {React.createElement(sortingIcon, { - onClick: () => onSortingChange(header.id), - size: 14, - })} -
- )} -
- ); - } - }; - - const onSortingChange = (column) => { - console.log(column); - const sortField = sorting[0]?.id; - const sortDirection = sorting[0]?.desc; - - const newSorting = []; - if (sortField == column) { - if (sortDirection == false) { - newSorting[0] = { - id: column, - desc: true, - }; - } - } else { - newSorting[0] = { - id: column, - desc: false, - }; - } - - setSorting(newSorting); - if (newSorting.length > 0) { - const compareColumn = newSorting[0].id; - const compareDesc = newSorting[0].desc; - - setData( - epgs.sort((a, b) => { - console.log(a); - console.log(newSorting[0].id); - if (a[compareColumn] !== b[compareColumn]) { - return compareDesc ? 1 : -1; - } - - return 0; - }) - ); - } - }; + const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange); const table = useTable({ columns, @@ -578,7 +489,6 @@ const EPGsTable = () => { enablePagination: false, enableRowSelection: false, renderTopToolbar: false, - onRowSelectionChange: setRowSelection, manualSorting: true, bodyCellRenderFns: { actions: renderBodyCell, @@ -632,7 +542,7 @@ const EPGsTable = () => { EPGs - + - - - - Standard EPG Source - - Dummy EPG Source - + + + Standard EPG Source + Dummy EPG Source + @@ -668,11 +576,8 @@ const EPGsTable = () => { diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 7c718590..ff5784d5 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -1,44 +1,46 @@ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import LogoForm from '../forms/Logo'; import useLogosStore from '../../store/logos'; import useLocalStorage from '../../hooks/useLocalStorage'; import { - SquarePlus, + ExternalLink, SquareMinus, SquarePen, - ExternalLink, - Filter, - Trash2, + SquarePlus, Trash, } from 'lucide-react'; import { ActionIcon, - Box, - Text, - Paper, - Button, - Flex, - Group, - useMantineTheme, - LoadingOverlay, - Stack, - Image, - Center, Badge, - Tooltip, - Select, - TextInput, - Menu, + Box, + Button, + Center, Checkbox, - Pagination, + Group, + Image, + LoadingOverlay, NativeSelect, + Pagination, + Paper, + Select, + Stack, + Text, + TextInput, + Tooltip, + useMantineTheme, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + cleanupUnusedLogos, + deleteLogo, + deleteLogos, + generateUsageLabel, + getFilteredLogos, +} from '../../utils/tables/LogosTableUtils.js'; -const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { +const LogoRowActions = ({ theme, row, editLogo, handleDeleteLogo }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const onEdit = useCallback(() => { @@ -46,8 +48,8 @@ const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { }, [row.original, editLogo]); const onDelete = useCallback(() => { - deleteLogo(row.original.id); - }, [row.original.id, deleteLogo]); + handleDeleteLogo(row.original.id); + }, [row.original.id, handleDeleteLogo]); const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -127,24 +129,7 @@ const LogosTable = () => { }, [filters.name]); const data = useMemo(() => { - const logosArray = Object.values(logos || {}); - - // Apply filters - let filteredLogos = logosArray; - - if (debouncedNameFilter) { - filteredLogos = filteredLogos.filter((logo) => - logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) - ); - } - - if (filters.used === 'used') { - filteredLogos = filteredLogos.filter((logo) => logo.is_used); - } else if (filters.used === 'unused') { - filteredLogos = filteredLogos.filter((logo) => !logo.is_used); - } - - return filteredLogos.sort((a, b) => a.id - b.id); + return getFilteredLogos(logos, debouncedNameFilter, filters.used); }, [logos, debouncedNameFilter, filters.used]); // Get paginated data @@ -175,15 +160,15 @@ const LogosTable = () => { async (id, deleteFile = false) => { setIsLoading(true); try { - await API.deleteLogo(id, deleteFile); + await deleteLogo(id, deleteFile); await fetchAllLogos(); // Refresh all logos to maintain full view - notifications.show({ + showNotification({ title: 'Success', message: 'Logo deleted successfully', color: 'green', }); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Error', message: 'Failed to delete logo', color: 'red', @@ -206,16 +191,16 @@ const LogosTable = () => { setIsLoading(true); try { - await API.deleteLogos(Array.from(selectedRows), deleteFiles); + await deleteLogos(Array.from(selectedRows), deleteFiles); await fetchAllLogos(); // Refresh all logos to maintain full view - notifications.show({ + showNotification({ title: 'Success', message: `${selectedRows.size} logos deleted successfully`, color: 'green', }); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Error', message: 'Failed to delete logos', color: 'red', @@ -234,14 +219,14 @@ const LogosTable = () => { async (deleteFiles = false) => { setIsCleaningUp(true); try { - const result = await API.cleanupUnusedLogos(deleteFiles); + const result = await cleanupUnusedLogos(deleteFiles); let message = `Successfully deleted ${result.deleted_count} unused logos`; if (result.local_files_deleted > 0) { message += ` and deleted ${result.local_files_deleted} local files`; } - notifications.show({ + showNotification({ title: 'Cleanup Complete', message: message, color: 'green', @@ -249,8 +234,8 @@ const LogosTable = () => { // Force refresh all logos after cleanup to maintain full view await fetchAllLogos(true); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Cleanup Failed', message: 'Failed to cleanup unused logos', color: 'red', @@ -269,7 +254,7 @@ const LogosTable = () => { setLogoModalOpen(true); }, []); - const deleteLogo = useCallback( + const handleDeleteLogo = useCallback( async (id) => { const logosArray = Object.values(logos || {}); const logo = logosArray.find((l) => l.id === id); @@ -428,40 +413,7 @@ const LogosTable = () => { ); } - // Analyze channel_names to categorize types - const categorizeUsage = (names) => { - const types = { channels: 0, movies: 0, series: 0 }; - - names.forEach((name) => { - if (name.startsWith('Channel:')) types.channels++; - else if (name.startsWith('Movie:')) types.movies++; - else if (name.startsWith('Series:')) types.series++; - }); - - return types; - }; - - const types = categorizeUsage(channelNames); - const typeCount = Object.values(types).filter( - (count) => count > 0 - ).length; - - // Generate smart label based on usage - const generateLabel = () => { - if (typeCount === 1) { - // Only one type - be specific - if (types.channels > 0) - return `${types.channels} channel${types.channels !== 1 ? 's' : ''}`; - if (types.movies > 0) - return `${types.movies} movie${types.movies !== 1 ? 's' : ''}`; - if (types.series > 0) return `${types.series} series`; - } else { - // Multiple types - use generic "items" - return `${count} item${count !== 1 ? 's' : ''}`; - } - }; - - const label = generateLabel(); + const label = generateUsageLabel(channelNames, count); return ( { theme={theme} row={row} editLogo={editLogo} - deleteLogo={deleteLogo} + handleDeleteLogo={handleDeleteLogo} /> ), }, @@ -536,7 +488,7 @@ const LogosTable = () => { [ theme, editLogo, - deleteLogo, + handleDeleteLogo, selectedRows, handleSelectRow, handleSelectAll, diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 4a1d7e6f..fa21191e 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -1,11 +1,10 @@ -import React, { +import { useEffect, useMemo, useRef, useState, useCallback, } from 'react'; -import API from '../../api'; import usePlaylistsStore from '../../store/playlists'; import M3UForm from '../forms/M3U'; import ServerGroupsManagerModal from '../ServerGroupsManagerModal'; @@ -20,16 +19,11 @@ import { ActionIcon, Tooltip, Switch, - Group, - Center, } from '@mantine/core'; import { SquareMinus, SquarePen, RefreshCcw, - ArrowUpDown, - ArrowUpNarrowWide, - ArrowDownWideNarrow, SquarePlus, } from 'lucide-react'; import useLocalStorage from '../../hooks/useLocalStorage'; @@ -42,55 +36,42 @@ import { import ConfirmationDialog from '../../components/ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from './CustomTable'; +import { + deletePlaylist, + getExpirationInfo, + getExpirationTooltip, + getPlaylistAutoCreatedChannelsCount, + getSortedPlaylists, + getStatusColor, + getStatusContent, + formatStatusText, + refreshPlaylist, + updatePlaylist, +} from '../../utils/tables/M3UsTableUtils.js'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from './M3uTableUtils.jsx'; -// Helper function to format status text -const formatStatusText = (status) => { - switch (status) { - case 'idle': - return 'Idle'; - case 'fetching': - return 'Fetching'; - case 'parsing': - return 'Parsing'; - case 'error': - return 'Error'; - case 'success': - return 'Success'; - case 'pending_setup': - return 'Pending Setup'; - default: - return status - ? status.charAt(0).toUpperCase() + status.slice(1) - : 'Unknown'; - } -}; +const StatusRow = ({ label, value }) => ( + + {label}{value ? ':' : ''} + {value && {value}} + +); -// Helper function to get status text color -const getStatusColor = (status) => { - switch (status) { - case 'idle': - return 'gray.5'; - case 'fetching': - return 'blue.5'; - case 'parsing': - return 'indigo.5'; - case 'error': - return 'red.5'; - case 'success': - return 'green.5'; - case 'pending_setup': - return 'orange.5'; // Orange to indicate action needed - default: - return 'gray.5'; - } -}; +const StatusBox = ({ children }) => ( + + {children} + +); const RowActions = ({ tableSize, editPlaylist, - deletePlaylist, + handleDeletePlaylist, row, - refreshPlaylist, + handleRefreshPlaylist, }) => { const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -111,7 +92,7 @@ const RowActions = ({ variant="transparent" size={iconSize} color="red.9" - onClick={() => deletePlaylist(row.original.id)} + onClick={() => handleDeletePlaylist(row.original.id)} > @@ -119,7 +100,7 @@ const RowActions = ({ variant="transparent" size={iconSize} color="blue.5" - onClick={() => refreshPlaylist(row.original.id)} + onClick={() => handleRefreshPlaylist(row.original.id)} disabled={!row.original.is_active} > @@ -128,10 +109,10 @@ const RowActions = ({ ); }; + const M3UTable = () => { const [playlist, setPlaylist] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); - const [rowSelection, setRowSelection] = useState([]); const [playlistCreated, setPlaylistCreated] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -179,209 +160,58 @@ const M3UTable = () => { return 'Idle'; } - switch (data.action) { + const content = getStatusContent(data); + + switch (content.type) { case 'initializing': - return buildInitializingStats(); - + return ( + + ); case 'downloading': - return buildDownloadingStats(data); - - case 'processing_groups': - return buildGroupProcessingStats(data); - + return ( + + + + + + ); + case 'groups': + return ( + + + {content.elapsedTime && } + {content.groupsProcessed && } + + ); case 'parsing': - return buildParsingStats(data); - + return ( + + + {content.elapsedTime && } + {content.timeRemaining && } + {content.streamsProcessed && } + + ); + case 'error': + return ( + + Error: + + {content.error || 'Unknown error occurred'} + + + ); default: - return data.status === 'error' - ? buildErrorStats(data) - : `${data.action || 'Processing'}...`; + return content.label; } }; - const buildDownloadingStats = (data) => { - if (data.progress == 100) { - return 'Download complete!'; - } - - if (data.progress == 0) { - return 'Downloading...'; - } - - // Format time remaining in minutes:seconds - const timeRemaining = data.time_remaining - ? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` - : 'calculating...'; - - // Format speed with appropriate unit (KB/s or MB/s) - const speed = - data.speed >= 1024 - ? `${(data.speed / 1024).toFixed(2)} MB/s` - : `${Math.round(data.speed)} KB/s`; - - return ( - - - - - Downloading: - - {parseInt(data.progress)}% - - - - Speed: - - {speed} - - - - Time left: - - {timeRemaining} - - - - ); - }; - - const buildGroupProcessingStats = (data) => { - if (data.progress == 100) { - return 'Groups processed!'; - } - - if (data.progress == 0) { - return 'Processing groups...'; - } - - // Format time displays if available - const elapsedTime = data.elapsed_time - ? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` - : null; - - return ( - - - - - Processing groups: - - {parseInt(data.progress)}% - - {elapsedTime && ( - - - Elapsed: - - {elapsedTime} - - )} - {data.groups_processed && ( - - - Groups: - - {data.groups_processed} - - )} - - - ); - }; - - const buildErrorStats = (data) => { - return ( - - - - - Error: - - - - {data.error || 'Unknown error occurred'} - - - - ); - }; - - const buildParsingStats = (data) => { - if (data.progress == 100) { - return 'Parsing complete!'; - } - - if (data.progress == 0) { - return 'Parsing...'; - } - - // Format time displays - const timeRemaining = data.time_remaining - ? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` - : 'calculating...'; - - const elapsedTime = data.elapsed_time - ? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` - : '0:00'; - - return ( - - - - - Parsing: - - {parseInt(data.progress)}% - - {data.elapsed_time && ( - - - Elapsed: - - {elapsedTime} - - )} - {data.time_remaining && ( - - - Remaining: - - {timeRemaining} - - )} - {data.streams_processed && ( - - - Streams: - - {data.streams_processed} - - )} - - - ); - }; - - const buildInitializingStats = () => { - return ( - - - - - Initializing refresh... - - - - - ); - }; - const editPlaylist = async (playlist = null) => { setPlaylist(playlist); setPlaylistModalOpen(true); }; - const refreshPlaylist = async (id) => { + const handleRefreshPlaylist = async (id) => { // Provide immediate visual feedback before the API call setRefreshProgress(id, { action: 'initializing', @@ -391,9 +221,9 @@ const M3UTable = () => { }); try { - await API.refreshPlaylist(id); + await refreshPlaylist(id); // No need to set again since WebSocket will update us once the task starts - } catch (error) { + } catch { // If the API call fails, show an error state setRefreshProgress(id, { action: 'error', @@ -406,7 +236,7 @@ const M3UTable = () => { } }; - const deletePlaylist = async (id) => { + const handleDeletePlaylist = async (id) => { // Get playlist details for the confirmation dialog const playlist = playlists.find((p) => p.id === id); setPlaylistToDelete(playlist); @@ -418,7 +248,7 @@ const M3UTable = () => { // thinking there are zero auto-created channels. let info; try { - const result = await API.getPlaylistAutoCreatedChannelsCount(id); + const result = await getPlaylistAutoCreatedChannelsCount(id); info = result || { count: 0, sample_names: [] }; } catch { info = { @@ -448,7 +278,9 @@ const M3UTable = () => { setIsLoading(true); setDeleting(true); try { - await API.deletePlaylist(id); + await deletePlaylist(id); + } catch (error) { + console.error('Error deleting playlist:', error); } finally { setDeleting(false); setIsLoading(false); @@ -460,11 +292,11 @@ const M3UTable = () => { const toggleActive = async (playlist) => { try { // Send only the is_active field to trigger our special handling - await API.updatePlaylist( + await updatePlaylist( { - id: playlist.id, is_active: !playlist.is_active, }, + playlist, true ); // Add a new parameter to indicate this is just a toggle } catch (error) { @@ -685,34 +517,10 @@ const M3UTable = () => { const now = getNow(); const daysLeft = diff(earliest, now, 'day'); - let color; - let label; - if (daysLeft < 0) { - color = 'red.7'; - label = 'Expired'; - } else if (daysLeft === 0) { - color = 'red.5'; - label = 'Expires today'; - } else if (daysLeft <= 7) { - color = 'orange.5'; - label = `${daysLeft}d left`; - } else if (daysLeft <= 30) { - color = 'yellow.5'; - label = `${daysLeft}d left`; - } else { - label = format(earliest, fullDateFormat); - } + const { color, label } = getExpirationInfo(daysLeft, earliest, fullDateFormat); const allExpirations = data.all_expirations || []; - const tooltipContent = - allExpirations.length > 0 - ? allExpirations - .map( - (e) => - `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${!e.is_active ? ' (inactive)' : ''}` - ) - .join('\n') - : label; + const tooltipContent = getExpirationTooltip(allExpirations, fullDateTimeFormat, label); return ( { }, ], [ - refreshPlaylist, + handleRefreshPlaylist, editPlaylist, - deletePlaylist, + handleDeletePlaylist, toggleActive, fullDateFormat, fullDateTimeFormat, @@ -776,7 +584,7 @@ const M3UTable = () => { //optionally access the underlying virtualizer instance const rowVirtualizerInstanceRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); + const [_isLoading, setIsLoading] = useState(true); const closeModal = (newPlaylist = null) => { if (newPlaylist) { @@ -812,85 +620,11 @@ const M3UTable = () => { } }, [editPlaylistId, processedData, playlists, setEditPlaylistId]); - const onSortingChange = (column) => { - console.log(column); - const sortField = sorting[0]?.id; - const sortDirection = sorting[0]?.desc; + const onSortingChange = makeSortingChangeHandler(sorting, setSorting, (col, desc) => + setData(getSortedPlaylists(playlists, col, desc)) + ); - const newSorting = []; - if (sortField == column) { - if (sortDirection == false) { - newSorting[0] = { - id: column, - desc: true, - }; - } - } else { - newSorting[0] = { - id: column, - desc: false, - }; - } - - setSorting(newSorting); - if (newSorting.length > 0) { - const compareColumn = newSorting[0].id; - const compareDesc = newSorting[0].desc; - - setData( - playlists - .filter((playlist) => playlist.locked === false) - .sort((a, b) => { - const aVal = a[compareColumn]; - const bVal = b[compareColumn]; - - // Always sort nulls/undefined to the end regardless of direction - if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; - - let comparison; - if (typeof aVal === 'string') { - comparison = aVal.localeCompare(bVal); - } else { - comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; - } - - return compareDesc ? -comparison : comparison; - }) - ); - } - }; - - const renderHeaderCell = (header) => { - let sortingIcon = ArrowUpDown; - if (sorting[0]?.id == header.id) { - if (sorting[0].desc === false) { - sortingIcon = ArrowUpNarrowWide; - } else { - sortingIcon = ArrowDownWideNarrow; - } - } - - switch (header.id) { - default: - return ( - - - {header.column.columnDef.header} - - {header.column.columnDef.sortable && ( -
- {React.createElement(sortingIcon, { - onClick: () => onSortingChange(header.id), - size: 14, - })} -
- )} -
- ); - } - }; + const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange); const renderBodyCell = useCallback(({ cell, row }) => { switch (cell.column.id) { @@ -899,9 +633,9 @@ const M3UTable = () => { ); } @@ -915,7 +649,6 @@ const M3UTable = () => { enablePagination: false, enableRowVirtualization: true, enableRowSelection: false, - onRowSelectionChange: setRowSelection, renderTopToolbar: false, sorting, manualSorting: true, @@ -1027,11 +760,8 @@ const M3UTable = () => { @@ -1115,7 +845,7 @@ This action cannot be undone.`} }} > - {`${autoChannelsInfo.count} auto-synced channel${autoChannelsInfo.count === 1 ? '' : 's'} created by this provider will also be deleted.`} + {`${autoChannelsInfo.count} auto-synced ${autoChannelsInfo.count === 1 ? 'channel' : 'channels'} created by this provider will also be deleted.`}
) : null} diff --git a/frontend/src/components/tables/M3uTableUtils.jsx b/frontend/src/components/tables/M3uTableUtils.jsx new file mode 100644 index 00000000..226912a1 --- /dev/null +++ b/frontend/src/components/tables/M3uTableUtils.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Center, Group, Text } from '@mantine/core'; +import { + ArrowDownWideNarrow, + ArrowUpDown, + ArrowUpNarrowWide, +} from 'lucide-react'; + +export const makeHeaderCellRenderer = (sorting, onSortingChange) => (header) => { + let sortingIcon = ArrowUpDown; + if (sorting[0]?.id === header.id) { + sortingIcon = + sorting[0].desc === false ? ArrowUpNarrowWide : ArrowDownWideNarrow; + } + + return ( + + + {header.column.columnDef.header} + + {header.column.columnDef.sortable && ( +
+ {React.createElement(sortingIcon, { + onClick: () => onSortingChange(header.id), + size: 14, + })} +
+ )} +
+ ); +}; + +export const makeSortingChangeHandler = + (sorting, setSorting, onDataSort) => (column) => { + const sortField = sorting[0]?.id; + const sortDirection = sorting[0]?.desc; + + const newSorting = []; + if (sortField === column) { + if (sortDirection === false) { + newSorting[0] = { id: column, desc: true }; + } + // third click → clear (empty array) + } else { + newSorting[0] = { id: column, desc: false }; + } + + setSorting(newSorting); + if (newSorting.length > 0) { + onDataSort(newSorting[0].id, newSorting[0].desc); + } + }; + diff --git a/frontend/src/components/tables/OutputProfilesTable.jsx b/frontend/src/components/tables/OutputProfilesTable.jsx index b72d3463..15c91ada 100644 --- a/frontend/src/components/tables/OutputProfilesTable.jsx +++ b/frontend/src/components/tables/OutputProfilesTable.jsx @@ -1,23 +1,26 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import API from '../../api'; +import { useEffect, useMemo, useState } from 'react'; import OutputProfileForm from '../forms/OutputProfile'; import useOutputProfilesStore from '../../store/outputProfiles'; import { - Box, ActionIcon, - Tooltip, - Text, - Paper, - Flex, + Box, Button, - useMantineTheme, Center, - Switch, + Flex, + Paper, Stack, + Switch, + Text, + Tooltip, + useMantineTheme, } from '@mantine/core'; -import { SquareMinus, SquarePen, Eye, EyeOff, SquarePlus } from 'lucide-react'; +import { Eye, EyeOff, SquareMinus, SquarePen, SquarePlus } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { + deleteOutputProfile, + updateOutputProfile, +} from '../../utils/tables/OutputProfilesTableUtils.js'; const RowActions = ({ row, editOutputProfile, deleteOutputProfile }) => { return ( @@ -54,10 +57,6 @@ const OutputProfiles = () => { const [tableSize] = useLocalStorage('table-size', 'default'); const theme = useMantineTheme(); - const rowVirtualizerInstanceRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const columns = useMemo( () => [ { @@ -139,10 +138,6 @@ const OutputProfiles = () => { setProfileModalOpen(true); }; - const deleteOutputProfile = async (id) => { - await API.deleteOutputProfile(id); - }; - const closeOutputProfileForm = () => { setProfile(null); setProfileModalOpen(false); @@ -151,7 +146,7 @@ const OutputProfiles = () => { const toggleHideInactive = () => setHideInactive((v) => !v); const toggleProfileIsActive = async (profile) => { - await API.updateOutputProfile({ + await updateOutputProfile({ id: profile.id, ...profile, is_active: !profile.is_active, @@ -159,23 +154,7 @@ const OutputProfiles = () => { }; useEffect(() => { - if (typeof window !== 'undefined') setIsLoading(false); - }, []); - - useEffect(() => { - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - - useEffect(() => { - setData( - outputProfiles.filter((p) => - hideInactive && !p.is_active ? false : true - ) - ); + setData(outputProfiles.filter((p) => !(hideInactive && !p.is_active))); }, [outputProfiles, hideInactive]); const renderHeaderCell = (header) => ( diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index d34d58ea..537ffaad 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -1,10 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import API from '../../api'; import StreamProfileForm from '../forms/StreamProfile'; import useStreamProfilesStore from '../../store/streamProfiles'; -import { TableHelper } from '../../helpers'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { Box, ActionIcon, @@ -21,16 +19,16 @@ import { import { SquareMinus, SquarePen, - Check, - X, Eye, EyeOff, SquarePlus, } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { updateStreamProfile } from '../../utils/forms/StreamProfileUtils.js'; -const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => { +const RowActions = ({ row, editStreamProfile, handleDeleteStreamProfile }) => { return ( <> { size="sm" color="red.9" disabled={row.original.locked} - onClick={() => deleteStreamProfile(row.original.id)} + onClick={() => handleDeleteStreamProfile(row.original.id)} > {/* Small icon size */} @@ -55,6 +53,10 @@ const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => { ); }; +const deleteStreamProfile = (id) => { + return API.deleteStreamProfile(id); +} + const StreamProfiles = () => { const [profile, setProfile] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); @@ -143,27 +145,21 @@ const StreamProfiles = () => { [] ); - //optionally access the underlying virtualizer instance - const rowVirtualizerInstanceRef = useRef(null); - - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const editStreamProfile = async (profile = null) => { setProfile(profile); setProfileModalOpen(true); }; - const deleteStreamProfile = async (id) => { + const handleDeleteStreamProfile = async (id) => { if (id == settings.default_stream_profile) { - notifications.show({ + showNotification({ title: 'Cannot delete default stream-profile', color: 'red.5', }); return; } - await API.deleteStreamProfile(id); + await deleteStreamProfile(id); }; const closeStreamProfileForm = () => { @@ -171,28 +167,14 @@ const StreamProfiles = () => { setProfileModalOpen(false); }; - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } - }, []); - - useEffect(() => { - //scroll to the top of the table when the sorting changes - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - const toggleHideInactive = () => { setHideInactive(!hideInactive); }; const toggleProfileIsActive = async (profile) => { - await API.updateStreamProfile({ - id: profile.id, + await updateStreamProfile( + profile.id, + { ...profile, is_active: !profile.is_active, }); @@ -200,9 +182,7 @@ const StreamProfiles = () => { useEffect(() => { setData( - streamProfiles.filter((profile) => - hideInactive && !profile.is_active ? false : true - ) + streamProfiles.filter((profile) => !(hideInactive && !profile.is_active)) ); }, [streamProfiles, hideInactive]); @@ -221,7 +201,7 @@ const StreamProfiles = () => { ); } @@ -255,11 +235,8 @@ const StreamProfiles = () => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 14fed719..aaeba59d 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -1,63 +1,54 @@ -import React, { - useEffect, - useMemo, - useCallback, - useState, - useRef, -} from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import StreamForm from '../forms/Stream'; import usePlaylistsStore from '../../store/playlists'; import useChannelsStore from '../../store/channels'; import { copyToClipboard, useDebounce } from '../../utils'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; import { - SquarePlus, - ListPlus, - SquareMinus, - EllipsisVertical, - Copy, + ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, - ArrowDownWideNarrow, - Search, - Filter, - Square, - SquareCheck, + Copy, + EllipsisVertical, Eye, EyeOff, + Filter, + ListPlus, RotateCcw, + Search, + Square, + SquareCheck, + SquareMinus, + SquarePlus, } from 'lucide-react'; import { - TextInput, ActionIcon, - Select, - Tooltip, - Menu, - Flex, Box, - Text, - Paper, Button, Card, - Stack, - Title, - Divider, Center, - Pagination, + Divider, + Flex, Group, - NativeSelect, - MultiSelect, - useMantineTheme, - UnstyledButton, - Skeleton, - Modal, - NumberInput, - Radio, LoadingOverlay, - Pill, + Menu, + MenuDivider, + MenuDropdown, + MenuItem, + MenuLabel, + MenuTarget, + MultiSelect, + NativeSelect, + Pagination, + Paper, + Stack, + Text, + TextInput, + Title, + Tooltip, + UnstyledButton, + useMantineTheme, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import { useNavigate } from 'react-router-dom'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; @@ -68,14 +59,33 @@ import useLocalStorage from '../../hooks/useLocalStorage'; import ConfirmationDialog from '../ConfirmationDialog'; import CreateChannelModal from '../modals/CreateChannelModal'; import useStreamsTableStore from '../../store/streamsTable'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { requeryChannels } from '../../utils/forms/ChannelUtils.js'; +import { + addStreamsToChannel, + appendFetchPageParams, + createChannelFromStream, + createChannelsFromStreamsAsync, + deleteStream, + deleteStreams, + getAllStreamIds, + getChannelNumberValue, + getChannelProfileIds, + getFilterParams, + getStatsTooltip, + getStreamFilterOptions, + getStreams, + queryStreamsTable, + requeryStreams, +} from '../../utils/tables/StreamsTableUtils.js'; const StreamRowActions = ({ theme, row, editStream, - deleteStream, + handleDeleteStream, handleWatchStream, - createChannelFromStream, + handleCreateChannelFromStream, table, }) => { const tableSize = table?.tableSize ?? 'default'; @@ -90,7 +100,7 @@ const StreamRowActions = ({ ); const addStreamToChannel = async () => { - await API.addStreamsToChannel(targetChannelId, channelSelectionStreams, [ + await addStreamsToChannel(targetChannelId, channelSelectionStreams, [ row.original, ]); }; @@ -100,8 +110,8 @@ const StreamRowActions = ({ }, [row.original, editStream]); const onDelete = useCallback(() => { - deleteStream(row.original.id); - }, [row.original.id, deleteStream]); + handleDeleteStream(row.original.id); + }, [row.original.id, handleDeleteStream]); const onPreview = useCallback(() => { console.log( @@ -144,21 +154,21 @@ const StreamRowActions = ({ size={iconSize} color={theme.tailwind.green[5]} variant="transparent" - onClick={() => createChannelFromStream(row.original)} + onClick={() => handleCreateChannelFromStream(row.original)} > - + - + - - }> + + }> Copy URL - - + + Edit - - + + Delete Stream - - + + Preview Stream - - + + ); @@ -230,13 +240,6 @@ const StreamsTable = ({ onReady }) => { const [isBulkDelete, setIsBulkDelete] = useState(false); const [deleting, setDeleting] = useState(false); - // const [allRowsSelected, setAllRowsSelected] = useState(false); - - // Add local storage for page size - const [storedPageSize, setStoredPageSize] = useLocalStorage( - 'streams-page-size', - 50 - ); const [filters, setFilters] = useState({ name: '', channel_group: '', @@ -485,43 +488,7 @@ const StreamsTable = ({ onReady }) => { ); - // Build compact display (resolution + video codec) - const parts = []; - if (stats.resolution) { - // Convert "1920x1080" to "1080p" format - const height = stats.resolution.split('x')[1]; - if (height) parts.push(`${height}p`); - } - if (stats.video_codec) { - parts.push(stats.video_codec.toUpperCase()); - } - const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; - - // Build tooltip content with friendly labels - const tooltipLines = []; - if (stats.resolution) - tooltipLines.push(`Resolution: ${stats.resolution}`); - if (stats.video_codec) - tooltipLines.push( - `Video Codec: ${stats.video_codec.toUpperCase()}` - ); - if (stats.video_bitrate) - tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); - if (stats.source_fps) - tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); - if (stats.audio_codec) - tooltipLines.push( - `Audio Codec: ${stats.audio_codec.toUpperCase()}` - ); - if (stats.audio_channels) - tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); - if (stats.audio_bitrate) - tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); - - const tooltipContent = - tooltipLines.length > 0 - ? tooltipLines.join('\n') - : 'No source info available'; + const { compactDisplay, tooltipContent } = getStatsTooltip(stats); return ( { // Build a URLSearchParams object containing only the filter portion of the // query. Page-rows fetches add page/page_size/ordering on top of this. const buildFilterParams = useCallback(() => { - const params = new URLSearchParams(); - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (typeof value === 'boolean') { - if (value) params.append(key, 'true'); - } else if (value !== null && value !== undefined && value !== '') { - params.append(key, String(value)); - } - }); - return params; + return getFilterParams(debouncedFilters); }, [debouncedFilters]); // Fetch the visible page of stream rows. Depends on pagination, sorting, @@ -609,21 +568,7 @@ const StreamsTable = ({ onReady }) => { const fetchPageData = useCallback( async ({ showLoader = true } = {}) => { const params = buildFilterParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - - if (sorting.length > 0) { - const columnId = sorting[0].id; - const fieldMapping = { - name: 'name', - group: 'channel_group__name', - m3u: 'm3u_account__name', - tvg_id: 'tvg_id', - }; - const sortField = fieldMapping[columnId] || columnId; - const sortDirection = sorting[0].desc ? '-' : ''; - params.append('ordering', `${sortDirection}${sortField}`); - } + appendFetchPageParams(params, pagination, sorting); const paramsString = params.toString(); @@ -644,7 +589,7 @@ const StreamsTable = ({ onReady }) => { } try { - const result = await API.queryStreamsTable(params); + const result = await queryStreamsTable(params); fetchInProgressRef.current = false; @@ -700,16 +645,8 @@ const StreamsTable = ({ onReady }) => { const savedStartNumber = localStorage.getItem('channel-numbering-start') || '1'; - let startingChannelNumberValue; - if (savedMode === 'provider') { - startingChannelNumberValue = null; - } else if (savedMode === 'auto') { - startingChannelNumberValue = 0; - } else if (savedMode === 'highest') { - startingChannelNumberValue = -1; - } else { - startingChannelNumberValue = Number(savedStartNumber); - } + const startingChannelNumberValue = + getChannelNumberValue(savedMode, savedStartNumber); await executeChannelCreation( startingChannelNumberValue, @@ -728,7 +665,7 @@ const StreamsTable = ({ onReady }) => { return streamFromCurrentPage; } - const response = await API.getStreams([streamId]); + const response = await getStreams([streamId]); return ( response?.find( (candidate) => Number(candidate.id) === Number(streamId) @@ -740,9 +677,9 @@ const StreamsTable = ({ onReady }) => { if (selectedStreamIds.length === 1) { const selectedStream = await resolveSelectedStream(selectedStreamIds[0]); if (selectedStream) { - await createChannelFromStream(selectedStream); + await handleCreateChannelFromStream(selectedStream); } else { - notifications.show({ + showNotification({ color: 'red', title: 'Stream not found', message: @@ -761,25 +698,13 @@ const StreamsTable = ({ onReady }) => { profileIds = null ) => { try { - // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise - let channelProfileIds; - if (profileIds) { - if (profileIds.includes('none')) { - channelProfileIds = []; - } else if (profileIds.includes('all')) { - channelProfileIds = null; - } else { - channelProfileIds = profileIds - .filter((id) => id !== 'all' && id !== 'none') - .map((id) => parseInt(id)); - } - } else { - channelProfileIds = - selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; - } + const channelProfileIds = getChannelProfileIds( + profileIds, + selectedProfileId + ); // Use the async API for all bulk operations - const response = await API.createChannelsFromStreamsAsync( + const response = await createChannelsFromStreamsAsync( selectedStreamIds, channelProfileIds, startingChannelNumberValue @@ -814,14 +739,7 @@ const StreamsTable = ({ onReady }) => { } // Convert mode to API value - const startingChannelNumberValue = - numberingMode === 'provider' - ? null - : numberingMode === 'auto' - ? 0 - : numberingMode === 'highest' - ? -1 - : Number(customStartNumber); + const startingChannelNumberValue = getChannelNumberValue(numberingMode, customStartNumber); setChannelNumberingModalOpen(false); await executeChannelCreation( @@ -839,7 +757,7 @@ const StreamsTable = ({ onReady }) => { setModalOpen(true); }; - const deleteStream = async (id) => { + const handleDeleteStream = async (id) => { // Get stream details for the confirmation dialog const streamObj = data.find((s) => s.id === id); setStreamToDelete(streamObj); @@ -858,7 +776,7 @@ const StreamsTable = ({ onReady }) => { setDeleting(true); setIsLoading(true); try { - await API.deleteStream(id); + await deleteStream(id); // Clear the selection for the deleted stream setSelectedStreamIds([]); table.setSelectedTableIds([]); @@ -869,7 +787,7 @@ const StreamsTable = ({ onReady }) => { } }; - const deleteStreams = async () => { + const handleDeleteStreams = async () => { setIsBulkDelete(true); setStreamToDelete(null); @@ -885,7 +803,7 @@ const StreamsTable = ({ onReady }) => { setDeleting(true); setIsLoading(true); try { - await API.deleteStreams(selectedStreamIds); + await deleteStreams(selectedStreamIds); setSelectedStreamIds([]); table.setSelectedTableIds([]); } finally { @@ -900,14 +818,14 @@ const StreamsTable = ({ onReady }) => { setModalOpen(false); setIsLoading(true); try { - await API.requeryStreams(); + await requeryStreams(); } finally { setIsLoading(false); } }; // Single channel creation functions - const createChannelFromStream = async (stream) => { + const handleCreateChannelFromStream = async (stream) => { // Set default profile selection based on current profile filter const defaultProfileIds = selectedProfileId === '0' ? ['all'] : [selectedProfileId]; @@ -922,16 +840,7 @@ const StreamsTable = ({ onReady }) => { const savedChannelNumber = localStorage.getItem('single-channel-numbering-specific') || '1'; - let channelNumberValue; - if (savedMode === 'provider') { - channelNumberValue = null; - } else if (savedMode === 'auto') { - channelNumberValue = 0; - } else if (savedMode === 'highest') { - channelNumberValue = -1; - } else { - channelNumberValue = Number(savedChannelNumber); - } + const channelNumberValue = getChannelNumberValue(savedMode, savedChannelNumber); await executeSingleChannelCreation( stream, @@ -951,30 +860,14 @@ const StreamsTable = ({ onReady }) => { channelNumber = null, profileIds = null ) => { - // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise - let channelProfileIds; - if (profileIds) { - if (profileIds.includes('none')) { - channelProfileIds = []; - } else if (profileIds.includes('all')) { - channelProfileIds = null; - } else { - channelProfileIds = profileIds - .filter((id) => id !== 'all' && id !== 'none') - .map((id) => parseInt(id)); - } - } else { - channelProfileIds = - selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; - } - - await API.createChannelFromStream({ + const channelProfileIds = getChannelProfileIds(profileIds, selectedProfileId); + await createChannelFromStream({ name: stream.name, channel_number: channelNumber, stream_id: stream.id, channel_profile_ids: channelProfileIds, }); - await API.requeryChannels(); + await requeryChannels(); }; // Handle confirming the single channel numbering modal @@ -992,14 +885,7 @@ const StreamsTable = ({ onReady }) => { } // Convert mode to API value - const channelNumberValue = - singleChannelMode === 'provider' - ? null - : singleChannelMode === 'auto' - ? 0 - : singleChannelMode === 'highest' - ? -1 - : Number(specificChannelNumber); + const channelNumberValue = getChannelNumberValue(singleChannelMode, specificChannelNumber); setSingleChannelModalOpen(false); await executeSingleChannelCreation( @@ -1013,11 +899,11 @@ const StreamsTable = ({ onReady }) => { setSingleChannelMode(nextMode); }; - const addStreamsToChannel = async () => { + const handleAddStreamsToChannel = async () => { // Look up full stream objects from the current page data const selectedIdSet = new Set(selectedStreamIds); const newStreams = data.filter((s) => selectedIdSet.has(s.id)); - await API.addStreamsToChannel( + await addStreamsToChannel( targetChannelId, channelSelectionStreams, newStreams @@ -1030,7 +916,6 @@ const StreamsTable = ({ onReady }) => { const onPageSizeChange = (e) => { const newPageSize = parseInt(e.target.value); - setStoredPageSize(newPageSize); setPagination({ ...pagination, pageSize: newPageSize, @@ -1246,14 +1131,14 @@ const StreamsTable = ({ onReady }) => { theme={theme} row={row} editStream={editStream} - deleteStream={deleteStream} + handleDeleteStream={handleDeleteStream} handleWatchStream={handleWatchStream} - createChannelFromStream={createChannelFromStream} + handleCreateChannelFromStream={handleCreateChannelFromStream} /> ); } }, - [theme, editStream, deleteStream, handleWatchStream] + [theme, editStream, handleDeleteStream, handleWatchStream] ); const table = useTable({ @@ -1316,7 +1201,7 @@ const StreamsTable = ({ onReady }) => { lastIdsParamsRef.current = paramsString; let cancelled = false; (async () => { - const ids = await API.getAllStreamIds(params); + const ids = await getAllStreamIds(params); if (!cancelled && ids) { setAllRowIds(ids); } @@ -1335,7 +1220,7 @@ const StreamsTable = ({ onReady }) => { lastFilterOptionsParamsRef.current = paramsString; let cancelled = false; (async () => { - const filterOptions = await API.getStreamFilterOptions(params); + const filterOptions = await getStreamFilterOptions(params); if (cancelled || !filterOptions || typeof filterOptions !== 'object') { return; } @@ -1377,16 +1262,14 @@ const StreamsTable = ({ onReady }) => { return; } - const loadGroups = async () => { + (async () => { hasFetchedChannelGroups.current = true; try { await fetchChannelGroups(); } catch (error) { console.error('Error fetching channel groups:', error); } - }; - - loadGroups(); + })(); }, [channelGroups, fetchChannelGroups]); useEffect(() => { @@ -1466,7 +1349,6 @@ const StreamsTable = ({ onReady }) => { fontSize: '20px', lineHeight: 1, letterSpacing: '-0.3px', - // color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary marginBottom: 0, }} > @@ -1501,7 +1383,7 @@ const StreamsTable = ({ onReady }) => { : 'default' } size="xs" - onClick={addStreamsToChannel} + onClick={handleAddStreamsToChannel} p={5} color={ selectedStreamIds.length > 0 && targetChannelId @@ -1544,16 +1426,16 @@ const StreamsTable = ({ onReady }) => { - + - + - - + { } > Only Unassociated - - + { } > Hide Stale - - + + @@ -1603,7 +1485,7 @@ const StreamsTable = ({ onReady }) => { leftSection={} variant="default" size="xs" - onClick={deleteStreams} + onClick={handleDeleteStreams} disabled={selectedStreamIds.length == 0} > Delete @@ -1611,17 +1493,17 @@ const StreamsTable = ({ onReady }) => { - + - + - - Toggle Columns - + Toggle Columns + toggleColumnVisibility('name')} leftSection={ columnVisibility.name !== false ? ( @@ -1632,8 +1514,8 @@ const StreamsTable = ({ onReady }) => { } > Name - - + toggleColumnVisibility('group')} leftSection={ columnVisibility.group !== false ? ( @@ -1644,8 +1526,8 @@ const StreamsTable = ({ onReady }) => { } > Group - - + toggleColumnVisibility('m3u')} leftSection={ columnVisibility.m3u !== false ? ( @@ -1656,8 +1538,8 @@ const StreamsTable = ({ onReady }) => { } > M3U - - + toggleColumnVisibility('tvg_id')} leftSection={ columnVisibility.tvg_id !== false ? ( @@ -1668,8 +1550,8 @@ const StreamsTable = ({ onReady }) => { } > TVG-ID - - + toggleColumnVisibility('stats')} leftSection={ columnVisibility.stats !== false ? ( @@ -1680,15 +1562,15 @@ const StreamsTable = ({ onReady }) => { } > Stats - - - + + } > Reset to Default - - + + diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index ffd47719..585d6f7f 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -1,15 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import API from '../../api'; import useUserAgentsStore from '../../store/userAgents'; import UserAgentForm from '../forms/UserAgent'; -import { TableHelper } from '../../helpers'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { ActionIcon, Center, Flex, - Select, Tooltip, Text, Paper, @@ -20,8 +17,20 @@ import { import { SquareMinus, SquarePen, Check, X, SquarePlus } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { showNotification } from '../../utils/notificationUtils.js'; -const RowActions = ({ row, editUserAgent, deleteUserAgent }) => { +const deleteUserAgents = async (ids) => { + for (const id of ids) { + try { + await API.deleteUserAgent(id); + } catch { + /* empty */ + } + } +}; +const deleteUserAgent = (id) => API.deleteUserAgent(id); + +const RowActions = ({ row, editUserAgent, handleDeleteUserAgent }) => { return ( <> { variant="transparent" size="sm" color="red.9" // Red color for delete actions - onClick={() => deleteUserAgent(row.original.id)} + onClick={() => handleDeleteUserAgent(row.original.id)} > {/* Small icon size */} @@ -49,7 +58,6 @@ const RowActions = ({ row, editUserAgent, deleteUserAgent }) => { const UserAgentsTable = () => { const [userAgent, setUserAgent] = useState(null); const [userAgentModalOpen, setUserAgentModalOpen] = useState(false); - const [activeFilterValue, setActiveFilterValue] = useState('all'); const userAgents = useUserAgentsStore((state) => state.userAgents); const settings = useSettingsStore((s) => s.settings); @@ -117,35 +125,30 @@ const UserAgentsTable = () => { [] ); - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const editUserAgent = async (userAgent = null) => { setUserAgent(userAgent); setUserAgentModalOpen(true); }; - const deleteUserAgent = async (ids) => { + const handleDeleteUserAgent = async (ids) => { if (Array.isArray(ids)) { if (ids.includes(settings.default_user_agent)) { - notifications.show({ + showNotification({ title: 'Cannot delete default user-agent', color: 'red.5', }); return; } - - await API.deleteUserAgents(ids); + await deleteUserAgents(ids); } else { if (ids == settings.default_user_agent) { - notifications.show({ + showNotification({ title: 'Cannot delete default user-agent', color: 'red.5', }); return; } - - await API.deleteUserAgent(ids); + await deleteUserAgent(ids); } }; @@ -154,12 +157,6 @@ const UserAgentsTable = () => { setUserAgentModalOpen(false); }; - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } - }, []); - const renderHeaderCell = (header) => { switch (header.id) { default: @@ -178,7 +175,7 @@ const UserAgentsTable = () => { ); } diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 7ec9504c..541452c7 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -26,6 +26,9 @@ import ConfirmationDialog from '../ConfirmationDialog'; import useLocalStorage from '../../hooks/useLocalStorage'; import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js'; +const deleteUser = (id) => { + return API.deleteUser(id); +}; const XCPasswordCell = ({ getValue }) => { const [isVisible, setIsVisible] = useState(false); const customProps = getValue() || {}; @@ -67,7 +70,7 @@ const XCPasswordCell = ({ getValue }) => { ); }; -const UserRowActions = ({ theme, row, editUser, deleteUser }) => { +const UserRowActions = ({ theme, row, editUser, handleDeleteUser }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const authUser = useAuthStore((s) => s.user); @@ -76,8 +79,8 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => { }, [row.original, editUser]); const onDelete = useCallback(() => { - deleteUser(row.original.id); - }, [row.original.id, deleteUser]); + handleDeleteUser(row.original.id); + }, [row.original.id, handleDeleteUser]); const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -138,7 +141,7 @@ const UsersTable = () => { setIsLoading(true); setDeleting(true); try { - await API.deleteUser(id); + await deleteUser(id); } finally { setDeleting(false); setIsLoading(false); @@ -151,7 +154,7 @@ const UsersTable = () => { setUserModalOpen(true); }, []); - const deleteUser = useCallback( + const handleDeleteUser = useCallback( async (id) => { const user = users.find((u) => u.id === id); setUserToDelete(user); @@ -317,12 +320,12 @@ const UsersTable = () => { theme={theme} row={row} editUser={editUser} - deleteUser={deleteUser} + handleDeleteUser={handleDeleteUser} /> ), }, ], - [theme, editUser, deleteUser, fullDateFormat, fullDateTimeFormat] + [theme, editUser, handleDeleteUser, fullDateFormat, fullDateTimeFormat] ); const closeUserForm = () => { diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx index b5529a57..591b99dc 100644 --- a/frontend/src/components/tables/VODLogosTable.jsx +++ b/frontend/src/components/tables/VODLogosTable.jsx @@ -6,7 +6,6 @@ import { Button, Center, Checkbox, - Flex, Group, Image, LoadingOverlay, @@ -20,12 +19,12 @@ import { Tooltip, useMantineTheme, } from '@mantine/core'; -import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react'; +import { ExternalLink, Trash, SquareMinus } from 'lucide-react'; import useVODLogosStore from '../../store/vodLogos'; import useLocalStorage from '../../hooks/useLocalStorage'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../../utils/notificationUtils.js'; const VODLogoRowActions = ({ theme, row, deleteLogo }) => { const [tableSize] = useLocalStorage('table-size', 'default'); @@ -79,8 +78,8 @@ export default function VODLogosTable() { const [paginationString, setPaginationString] = useState(''); const [isCleaningUp, setIsCleaningUp] = useState(false); const [unusedLogosCount, setUnusedLogosCount] = useState(0); - const [loadingUnusedCount, setLoadingUnusedCount] = useState(false); const tableRef = React.useRef(null); + useEffect(() => { fetchVODLogos({ page: currentPage, @@ -93,14 +92,11 @@ export default function VODLogosTable() { // Fetch the total count of unused logos useEffect(() => { const fetchUnusedCount = async () => { - setLoadingUnusedCount(true); try { const count = await getUnusedLogosCount(); setUnusedLogosCount(count); } catch (error) { console.error('Failed to fetch unused logos count:', error); - } finally { - setLoadingUnusedCount(false); } }; @@ -157,21 +153,21 @@ export default function VODLogosTable() { try { if (deleteTarget.length === 1) { await deleteVODLogo(deleteTarget[0]); - notifications.show({ + showNotification({ title: 'Success', message: 'VOD logo deleted successfully', color: 'green', }); } else { await deleteVODLogos(deleteTarget); - notifications.show({ + showNotification({ title: 'Success', message: `${deleteTarget.length} VOD logos deleted successfully`, color: 'green', }); } } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error.message || 'Failed to delete VOD logos', color: 'red', @@ -193,7 +189,7 @@ export default function VODLogosTable() { setIsCleaningUp(true); try { const result = await cleanupUnusedVODLogos(); - notifications.show({ + showNotification({ title: 'Success', message: `Cleaned up ${result.deleted_count} unused VOD logos`, color: 'green', @@ -202,7 +198,7 @@ export default function VODLogosTable() { const newCount = await getUnusedLogosCount(); setUnusedLogosCount(newCount); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error.message || 'Failed to cleanup unused VOD logos', color: 'red', @@ -315,7 +311,7 @@ export default function VODLogosTable() { const usageParts = []; if (movie_count > 0) { usageParts.push( - `${movie_count} movie${movie_count !== 1 ? 's' : ''}` + `${movie_count} ${movie_count !== 1 ? 'movies' : 'movie'}` ); } if (series_count > 0) { @@ -325,7 +321,7 @@ export default function VODLogosTable() { const label = usageParts.length === 1 ? usageParts[0] - : `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`; + : `${totalUsage} ${totalUsage !== 1 ? 'items' : 'item'}`; return ( ({ default: vi.fn() })); +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => `${path}?output_format=mpegts`), +})); + +vi.mock('../../../utils/tables/ChannelTableStreamsUtils.js', () => ({ + categorizeStreamStats: vi.fn(() => ({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + })), + formatStatKey: vi.fn((key) => key), + formatStatValue: vi.fn((key, value) => String(value)), + getChannelStreamStats: vi.fn().mockResolvedValue([]), + reorderChannelStreams: vi.fn().mockResolvedValue(undefined), +})); + +// ── @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: vi.fn(() => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + })), + useSensor: vi.fn((sensor) => sensor), + useSensors: vi.fn((...sensors) => sensors), +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + arrayMove: vi.fn((arr, from, to) => { + const next = [...arr]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }), + SortableContext: ({ children }) => ( +
{children}
+ ), + useSortable: vi.fn(() => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + })), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { Transform: { toString: vi.fn(() => '') } }, +})); + +// ── zustand/shallow ──────────────────────────────────────────────────────────── +vi.mock('zustand/shallow', () => ({ + shallow: (a, b) => a === b, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, ...rest }) => ( + + ), + Badge: ({ children, color, onClick, style }) => ( + + {children} + + ), + Box: ({ children, style, className, ...rest }) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, leftSection }) => ( + + ), + Center: ({ children }) =>
{children}
, + Collapse: ({ children, in: open }) => + open ?
{children}
: null, + Flex: ({ children, style }) => ( +
{children}
+ ), + Group: ({ children }) =>
{children}
, + Text: ({ children, size }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, + useMantineTheme: vi.fn(() => ({ + tailwind: { red: { 6: '#fa5252' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronRight: () => , + Eye: () => , + GripHorizontal: () => , + SquareMinus: ({ onClick, disabled, color }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../store/channelsTable'; +import usePlaylistsStore from '../../../store/playlists'; +import useVideoStore from '../../../store/useVideoStore'; +import useSettingsStore from '../../../store/settings'; +import useAuthStore from '../../../store/auth'; +import { buildLiveStreamUrl } from '../../../utils/components/FloatingVideoUtils.js'; +import * as ChannelTableStreamsUtils from '../../../utils/tables/ChannelTableStreamsUtils.js'; +import { copyToClipboard } from '../../../utils'; +import { DndContext } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; + +// ── Factories ────────────────────────────────────────────────────────────────── + +const makeStream = (overrides = {}) => ({ + id: 's-1', + name: 'Stream One', + m3u_account: 'acc-1', + url: 'http://example.com/stream', + stream_hash: 'hash-abc', + quality: '1080p', + stream_stats: null, + stream_stats_updated_at: null, + is_stale: false, + ...overrides, +}); + +const makeChannel = (streams = [makeStream()]) => ({ + id: 'ch-1', + name: 'HBO', + streams, +}); + +const makePlaylists = () => [{ id: 'acc-1', name: 'My M3U' }]; + +/** Wire all store mocks with sensible defaults */ +const setupMocks = ({ + streams = [makeStream()], + playlists = makePlaylists(), + isAdmin = true, + isVideoVisible = false, + envMode = 'production', +} = {}) => { + const mockPatchChannelStreamStats = vi.fn(); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => { + if (typeof sel === 'function') { + const storeState = { + getChannelStreams: () => streams, + patchChannelStreamStats: mockPatchChannelStreamStats, + }; + return sel(storeState); + } + }); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => sel({ playlists })); + + const mockShowVideo = vi.fn(); + vi.mocked(useVideoStore).mockImplementation((sel) => { + const state = { showVideo: mockShowVideo, isVisible: isVideoVisible }; + return sel(state); + }); + // Also expose getState for the ref-based metadata read + useVideoStore.getState = vi.fn(() => ({ metadata: null })); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: envMode } }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: { user_level: isAdmin ? 10 : 1 } }) + ); + + return { mockShowVideo, mockPatchChannelStreamStats }; +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('ChannelStreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ChannelTableStreamsUtils.getChannelStreamStats).mockResolvedValue( + [] + ); + vi.mocked(ChannelTableStreamsUtils.reorderChannelStreams).mockResolvedValue( + undefined + ); + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + + // ── Rendering ──────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders stream name', () => { + setupMocks(); + render(); + expect(screen.getByText('Stream One')).toBeInTheDocument(); + }); + + it('renders the M3U account name badge', () => { + setupMocks(); + render(); + expect(screen.getByText('My M3U')).toBeInTheDocument(); + }); + + it('renders "Unknown" account badge when m3u_account has no matching playlist', () => { + setupMocks({ playlists: [] }); + render(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('renders the quality badge when stream has quality', () => { + setupMocks(); + render(); + expect(screen.getByText('1080p')).toBeInTheDocument(); + }); + + it('does not render quality badge when stream has no quality', () => { + const streams = [makeStream({ quality: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('1080p')).not.toBeInTheDocument(); + }); + + it('renders the URL badge when stream has a url', () => { + setupMocks(); + render(); + expect(screen.getByText('URL')).toBeInTheDocument(); + }); + + it('does not render URL badge when stream has no url', () => { + const streams = [makeStream({ url: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('URL')).not.toBeInTheDocument(); + }); + + it('renders "No Data" when there are no streams', () => { + setupMocks({ streams: [] }); + render(); + expect(screen.getByText('No Data')).toBeInTheDocument(); + }); + + it('renders the preview Eye action icon when stream has a url', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the drag handle grip icon', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-grip')).toBeInTheDocument(); + }); + + it('renders the remove stream icon', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument(); + }); + + it('renders multiple streams when channel has multiple', () => { + const streams = [ + makeStream({ id: 's-1', name: 'Stream One' }), + makeStream({ id: 's-2', name: 'Stream Two' }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Stream One')).toBeInTheDocument(); + expect(screen.getByText('Stream Two')).toBeInTheDocument(); + }); + }); + + // ── Stats fetching on mount ─────────────────────────────────────────────── + + describe('stats fetching', () => { + it('calls getChannelStreamStats on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalledWith('ch-1', null, undefined); + }); + }); + + it('passes the latest stream_stats_updated_at as the "since" cursor', async () => { + const streams = [ + makeStream({ stream_stats_updated_at: '2024-01-01T00:00:00Z' }), + ]; + setupMocks({ streams }); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalledWith('ch-1', '2024-01-01T00:00:00Z', undefined); + }); + }); + + it('calls patchChannelStreamStats when getChannelStreamStats returns updates', async () => { + const updates = [ + { + id: 's-1', + stream_stats: { resolution: '1080p' }, + stream_stats_updated_at: 't2', + }, + ]; + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue(updates); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect(mockPatchChannelStreamStats).toHaveBeenCalledWith( + 'ch-1', + updates + ); + }); + }); + + it('does not call patchChannelStreamStats when getChannelStreamStats returns empty', async () => { + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue([]); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalled(); + }); + expect(mockPatchChannelStreamStats).not.toHaveBeenCalled(); + }); + + it('does not call patchChannelStreamStats when getChannelStreamStats returns null', async () => { + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue(null); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalled(); + }); + expect(mockPatchChannelStreamStats).not.toHaveBeenCalled(); + }); + }); + + // ── Preview stream (Eye button) ─────────────────────────────────────────── + + describe('preview stream', () => { + it('calls showVideo with correct url when Eye button is clicked', () => { + const { mockShowVideo } = setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith( + '/proxy/ts/stream/hash-abc' + ); + expect(mockShowVideo).toHaveBeenCalledWith( + '/proxy/ts/stream/hash-abc?output_format=mpegts', + 'live', + expect.objectContaining({ name: 'Stream One', streamId: 's-1' }) + ); + }); + + it('uses stream_hash over id in the video url', () => { + const streams = [makeStream({ id: 's-99', stream_hash: 'special-hash' })]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith( + '/proxy/ts/stream/special-hash' + ); + }); + + it('uses stream id when stream_hash is absent', () => { + const streams = [makeStream({ id: 's-1', stream_hash: null })]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith('/proxy/ts/stream/s-1'); + }); + + it('prefixes hostname in dev mode', () => { + const { mockShowVideo } = setupMocks({ envMode: 'dev' }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + const calledUrl = mockShowVideo.mock.calls[0][0]; + expect(calledUrl).toContain(':5656'); + }); + + it('does not prefix hostname in production mode', () => { + const { mockShowVideo } = setupMocks({ envMode: 'production' }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + const calledUrl = mockShowVideo.mock.calls[0][0]; + expect(calledUrl).not.toContain(':5656'); + }); + }); + + // ── URL badge copy-to-clipboard ─────────────────────────────────────────── + + describe('URL badge copy to clipboard', () => { + it('calls copyToClipboard with the stream url when URL badge is clicked', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('URL')); + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith( + 'http://example.com/stream', + expect.objectContaining({ successTitle: 'URL Copied' }) + ); + }); + }); + }); + + // ── Remove stream ───────────────────────────────────────────────────────── + + describe('remove stream', () => { + it('removes stream from the list when remove icon is clicked', async () => { + const streams = [ + makeStream({ id: 's-1', name: 'Stream One' }), + makeStream({ id: 's-2', name: 'Stream Two' }), + ]; + setupMocks({ streams }); + render(); + + expect(screen.getByText('Stream One')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('icon-square-minus')[0]); + + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', ['s-2']); + }); + }); + + it('calls reorderChannelStreams with an empty array when last stream is removed', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-square-minus')); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', []); + }); + }); + }); + + // ── Stream stats display ────────────────────────────────────────────────── + + describe('basic stream stats', () => { + it('renders video stats section when video_codec is present', () => { + const streams = [ + makeStream({ + stream_stats: { video_codec: 'h264', resolution: '1920x1080' }, + }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Video:')).toBeInTheDocument(); + }); + + it('renders resolution badge', () => { + const streams = [ + makeStream({ stream_stats: { resolution: '1920x1080' } }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('1920x1080')).toBeInTheDocument(); + }); + + it('renders video_bitrate badge with kbps suffix', () => { + const streams = [makeStream({ stream_stats: { video_bitrate: 5000 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('5000 kbps')).toBeInTheDocument(); + }); + + it('renders fps badge', () => { + const streams = [makeStream({ stream_stats: { source_fps: 29.97 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('29.97 FPS')).toBeInTheDocument(); + }); + + it('renders codec badge uppercased', () => { + const streams = [makeStream({ stream_stats: { video_codec: 'h264' } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('H264')).toBeInTheDocument(); + }); + + it('renders audio section when audio_codec is present', () => { + const streams = [makeStream({ stream_stats: { audio_codec: 'aac' } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Audio:')).toBeInTheDocument(); + expect(screen.getByText('AAC')).toBeInTheDocument(); + }); + + it('renders audio channels badge', () => { + const streams = [makeStream({ stream_stats: { audio_channels: 2 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('renders output bitrate section when ffmpeg_output_bitrate is present', () => { + const streams = [ + makeStream({ stream_stats: { ffmpeg_output_bitrate: 3000 } }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Output Bitrate:')).toBeInTheDocument(); + expect(screen.getByText('3000 kbps')).toBeInTheDocument(); + }); + + it('renders last updated timestamp when stream_stats_updated_at is set', () => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: { video_bitrate: 5000 }, + audio: {}, + technical: {}, + other: {}, + } + ); + const streams = [ + makeStream({ + stream_stats: { video_bitrate: 5000 }, + stream_stats_updated_at: '2024-01-15T10:30:00Z', + }), + ]; + setupMocks({ streams }); + render(); + // Expand advanced stats first so the timestamp is visible + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); + }); + }); + + // ── Advanced stats toggle ───────────────────────────────────────────────── + + describe('advanced stats toggle', () => { + const makeStreamWithAdvancedStats = () => + makeStream({ + stream_stats: { video_bitrate: 4000 }, + }); + + beforeEach(() => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: { video_bitrate: 4000 }, + audio: {}, + technical: {}, + other: {}, + } + ); + }); + + it('shows "Show Advanced Stats" button when advanced stats exist', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Show Advanced Stats')).toBeInTheDocument(); + }); + + it('does not show advanced stats toggle when no advanced stats exist', () => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + } + ); + const streams = [makeStream({ stream_stats: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('Show Advanced Stats')).not.toBeInTheDocument(); + }); + + it('toggles to "Hide Advanced Stats" after clicking Show', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByText('Hide Advanced Stats')).toBeInTheDocument(); + }); + + it('opens the Collapse panel when Show Advanced Stats is clicked', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + expect(screen.queryByTestId('collapse-open')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + }); + + it('closes the Collapse panel when Hide Advanced Stats is clicked', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + fireEvent.click(screen.getByText('Hide Advanced Stats')); + expect(screen.queryByTestId('collapse-open')).not.toBeInTheDocument(); + }); + }); + + // ── DnD reorder ─────────────────────────────────────────────────────────── + + describe('drag and drop reorder', () => { + it('calls reorderChannelStreams with new order after drag end', async () => { + const streams = [ + makeStream({ id: 's-1', name: 'First' }), + makeStream({ id: 's-2', name: 'Second' }), + ]; + vi.mocked(arrayMove).mockImplementation((arr, from, to) => { + const next = [...arr]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + setupMocks({ streams }); + + // Capture the onDragEnd handler from DndContext + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-2' } }); + }); + + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', expect.any(Array)); + }); + }); + + it('does not reorder when active and over are the same', async () => { + const streams = [makeStream({ id: 's-1' }), makeStream({ id: 's-2' })]; + setupMocks({ streams }); + + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-1' } }); + }); + + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).not.toHaveBeenCalled(); + }); + + it('does not reorder when user is not an admin', async () => { + const streams = [makeStream({ id: 's-1' }), makeStream({ id: 's-2' })]; + setupMocks({ streams, isAdmin: false }); + + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-2' } }); + }); + + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).not.toHaveBeenCalled(); + }); + }); + + // ── m3u account map ─────────────────────────────────────────────────────── + + describe('m3u account map', () => { + it('handles null playlists gracefully', () => { + setupMocks({ playlists: null }); + expect(() => + render() + ).not.toThrow(); + }); + + it('handles playlists with missing ids gracefully', () => { + setupMocks({ playlists: [{ name: 'No ID Playlist' }] }); + expect(() => + render() + ).not.toThrow(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + }); + + // ── stale row ───────────────────────────────────────────────────────────── + + describe('stale stream row', () => { + it('applies stale-stream-row class when is_stale is true', () => { + const streams = [makeStream({ is_stale: true })]; + setupMocks({ streams }); + const { container } = render( + + ); + expect(container.querySelector('.stale-stream-row')).toBeInTheDocument(); + }); + + it('does not apply stale-stream-row class when is_stale is false', () => { + setupMocks(); + const { container } = render(); + expect( + container.querySelector('.stale-stream-row') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx new file mode 100644 index 00000000..1d36f682 --- /dev/null +++ b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx @@ -0,0 +1,1211 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── DnD Kit ──────────────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }) =>
{children}
, + PointerSensor: class PointerSensor {}, + closestCenter: vi.fn(), + useSensor: vi.fn(), + useSensors: vi.fn(() => []), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }) =>
{children}
, + verticalListSortingStrategy: vi.fn(), +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/channelsTable', () => { + const mock = vi.fn(); + mock.getState = vi.fn(() => ({ selectedChannelIds: [] })); + mock.setState = vi.fn(); + return { default: mock }; +}); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => [{}, vi.fn()]), +})); +vi.mock('../../../hooks/useSmartLogos', () => ({ + useChannelLogoSelection: vi.fn(() => ({ ensureLogosLoaded: vi.fn() })), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), + useDebounce: vi.fn((val) => val), +})); +vi.mock('../../../utils/forms/ChannelUtils.js', () => ({ + listOverriddenFields: vi.fn(() => []), + requeryChannels: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => path), +})); +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + getShowVideoUrl: vi.fn(() => '/proxy/ts/stream/uuid-1'), +})); +vi.mock('../../../utils/tables/ChannelsTableUtils.js', () => ({ + buildEPGUrl: vi.fn(() => 'http://localhost/output/epg'), + buildFetchParams: vi.fn(() => new URLSearchParams()), + buildHDHRUrl: vi.fn(() => 'http://localhost/hdhr'), + buildM3UUrl: vi.fn(() => 'http://localhost/output/m3u'), + deleteChannel: vi.fn().mockResolvedValue(undefined), + deleteChannels: vi.fn().mockResolvedValue(undefined), + epgUrlBase: 'http://localhost/output/epg', + getAllChannelIds: vi.fn().mockResolvedValue([1, 2]), + hdhrUrlBase: 'http://localhost/hdhr', + m3uUrlBase: 'http://localhost/output/m3u', + queryChannels: vi.fn().mockResolvedValue(undefined), + reorderChannel: vi.fn().mockResolvedValue(undefined), + updateProfileChannel: vi.fn().mockResolvedValue(undefined), + updateProfileChannels: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); +vi.mock('../ChannelsTable/ChannelsTableOnboarding', () => ({ + default: ({ editChannel }) => ( +
+ +
+ ), +})); +vi.mock('../ChannelsTable/ChannelTableHeader', () => ({ + default: ({ + editChannel, + deleteChannels, + showDisabled, + setShowDisabled, + showOnlyStreamlessChannels, + setShowOnlyStreamlessChannels, + showOnlyStaleChannels, + setShowOnlyStaleChannels, + showOnlyOverriddenChannels, + setShowOnlyOverriddenChannels, + }) => ( +
+ + + + + + +
+ ), +})); +vi.mock('../ChannelsTable/EditableCell', () => ({ + EditableEPGCell: () => , + EditableGroupCell: () => , + EditableLogoCell: () => , + EditableNumberCell: () => , + EditableTextCell: () => , +})); +vi.mock('../ChannelTableStreams', () => ({ + default: () =>
, +})); +vi.mock('../../forms/Channel', () => ({ + default: ({ isOpen, onClose, channel }) => + isOpen ? ( +
+ {channel?.name ?? 'new'} + +
+ ) : null, +})); +vi.mock('../../forms/ChannelBatch', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); +vi.mock('../../forms/Recording', () => ({ + default: ({ isOpen, onClose, channel }) => + isOpen ? ( +
+ {channel?.name} + +
+ ) : null, +})); +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + loading, + confirmLabel, + cancelLabel, + }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); +vi.mock('../../LazyLogo', () => ({ + default: ({ alt }) => {alt}, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Box: ({ children, style, role, 'aria-label': ariaLabel }) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Flex: ({ children, style }) =>
{children}
, + Group: ({ children, style }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) =>
{children}
, + Label: ({ children }) =>
{children}
, + Item: ({ children, onClick, disabled, leftSection }) => ( + + ), + } + ), + MenuDropdown: ({ children }) =>
{children}
, + MenuItem: ({ children, onClick, disabled, leftSection }) => ( + + ), + MenuTarget: ({ children }) =>
{children}
, + MultiSelect: ({ value, data }) => ( + + ), + NativeSelect: ({ value, data, onChange }) => ( + + ), + NumberInput: ({ value, onChange }) => ( + onChange(Number(e.target.value))} + /> + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Popover: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), + } + ), + PopoverDropdown: ({ children, onClick, onMouseDown }) => ( +
+ {children} +
+ ), + PopoverTarget: ({ children }) =>
{children}
, + Select: ({ value, onChange, data, placeholder }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, label, disabled }) => ( + + ), + Text: ({ children, style }) => ( + + {children} + + ), + TextInput: ({ value, placeholder, onChange, readOnly }) => ( + {})} + readOnly={readOnly} + /> + ), + Tooltip: ({ children, label }) =>
{children}
, + UnstyledButton: ({ children, onClick }) => ( + + ), + useMantineTheme: vi.fn(() => ({ + tailwind: { + yellow: { 3: '#fde047' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, + palette: { + custom: { + greenMain: '#22c55e', + indigoMain: '#6366f1', + greyBorder: '#71717a', + }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDownWideNarrow: () => , + ArrowUpDown: () => , + ArrowUpNarrowWide: () => , + CirclePlay: () => , + Copy: () => , + EllipsisVertical: () => , + EyeOff: () => , + Pencil: () => , + ScanEye: () => , + ScreenShare: () => , + Scroll: () => , + Search: () => , + SquareMinus: () => , + SquarePen: () => , + Tv2: () => , +})); + +// ── CSS import ───────────────────────────────────────────────────────────────── +vi.mock('../table.css', () => ({})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useChannelsTableStore from '../../../store/channelsTable'; +import useAuthStore from '../../../store/auth'; +import useEPGsStore from '../../../store/epgs'; +import useSettingsStore from '../../../store/settings'; +import useVideoStore from '../../../store/useVideoStore'; +import useOutputProfilesStore from '../../../store/outputProfiles'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { useTable } from '../CustomTable'; +import { + deleteChannel, + deleteChannels, + queryChannels, + getAllChannelIds, +} from '../../../utils/tables/ChannelsTableUtils.js'; +import { requeryChannels } from '../../../utils/forms/ChannelUtils.js'; +import { copyToClipboard } from '../../../utils'; +import { buildLiveStreamUrl } from '../../../utils/components/FloatingVideoUtils.js'; +import { USER_LEVELS } from '../../../constants'; +import ChannelsTable from '../ChannelsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeChannel = (overrides = {}) => ({ + id: 1, + uuid: 'uuid-1', + name: 'Test Channel', + channel_number: 101, + effective_channel_number: 101, + effective_name: 'Test Channel', + streams: [{ id: 10, is_stale: false }], + hidden_from_output: false, + channel_group_id: null, + logo_id: null, + epg_data_id: null, + ...overrides, +}); + +const makeAdminUser = () => ({ id: 99, user_level: USER_LEVELS.ADMIN }); +const makeStandardUser = () => ({ id: 88, user_level: USER_LEVELS.STANDARD }); + +let capturedTableOptions = null; + +const makeDefaultTableInstance = (overrides = {}) => ({ + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [], + ...overrides, +}); + +const setupMocks = ({ + channels = [makeChannel()], + authUser = makeAdminUser(), + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + selectedProfileId = '0', + profiles = { 0: { name: 'Default', channels: new Set() } }, + pageCount = 1, + totalCount = 1, + allQueryIds = [1], + channelGroups = {}, + epgs = {}, + hasUnassignedEPGChannels = false, + tableOverrides = {}, +} = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + channels, + pageCount, + totalCount, + allQueryIds, + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [], + hasUnassignedEPGChannels, + setSelectedChannelIds: vi.fn(), + setExpandedChannelId: vi.fn(), + setPagination: vi.fn(), + setSorting: vi.fn(), + setAllQueryIds: vi.fn(), + }) + ); + vi.mocked(useChannelsTableStore).getState.mockReturnValue({ + selectedChannelIds: [], + }); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelIds: channels.map((c) => c.id), + profiles, + selectedProfileId, + channelGroups, + }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => sel({ user: authUser })); + + vi.mocked(useEPGsStore).mockImplementation((sel) => + sel({ epgs, tvgsById: {}, tvgsLoaded: true }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: 'production' } }) + ); + + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo: vi.fn() }) + ); + + vi.mocked(useOutputProfilesStore).mockImplementation((sel) => + sel({ profiles: [] }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([{}, vi.fn()]); + + const tableInstance = makeDefaultTableInstance(tableOverrides); + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return tableInstance; + }); + + return { tableInstance }; +}; + +const getActionsCol = () => + capturedTableOptions.columns.find((c) => c.id === 'actions'); + +const getCol = (id) => capturedTableOptions.columns.find((c) => c.id === id); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('ChannelsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(deleteChannel).mockResolvedValue(undefined); + vi.mocked(deleteChannels).mockResolvedValue(undefined); + vi.mocked(queryChannels).mockResolvedValue(undefined); + vi.mocked(getAllChannelIds).mockResolvedValue([1]); + vi.mocked(requeryChannels).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders "Channels" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Channels')).toBeInTheDocument(); + }); + + it('renders ChannelTableHeader', () => { + setupMocks(); + render(); + expect(screen.getByTestId('channel-table-header')).toBeInTheDocument(); + }); + + it('renders CustomTable when channels exist', () => { + setupMocks({ channels: [makeChannel()] }); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders onboarding when channels array is empty and no channelIds', async () => { + setupMocks({ + channels: [], + tableOverrides: { getRowModel: () => ({ rows: [] }) }, + }); + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelIds: [], + profiles: {}, + selectedProfileId: '0', + channelGroups: {}, + }) + ); + render(); + await waitFor(() => + expect(screen.getByTestId('onboarding')).toBeInTheDocument() + ); + }); + + it('renders pagination controls when channels exist', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + + it('does not render confirmation dialog initially', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('does not render channel form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('channel-form')).not.toBeInTheDocument(); + }); + + it('renders HDHR, M3U, and EPG link buttons', () => { + setupMocks(); + render(); + expect(screen.getByText('HDHR')).toBeInTheDocument(); + expect(screen.getByText('M3U')).toBeInTheDocument(); + expect(screen.getByText('EPG')).toBeInTheDocument(); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls queryChannels on mount', async () => { + setupMocks(); + render(); + await waitFor(() => expect(queryChannels).toHaveBeenCalled()); + }); + + it('calls getAllChannelIds on mount', async () => { + setupMocks(); + render(); + await waitFor(() => expect(getAllChannelIds).toHaveBeenCalled()); + }); + + it('calls onReady after successful fetch when tvgsLoaded is true', async () => { + setupMocks(); + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes manualPagination: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualPagination).toBe(true); + }); + + it('passes manualSorting: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes manualFiltering: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualFiltering).toBe(true); + }); + + it('passes enableRowSelection: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(true); + }); + + it('passes channels as data', () => { + const channels = [makeChannel({ id: 5 })]; + setupMocks({ channels }); + render(); + expect(capturedTableOptions.data).toBe(channels); + }); + }); + + // ── Add Channel form ─────────────────────────────────────────────────────── + + describe('Add Channel form', () => { + it('opens channel form with no channel when "Add Channel" is clicked in header', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('header-add-channel')); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + expect(screen.getByTestId('channel-form-name')).toHaveTextContent('new'); + }); + + it('closes channel form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('header-add-channel')); + fireEvent.click(screen.getByTestId('channel-form-close')); + expect(screen.queryByTestId('channel-form')).not.toBeInTheDocument(); + }); + + it('opens channel form with no channel from onboarding', async () => { + setupMocks({ + channels: [], + tableOverrides: { getRowModel: () => ({ rows: [] }) }, + }); + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelIds: [], + profiles: {}, + selectedProfileId: '0', + channelGroups: {}, + }) + ); + render(); + const addBtn = await screen.findByTestId('onboarding-add'); + fireEvent.click(addBtn); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + }); + }); + + // ── ChannelRowActions: edit ──────────────────────────────────────────────── + + describe('ChannelRowActions — edit button', () => { + const renderActionCell = (channel, tableInstance) => { + const col = getActionsCol(); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + }; + + it('edit button is disabled for non-admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeStandardUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled for admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect( + getByTestId('icon-square-pen').closest('button') + ).not.toBeDisabled(); + }); + + it('clicking edit button opens channel form populated with the channel', () => { + const channel = makeChannel({ name: 'HBO' }); + const { tableInstance } = setupMocks(); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + expect(screen.getByTestId('channel-form-name')).toHaveTextContent('HBO'); + }); + }); + + // ── ChannelRowActions: delete ────────────────────────────────────────────── + + describe('ChannelRowActions — delete button', () => { + const renderActionCell = (channel, tableInstance) => { + const col = getActionsCol(); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + }; + + it('delete button is disabled for non-admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeStandardUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled for admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect( + getByTestId('icon-square-minus').closest('button') + ).not.toBeDisabled(); + }); + + it('opens confirmation dialog when delete is clicked and warning is not suppressed', () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Channel Deletion' + ); + }); + + it('calls deleteChannel directly when warning is suppressed', async () => { + const channel = makeChannel({ id: 7 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => true), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(7)); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannel when confirm dialog is confirmed', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(5)); + }); + + it('calls requeryChannels after deleteChannel succeeds', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(requeryChannels).toHaveBeenCalled()); + }); + + it('closes dialog after confirming delete', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes dialog on Cancel', () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Bulk delete ──────────────────────────────────────────────────────────── + + describe('Bulk delete via ChannelTableHeader', () => { + it('opens confirmation dialog with "Bulk" in title', () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Bulk Channel Deletion' + ); + }); + + it('calls deleteChannels with selected ids when bulk confirm is clicked', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); + }); + + it('awaits requeryChannels before clearing bulk selection', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + const setSelectedTableIds = vi.fn(); + let resolveRequery; + const requeryPromise = new Promise((resolve) => { + resolveRequery = resolve; + }); + vi.mocked(requeryChannels).mockReturnValue(requeryPromise); + + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds, + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); + expect(requeryChannels).toHaveBeenCalled(); + expect(setSelectedTableIds).not.toHaveBeenCalled(); + + resolveRequery(); + await waitFor(() => expect(setSelectedTableIds).toHaveBeenCalledWith([])); + }); + + it('skips dialog and calls deleteChannels directly when warning is suppressed', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => true), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── ChannelRowActions: preview ───────────────────────────────────────────── + + describe('ChannelRowActions — preview button', () => { + it('calls showVideo when preview button is clicked', () => { + const channel = makeChannel({ uuid: 'uuid-abc', name: 'ESPN' }); + const showVideoMock = vi.fn(); + const { tableInstance } = setupMocks(); + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo: showVideoMock }) + ); + vi.mocked(buildLiveStreamUrl).mockReturnValue( + '/proxy/ts/stream/uuid-abc' + ); + render(); + const col = getActionsCol(); + const { getByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + fireEvent.click(getByTestId('icon-circle-play').closest('button')); + expect(showVideoMock).toHaveBeenCalledWith( + expect.stringContaining('uuid-abc'), + 'live', + expect.objectContaining({ name: 'ESPN' }) + ); + }); + }); + + // ── Recording form ───────────────────────────────────────────────────────── + + describe('Recording form', () => { + it('opens recording form when Record menu item is clicked', () => { + const channel = makeChannel({ name: 'CNN', id: 3 }); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + // Record menu item is the second menu-item (after Copy URL) + const menuItems = getAllByTestId('menu-item'); + const recordItem = menuItems.find((el) => + el.textContent.includes('Record') + ); + fireEvent.click(recordItem); + expect(screen.getByTestId('recording-form')).toBeInTheDocument(); + expect(screen.getByTestId('recording-form-channel')).toHaveTextContent( + 'CNN' + ); + }); + + it('closes recording form when onClose is called', () => { + const channel = makeChannel({ name: 'CNN', id: 3 }); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + const menuItems = getAllByTestId('menu-item'); + const recordItem = menuItems.find((el) => + el.textContent.includes('Record') + ); + fireEvent.click(recordItem); + fireEvent.click(screen.getByTestId('recording-form-close')); + expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument(); + }); + }); + + // ── Copy URL ─────────────────────────────────────────────────────────────── + + describe('"Copy URL" menu item', () => { + it('calls copyToClipboard when "Copy URL" is clicked', () => { + const channel = makeChannel({ uuid: 'uuid-1' }); + const { tableInstance } = setupMocks(); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + const menuItems = getAllByTestId('unstyled-button'); + const copyBtn = menuItems.find((el) => + el.textContent.includes('Copy URL') + ); + fireEvent.click(copyBtn); + expect(copyToClipboard).toHaveBeenCalled(); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('renders pagination string', () => { + setupMocks({ totalCount: 50 }); + render(); + expect(screen.getByText('1 to 25 of 50')).toBeInTheDocument(); + }); + + it('clicking next page triggers fetchData with updated page', async () => { + setupMocks({ totalCount: 100, pageCount: 4 }); + render(); + fireEvent.click(screen.getByTestId('pagination-next')); + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2)); + }); + }); + + // ── enabled column ───────────────────────────────────────────────────────── + + describe('ChannelEnabledSwitch (enabled column)', () => { + const renderEnabledCell = (channel) => { + const col = getCol('enabled'); + const tableInstance = makeDefaultTableInstance({ + getState: () => ({ selectedTableIds: [] }), + }); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + }; + + it('renders a Switch for the enabled column', () => { + setupMocks({ selectedProfileId: '0' }); + render(); + const { container } = renderEnabledCell(makeChannel()); + const { getByTestId } = within(container); + expect(getByTestId('switch')).toBeInTheDocument(); + }); + + it('switch is disabled when selectedProfileId is "0"', () => { + setupMocks({ selectedProfileId: '0' }); + render(); + const { container } = renderEnabledCell(makeChannel()); + const { getByTestId } = within(container); + expect(getByTestId('switch')).toBeDisabled(); + }); + }); + + // ── channel_number column ────────────────────────────────────────────────── + + describe('channel_number column', () => { + it('accessorFn returns effective_channel_number when present', () => { + setupMocks(); + render(); + const col = getCol('channel_number'); + const result = col.accessorFn({ + effective_channel_number: 999, + channel_number: 100, + }); + expect(result).toBe(999); + }); + + it('accessorFn falls back to channel_number when effective_channel_number is null', () => { + setupMocks(); + render(); + const col = getCol('channel_number'); + const result = col.accessorFn({ + effective_channel_number: null, + channel_number: 42, + }); + expect(result).toBe(42); + }); + }); + + // ── name column ──────────────────────────────────────────────────────────── + + describe('name column', () => { + it('accessorFn returns effective_name when present', () => { + setupMocks(); + render(); + const col = getCol('name'); + const result = col.accessorFn({ + effective_name: 'Override Name', + name: 'Original', + }); + expect(result).toBe('Override Name'); + }); + + it('accessorFn falls back to name when effective_name is null', () => { + setupMocks(); + render(); + const col = getCol('name'); + const result = col.accessorFn({ effective_name: null, name: 'Original' }); + expect(result).toBe('Original'); + }); + }); + + // ── rowClassMap ──────────────────────────────────────────────────────────── + + describe('rowClassMap / getRowStyles', () => { + it('returns no-streams-row class for channels without streams', () => { + const channel = makeChannel({ id: 10, streams: [] }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toEqual({ + className: 'no-streams-row', + }); + }); + + it('returns empty object for channels with active streams', () => { + const channel = makeChannel({ + id: 11, + streams: [{ id: 20, is_stale: false }], + }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toEqual({}); + }); + + it('returns stale-streams-row class for channels with stale streams', () => { + const channel = makeChannel({ + id: 12, + streams: [{ id: 21, is_stale: true }], + }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toMatchObject({ + className: expect.stringMatching(/stale/i), + }); + }); + }); + + // ── expandedRowRenderer ──────────────────────────────────────────────────── + + describe('expandedRowRenderer', () => { + it('renders ChannelTableStreams for the expanded row', () => { + const channel = makeChannel({ + id: 1, + streams: [{ id: 10, is_stale: false }], + }); + setupMocks({ channels: [channel] }); + render(); + const { expandedRowRenderer } = capturedTableOptions; + const { getByTestId } = render( + expandedRowRenderer({ row: { id: '1', original: channel } }) + ); + expect(getByTestId('channel-table-streams')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/EPGsTable.test.jsx b/frontend/src/components/tables/__tests__/EPGsTable.test.jsx new file mode 100644 index 00000000..c1e29943 --- /dev/null +++ b/frontend/src/components/tables/__tests__/EPGsTable.test.jsx @@ -0,0 +1,1347 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import EPGsTable from '../EPGsTable'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn((val) => `formatted:${val}`), + useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY HH:mm' })), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/tables/EPGsTableUtils.js', () => ({ + deleteEpg: vi.fn().mockResolvedValue(undefined), + formatStatusText: vi.fn((s) => + s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown' + ), + getProgressInfo: vi.fn(() => null), + getProgressLabel: vi.fn(() => null), + getSortedEpgs: vi.fn((epgs) => Object.values(epgs)), + refreshEpg: vi.fn().mockResolvedValue(undefined), + updateEpg: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../M3uTableUtils.jsx', () => ({ + makeHeaderCellRenderer: vi.fn(() => (header) => ( + + {header.column.columnDef.header} + + )), + makeSortingChangeHandler: vi.fn(() => vi.fn()), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/EPG', () => ({ + default: ({ isOpen, onClose, epg }) => + isOpen ? ( +
+ {epg?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../forms/DummyEPG', () => ({ + default: ({ isOpen, onClose, epg }) => + isOpen ? ( +
+ {epg?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: ({ table }) => ( +
+ {table?.getRowModel?.().rows.map((row) => ( +
+ {row.getVisibleCells().map((cell) => ( +
+ {cell.column.id === 'actions' + ? table.bodyCellRenderFns?.actions?.({ cell, row }) + : cell.column.columnDef.cell + ? cell.column.columnDef.cell(cell.getContext()) + : cell.getValue?.()} +
+ ))} +
+ ))} +
+ ), + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ + children, + onClick, + leftSection, + rightSection, + disabled, + loading, + }) => ( + + ), + Checkbox: ({ checked, onChange, label, disabled }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), + Item: ({ children, onClick }) => ( + + ), + } + ), + MenuDropdown: ({ children }) => ( +
{children}
+ ), + MenuItem: ({ children, onClick }) => ( + + ), + MenuTarget: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Paper: ({ children, style }) =>
{children}
, + Progress: ({ value }) =>
, + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, size, c, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + colors: { red: { 6: '#fa5252' }, green: { 6: '#40c057' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + RefreshCcw: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useEPGsStore from '../../../store/epgs'; +import useWarningsStore from '../../../store/warnings'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as EPGsTableUtils from '../../../utils/tables/EPGsTableUtils.js'; +import { useTable, CustomTable } from '../CustomTable'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { makeSortingChangeHandler } from '../M3uTableUtils.jsx'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeEpg = (overrides = {}) => ({ + id: 'epg-1', + name: 'Test EPG', + source_type: 'xmltv', + url: 'http://example.com/epg.xml', + status: 'idle', + last_message: null, + is_active: true, + updated_at: '2024-01-01T12:00:00Z', + ...overrides, +}); + +const makeDummyEpg = (overrides = {}) => + makeEpg({ + id: 'epg-dummy', + name: 'Dummy EPG', + source_type: 'dummy', + ...overrides, + }); + +// Captures the useTable options passed by EPGsTable so we can invoke +// renderBodyCell and renderHeaderCell in tests. +let capturedTableOptions = null; + +/** Wire stores and the useTable spy */ +const setupMocks = ({ + epgs = { 'epg-1': makeEpg() }, + refreshProgress = {}, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + tableSize = 'default', +} = {}) => { + vi.mocked(useEPGsStore).mockImplementation((sel) => + sel({ epgs, refreshProgress }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + // Capture the options EPGsTable passes to useTable so tests can call + // renderBodyCell / renderHeaderCell manually. + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + bodyCellRenderFns: opts.bodyCellRenderFns ?? {}, + getHeaderGroups: () => [], + }; + }); +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Build a minimal row/cell pair like TanStack Table would provide */ +const makeRowContext = (epgObj) => { + const row = { + id: epgObj.id, + original: epgObj, + getIsSelected: vi.fn(() => false), + getVisibleCells: vi.fn(() => []), + }; + const cell = { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => epgObj.is_active), + row, + getContext: vi.fn(() => ({})), + }; + return { row, cell }; +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('EPGsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(EPGsTableUtils.deleteEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.refreshEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.updateEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.getSortedEpgs).mockImplementation((epgs) => + Object.values(epgs) + ); + }); + + // ── Header renders ───────────────────────────────────────────────────────── + + describe('header / top toolbar', () => { + it('renders the "EPGs" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('EPGs')).toBeInTheDocument(); + }); + + it('renders the "Add EPG" menu button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add EPG')).toBeInTheDocument(); + }); + + it('renders both menu items in the Add EPG menu', () => { + setupMocks(); + render(); + expect(screen.getByText('Standard EPG Source')).toBeInTheDocument(); + expect(screen.getByText('Dummy EPG Source')).toBeInTheDocument(); + }); + }); + + // ── Add EPG modal flows ──────────────────────────────────────────────────── + + describe('add EPG modals', () => { + it('opens the standard EPG form when "Standard EPG Source" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + expect(screen.getByTestId('epg-form')).toBeInTheDocument(); + }); + + it('passes null epg to EPGForm when creating new standard EPG', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + expect(screen.getByTestId('epg-form-epg')).toHaveTextContent('new'); + }); + + it('closes the standard EPG form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + fireEvent.click(screen.getByTestId('epg-form-close')); + expect(screen.queryByTestId('epg-form')).not.toBeInTheDocument(); + }); + + it('opens the dummy EPG form when "Dummy EPG Source" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + expect(screen.getByTestId('dummy-epg-form')).toBeInTheDocument(); + }); + + it('passes null epg to DummyEPGForm when creating new dummy EPG', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + expect(screen.getByTestId('dummy-epg-form-epg')).toHaveTextContent('new'); + }); + + it('closes the dummy EPG form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + fireEvent.click(screen.getByTestId('dummy-epg-form-close')); + expect(screen.queryByTestId('dummy-epg-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit EPG (RowActions) ────────────────────────────────────────────────── + + describe('edit EPG via RowActions', () => { + it('opens the standard EPG form with the correct epg when edit is clicked', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + // Invoke the actions cell renderer directly + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('epg-form')).toBeInTheDocument(); + expect(screen.getByTestId('epg-form-epg')).toHaveTextContent('Test EPG'); + }); + + it('opens the dummy EPG form when editing a dummy EPG', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('dummy-epg-form')).toBeInTheDocument(); + }); + }); + + // ── Delete EPG (with confirmation) ──────────────────────────────────────── + + describe('delete EPG', () => { + it('opens the confirmation dialog when delete is clicked (warning not suppressed)', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('shows the correct title in the confirmation dialog', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm EPG Source Deletion' + ); + }); + + it('calls deleteEpg when delete is confirmed', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(EPGsTableUtils.deleteEpg).toHaveBeenCalledWith('epg-1'); + }); + }); + + it('closes the dialog after successful delete', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('closes the dialog when Cancel is clicked without deleting', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + expect(EPGsTableUtils.deleteEpg).not.toHaveBeenCalled(); + }); + + it('skips confirmation and calls deleteEpg immediately when warning is suppressed', async () => { + const epg = makeEpg(); + setupMocks({ + epgs: { 'epg-1': epg }, + isWarningSuppressed: vi.fn(() => true), + }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.deleteEpg).toHaveBeenCalledWith('epg-1'); + }); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + // ── Refresh EPG ──────────────────────────────────────────────────────────── + + describe('refresh EPG', () => { + it('calls refreshEpg with the correct id when refresh is clicked', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + // refreshEPG passes force=false (the default) through to refreshEpg + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalledWith('epg-1', false); + }); + }); + + it('shows a notification after refreshEpg resolves', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'EPG refresh initiated' }) + ); + }); + }); + + it('disables the refresh button for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + + it('disables the refresh button for inactive EPGs', () => { + const epg = makeEpg({ is_active: false }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + }); + + // ── Toggle active (Switch) ───────────────────────────────────────────────── + + describe('toggle active', () => { + it('calls updateEpg with flipped is_active when Switch is toggled', async () => { + const epg = makeEpg({ is_active: true }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + // The switch is rendered inside the is_active column cell renderer. + // Find the column definition and call its cell renderer. + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { + original: epg, + getIsSelected: vi.fn(() => false), + }; + const cell = { getValue: vi.fn(() => epg.is_active) }; + const { getByTestId } = render(isActiveCol.cell({ row, cell })); + fireEvent.click(getByTestId('switch')); + + await waitFor(() => { + expect(EPGsTableUtils.updateEpg).toHaveBeenCalledWith( + { is_active: false }, + epg, + true + ); + }); + }); + + it('does not call updateEpg when epg object is invalid', async () => { + setupMocks(); + render(); + + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { original: { source_type: 'xmltv', id: undefined, is_active: true }, getIsSelected: vi.fn(() => false) }; + const cell = { getValue: vi.fn(() => false) }; + + // Should not throw even with an invalid (no id) epg, and should not call updateEpg + render(isActiveCol.cell({ row, cell })); + expect(EPGsTableUtils.updateEpg).not.toHaveBeenCalled(); + }); + + it('disables the Switch for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { original: epg, getIsSelected: vi.fn(() => false) }; + const cell = { getValue: vi.fn(() => true) }; + const { getByTestId } = render(isActiveCol.cell({ row, cell })); + expect(getByTestId('switch')).toBeDisabled(); + }); + }); + + // ── Status cell ──────────────────────────────────────────────────────────── + + describe('status cell', () => { + const statuses = ['idle', 'fetching', 'parsing', 'error', 'success']; + statuses.forEach((status) => { + it(`renders formatted status text for status="${status}"`, () => { + const epg = makeEpg({ status }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const statusCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'status' + ); + const row = { original: epg }; + const { container } = render(statusCol.cell({ row })); + expect(container).toBeInTheDocument(); + expect(EPGsTableUtils.formatStatusText).toHaveBeenCalledWith(status); + }); + }); + + it('renders "idle" status for dummy EPG regardless of actual status', () => { + const epg = makeDummyEpg({ status: 'fetching' }); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const statusCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'status' + ); + const row = { original: epg }; + render(statusCol.cell({ row })); + expect(EPGsTableUtils.formatStatusText).toHaveBeenCalledWith('idle'); + }); + }); + + // ── Updated_at cell ──────────────────────────────────────────────────────── + + describe('updated_at cell', () => { + it('renders "Never" when updated_at is null', () => { + const epg = makeEpg({ updated_at: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const updatedCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'updated_at' + ); + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(updatedCol.cell({ cell })); + expect(getByText('Never')).toBeInTheDocument(); + }); + + it('renders the formatted date when updated_at has a value', () => { + setupMocks(); + render(); + + const updatedCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'updated_at' + ); + const cell = { getValue: vi.fn(() => '2024-01-01T12:00:00Z') }; + const { getByText } = render(updatedCol.cell({ cell })); + expect(getByText(/formatted:/)).toBeInTheDocument(); + }); + }); + + // ── Status message cell ──────────────────────────────────────────────────── + + describe('status message cell', () => { + it('returns null for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { container } = render(
{msgCol.cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('renders error message when status is error', () => { + const epg = makeEpg({ status: 'error', last_message: 'Something broke' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('Something broke')).toBeInTheDocument(); + }); + + it('renders success message when status is success and last_message is set', () => { + const epg = makeEpg({ status: 'success', last_message: 'All good' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('All good')).toBeInTheDocument(); + }); + + it('renders fallback success message when status is success and last_message is null', () => { + const epg = makeEpg({ status: 'success', last_message: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('EPG data refreshed successfully')).toBeInTheDocument(); + }); + + it('renders idle last_message when status is idle and message is set', () => { + const epg = makeEpg({ status: 'idle', last_message: 'Previous result' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('Previous result')).toBeInTheDocument(); + }); + + it('renders progress display when refreshProgress is active', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Downloading'); + vi.mocked(EPGsTableUtils.getProgressInfo).mockReturnValue(null); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { 'epg-1': { action: 'downloading', progress: 50 } }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByTestId, getByText } = render(msgCol.cell({ row })); + expect(getByText(/Downloading: 50%/)).toBeInTheDocument(); + expect(getByTestId('progress')).toBeInTheDocument(); + }); + + it('renders speed when progress has speed', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Downloading'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'downloading', progress: 30, speed: 512 }, + }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText(/Speed: 512 KB\/s/)).toBeInTheDocument(); + }); + + it('renders additionalInfo when getProgressInfo returns a value', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue( + 'Parsing Programs' + ); + vi.mocked(EPGsTableUtils.getProgressInfo).mockReturnValue( + '5,000 / 10,000' + ); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_programs', progress: 50 }, + }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('5,000 / 10,000')).toBeInTheDocument(); + }); + }); + + // ── URL cell ─────────────────────────────────────────────────────────────── + + describe('URL / api_key / file_path cell', () => { + it('renders the url when present', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { original: makeEpg() }; + const cell = { getValue: vi.fn(() => 'http://example.com/epg.xml') }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('http://example.com/epg.xml')).toBeInTheDocument(); + }); + + it('falls back to password when url is empty and password is set', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { + original: { ...makeEpg(), url: null, password: 'MY-KEY-123' }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('MY-KEY-123')).toBeInTheDocument(); + }); + + it('falls back to file_path when url and api_key are both absent', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { + original: { + ...makeEpg(), + url: null, + api_key: null, + file_path: '/data/epg.xml', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('/data/epg.xml')).toBeInTheDocument(); + }); + }); + + // ── Sorting integration ──────────────────────────────────────────────────── + + describe('sorting integration', () => { + it('calls getSortedEpgs when sorting handler fires', () => { + setupMocks(); + render(); + // The real makeSortingChangeHandler is mocked; verify it was called + // with sorting state and a setter from the component + expect(makeSortingChangeHandler).toHaveBeenCalled(); + }); + }); + + // ── Data initialization from epgs store ─────────────────────────────────── + + describe('data initialization', () => { + it('passes the epgs as data to useTable (active-first, then alphabetical)', () => { + const epgs = { + 'epg-2': makeEpg({ id: 'epg-2', name: 'Zebra', is_active: false }), + 'epg-1': makeEpg({ id: 'epg-1', name: 'Alpha', is_active: true }), + }; + setupMocks({ epgs }); + render(); + // Active EPG should come first in the sorted data passed to useTable + const data = capturedTableOptions.data; + expect(data[0].is_active).toBe(true); + }); + + it('places inactive EPGs after active ones', () => { + const epgs = { + 'epg-a': makeEpg({ id: 'epg-a', name: 'Active', is_active: true }), + 'epg-b': makeEpg({ id: 'epg-b', name: 'Inactive', is_active: false }), + }; + setupMocks({ epgs }); + render(); + const data = capturedTableOptions.data; + expect(data[data.length - 1].is_active).toBe(false); + }); + + it('sorts two active EPGs alphabetically by name', () => { + const epgs = { + 'epg-z': makeEpg({ id: 'epg-z', name: 'Zebra', is_active: true }), + 'epg-a': makeEpg({ id: 'epg-a', name: 'Alpha', is_active: true }), + }; + setupMocks({ epgs }); + render(); + const data = capturedTableOptions.data; + expect(data[0].name).toBe('Alpha'); + expect(data[1].name).toBe('Zebra'); + }); + }); + + // ── Source type cell ─────────────────────────────────────────────────────── + + describe('source type cell', () => { + const getTypeCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'source_type'); + + it('renders "XMLTV" for source_type xmltv', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'xmltv') } }) + ); + expect(getByText('XMLTV')).toBeInTheDocument(); + }); + + it('renders "Schedules Direct" for source_type schedules_direct', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'schedules_direct') } }) + ); + expect(getByText('Schedules Direct')).toBeInTheDocument(); + }); + + it('renders "Custom Dummy" for source_type dummy', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'dummy') } }) + ); + expect(getByText('Custom Dummy')).toBeInTheDocument(); + }); + + it('renders the raw value for an unknown source_type', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'unknown_type') } }) + ); + expect(getByText('unknown_type')).toBeInTheDocument(); + }); + }); + + // ── URL cell – schedules_direct scenarios ────────────────────────────────── + + describe('URL cell - schedules_direct', () => { + const getUrlCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + + it('shows "User: " for schedules_direct with a username', () => { + setupMocks(); + render(); + const row = { + original: { + ...makeEpg(), + source_type: 'schedules_direct', + username: 'myuser', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('User: myuser')).toBeInTheDocument(); + }); + + it('shows "(credentials set)" for schedules_direct with no username', () => { + setupMocks(); + render(); + const row = { + original: { + ...makeEpg(), + source_type: 'schedules_direct', + username: '', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('(credentials set)')).toBeInTheDocument(); + }); + + it('falls back to password when url is empty and password is set', () => { + setupMocks(); + render(); + const row = { + original: { ...makeEpg(), url: null, password: 'secret123' }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('secret123')).toBeInTheDocument(); + }); + }); + + // ── Schedules Direct early refresh dialog ────────────────────────────────── + + describe('Schedules Direct early refresh', () => { + const makeRecentSdEpg = (overrides = {}) => + makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + // 30 minutes ago — well within the 2-hour window + updated_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + ...overrides, + }); + + const makeOldSdEpg = (overrides = {}) => + makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + // 3 hours ago — outside the 2-hour window + updated_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + ...overrides, + }); + + it('opens the SD early-refresh confirmation dialog when the EPG was refreshed recently', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Refresh Schedules Direct Early?' + ); + }); + + it('shows "Refresh Anyway" as the confirm label', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Refresh Anyway'); + }); + + it('skips the SD dialog and calls refreshEpg directly when EPG was refreshed more than 2 hours ago', async () => { + const epg = makeOldSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('skips the SD dialog when the SD EPG has no updated_at', async () => { + const epg = makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + updated_at: null, + }); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls refreshEpg with force=true when "Refresh Anyway" is confirmed', async () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalledWith('sd-1', true); + }); + }); + + it('shows a notification after force-refreshing', async () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'EPG refresh initiated' }) + ); + }); + }); + + it('closes the SD dialog without calling refreshEpg when cancelled', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(EPGsTableUtils.refreshEpg).not.toHaveBeenCalled(); + }); + }); + + // ── Status message cell - progress edge cases ────────────────────────────── + + describe('status message cell - progress edge cases', () => { + const getMsgCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'last_message'); + + it('renders nothing when getProgressLabel returns null for active progress', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue(null); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { 'epg-1': { action: 'unknown_action', progress: 50 } }, + }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('shows progress when progress.status is "in_progress" even at 100%', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Processing'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'processing', progress: 100, status: 'in_progress' }, + }, + }); + render(); + + const row = { original: epg }; + const { getByText } = render(getMsgCol().cell({ row })); + expect(getByText(/Processing: 100%/)).toBeInTheDocument(); + }); + + it('shows progress for parsing_channels action when epg.status is "parsing"', () => { + const epg = makeEpg({ status: 'parsing' }); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_channels', progress: 100 }, + }, + }); + render(); + + const row = { original: epg }; + const { getByText } = render(getMsgCol().cell({ row })); + expect(getByText(/Parsing Channels: 100%/)).toBeInTheDocument(); + }); + + it('does not show progress for parsing_channels action when epg.status is not "parsing"', () => { + const epg = makeEpg({ status: 'idle' }); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_channels', progress: 100 }, + }, + }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + // condition fails (100 < 100=false, no in_progress, parsing_channels + idle=false) + // falls through to the idle-message branch; no last_message → returns null + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('renders nothing when there is no active progress and no status message', () => { + const epg = makeEpg({ status: 'idle', last_message: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + }); + + // ── Table structure ──────────────────────────────────────────────────────── + + describe('table structure', () => { + it('renders the CustomTable component', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('passes enablePagination: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes manualSorting: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes renderTopToolbar: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.renderTopToolbar).toBe(false); + }); + + it('actions column size is 75 in compact mode', () => { + setupMocks({ tableSize: 'compact' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(75); + }); + + it('actions column size is 100 in default mode', () => { + setupMocks({ tableSize: 'default' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(100); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/LogosTable.test.jsx b/frontend/src/components/tables/__tests__/LogosTable.test.jsx new file mode 100644 index 00000000..995e9d84 --- /dev/null +++ b/frontend/src/components/tables/__tests__/LogosTable.test.jsx @@ -0,0 +1,1054 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/logos', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/tables/LogosTableUtils.js', () => ({ + cleanupUnusedLogos: vi.fn(), + deleteLogo: vi.fn(), + deleteLogos: vi.fn(), + generateUsageLabel: vi.fn((names, count) => `${count} channel${count !== 1 ? 's' : ''}`), + getFilteredLogos: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/Logo', () => ({ + default: ({ isOpen, onClose, logo, onSuccess }) => + isOpen ? ( +
+ {logo?.name ?? 'new'} + + + + + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + showDeleteFileOption, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + + {showDeleteFileOption && ( + + )} +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading, color, variant }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, onChange, label, disabled }) => ( + + ), + Group: ({ children, style }) =>
{children}
, + Image: ({ src, alt, fallbackSrc, style, onMouseEnter, onMouseLeave }) => ( + {alt} + ), + LoadingOverlay: ({ visible }) => (visible ?
: null), + NativeSelect: ({ value, data, onChange, style }) => ( + + ), + Pagination: ({ total, value, onChange, style }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Select: ({ value, onChange, data, style }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, size, c, style }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder, style }) => ( + + ), + Tooltip: ({ children }) =>
{children}
, + useMantineTheme: vi.fn(() => ({ + tailwind: { + yellow: { 3: '#fbbf24' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ExternalLink: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + Trash: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useLogosStore from '../../../store/logos'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as LogosTableUtils from '../../../utils/tables/LogosTableUtils.js'; +import { useTable } from '../CustomTable'; +import LogosTable from '../LogosTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'Test Logo', + url: 'http://example.com/logo.png', + cache_url: '/cached/logo.png', + channel_count: 0, + channel_names: [], + is_used: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + logos = { 1: makeLogo() }, + storeLoading = false, + tableSize = 'default', +} = {}) => { + const mockFetchAllLogos = vi.fn().mockResolvedValue(undefined); + const mockUpdateLogo = vi.fn(); + const mockAddLogo = vi.fn(); + + vi.mocked(useLogosStore).mockImplementation((sel) => { + const state = { + logos, + fetchAllLogos: mockFetchAllLogos, + updateLogo: mockUpdateLogo, + addLogo: mockAddLogo, + isLoading: storeLoading, + }; + return typeof sel === 'function' ? sel(state) : state; + }); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ suppressWarning: vi.fn(), isWarningSuppressed: vi.fn(() => false) }) + ); + + vi.mocked(useLocalStorage).mockImplementation((key, defaultVal) => { + if (key === 'table-size') return [tableSize, vi.fn()]; + if (key === 'logos-page-size') return [25, vi.fn()]; + return [defaultVal, vi.fn()]; + }); + + vi.mocked(LogosTableUtils.getFilteredLogos).mockReturnValue(Object.values(logos)); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + }; + }); + + return { mockFetchAllLogos, mockUpdateLogo, mockAddLogo }; +}; + +// ── Column helpers ───────────────────────────────────────────────────────────── +const getActionsCell = (logo) => { + const col = capturedTableOptions.columns.find((c) => c.id === 'actions'); + return col.cell({ row: { id: logo.id, original: logo } }); +}; + +const getSelectCell = (logo) => { + const col = capturedTableOptions.columns.find((c) => c.id === 'select'); + return col.cell({ row: { id: logo.id, original: logo } }); +}; + +const getSelectHeader = () => { + const col = capturedTableOptions.columns.find((c) => c.id === 'select'); + return col.header({ table: {} }); +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('LogosTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(LogosTableUtils.deleteLogo).mockResolvedValue(undefined); + vi.mocked(LogosTableUtils.deleteLogos).mockResolvedValue(undefined); + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 3, + local_files_deleted: 0, + }); + window.open = vi.fn(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the name filter input', () => { + setupMocks(); + render(); + expect(screen.getByPlaceholderText('Filter by name...')).toBeInTheDocument(); + }); + + it('renders the usage filter select with all options', () => { + setupMocks(); + render(); + const select = screen.getByTestId('usage-select'); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('all'); + }); + + it('renders the "Add Logo" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Logo')).toBeInTheDocument(); + }); + + it('"Delete" button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete').closest('button')).toBeDisabled(); + }); + + it('"Cleanup Unused" button is disabled when there are no unused logos', () => { + setupMocks({ logos: { 1: makeLogo({ is_used: true }) } }); + render(); + expect(screen.getByText(/Cleanup Unused/).closest('button')).toBeDisabled(); + }); + + it('"Cleanup Unused" button shows count and is enabled when unused logos exist', () => { + setupMocks({ + logos: { + 1: makeLogo({ id: 1, is_used: false }), + 2: makeLogo({ id: 2, name: 'Logo 2', is_used: false }), + }, + }); + render(); + const btn = screen.getByText(/Cleanup Unused/).closest('button'); + expect(btn).not.toBeDisabled(); + expect(btn).toHaveTextContent('(2)'); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders loading overlay when store is loading', () => { + setupMocks({ storeLoading: true }); + render(); + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); + }); + + it('does not render loading overlay when not loading', () => { + setupMocks({ storeLoading: false }); + render(); + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + it('renders pagination controls', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + + it('renders the page size selector', () => { + setupMocks(); + render(); + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + + it('shows "1 to 1 of 1" pagination string for a single logo', () => { + setupMocks({ logos: { 1: makeLogo() } }); + render(); + expect(screen.getByText('1 to 1 of 1')).toBeInTheDocument(); + }); + + it('"Prev" pagination button is disabled on first page', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination-prev')).toBeDisabled(); + }); + + it('"Next" pagination button is disabled when on the only page', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + }); + }); + + // ── Add Logo ─────────────────────────────────────────────────────────────── + + describe('Add Logo', () => { + it('opens logo form with no selected logo when "Add Logo" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + expect(screen.getByTestId('logo-form')).toBeInTheDocument(); + expect(screen.getByTestId('logo-form-name')).toHaveTextContent('new'); + }); + + it('closes the logo form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit Logo ────────────────────────────────────────────────────────────── + + describe('Edit Logo', () => { + it('opens the logo form populated with the logo when edit action is clicked', () => { + const logo = makeLogo({ name: 'My Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getActionIcon } = render(getActionsCell(logo)); + fireEvent.click(getActionIcon('icon-square-pen').closest('button')); + + expect(screen.getByTestId('logo-form')).toBeInTheDocument(); + expect(screen.getByTestId('logo-form-name')).toHaveTextContent('My Logo'); + }); + + it('closes the logo form when onClose is called after edit', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getActionIcon } = render(getActionsCell(logo)); + fireEvent.click(getActionIcon('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('logo-form-close')); + + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + }); + + // ── onLogoSuccess ────────────────────────────────────────────────────────── + + describe('onLogoSuccess', () => { + it('calls updateLogo when result type is "update"', () => { + const { mockUpdateLogo } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-update')); + expect(mockUpdateLogo).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, name: 'Updated' }) + ); + }); + + it('calls addLogo when result type is "create"', () => { + const { mockAddLogo } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-create')); + expect(mockAddLogo).toHaveBeenCalledWith( + expect.objectContaining({ id: 99, name: 'New' }) + ); + }); + + it('calls fetchAllLogos when the logo is missing from the result', async () => { + const { mockFetchAllLogos } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-no-logo')); + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('does nothing when result is null', () => { + const { mockUpdateLogo, mockAddLogo, mockFetchAllLogos } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-null')); + expect(mockUpdateLogo).not.toHaveBeenCalled(); + expect(mockAddLogo).not.toHaveBeenCalled(); + expect(mockFetchAllLogos).not.toHaveBeenCalled(); + }); + }); + + // ── Delete single logo ───────────────────────────────────────────────────── + + describe('Delete single logo', () => { + it('opens "Delete Logo" confirmation dialog when delete action icon is clicked', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Logo'); + }); + + it('calls deleteLogo with the logo id on confirm', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, false) + ); + }); + + it('calls fetchAllLogos after a successful delete', async () => { + const logo = makeLogo(); + const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows a success notification after delete', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ) + ); + }); + + it('shows an error notification when deleteLogo rejects', async () => { + vi.mocked(LogosTableUtils.deleteLogo).mockRejectedValue(new Error('fail')); + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes the dialog on cancel without calling deleteLogo', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(LogosTableUtils.deleteLogo).not.toHaveBeenCalled(); + }); + + it('shows "Also Delete Files" option for /data/logos URLs', () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-ok-with-files')).toBeInTheDocument(); + }); + + it('does not show "Also Delete Files" option for external URLs', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.queryByTestId('confirm-ok-with-files')).not.toBeInTheDocument(); + }); + + it('calls deleteLogo with deleteFile=true when confirmed with file deletion', async () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok-with-files')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, true) + ); + }); + }); + + // ── Bulk delete ──────────────────────────────────────────────────────────── + + describe('Bulk delete', () => { + it('"Delete" button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete').closest('button')).toBeDisabled(); + }); + + it('enables "Delete" button and shows count after a row is selected', () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + + expect(screen.getByText(/Delete \(1\)/).closest('button')).not.toBeDisabled(); + }); + + it('opens "Delete Multiple Logos" dialog after selecting rows and clicking Delete', () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Multiple Logos'); + }); + + it('calls deleteLogos with selected ids on confirm', async () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogos).toHaveBeenCalledWith([1], false) + ); + }); + + it('calls fetchAllLogos after bulk delete', async () => { + const logo = makeLogo({ id: 1 }); + const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows success notification after bulk delete', async () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green' }) + ) + ); + }); + + it('shows error notification when deleteLogos rejects', async () => { + vi.mocked(LogosTableUtils.deleteLogos).mockRejectedValue(new Error('fail')); + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ) + ); + }); + + it('select-all header checkbox selects all logos', () => { + const logos = { + 1: makeLogo({ id: 1 }), + 2: makeLogo({ id: 2, name: 'Logo 2' }), + }; + setupMocks({ logos }); + render(); + + const { getByTestId: getHeaderCheckbox } = render(getSelectHeader()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + expect(screen.getByText(/Delete \(2\)/).closest('button')).not.toBeDisabled(); + }); + }); + + // ── Cleanup unused ───────────────────────────────────────────────────────── + + describe('Cleanup unused', () => { + const withUnused = () => ({ logos: { 1: makeLogo({ is_used: false }) } }); + + it('opens the cleanup confirmation dialog when the button is clicked', () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup Unused Logos'); + }); + + it('calls cleanupUnusedLogos(false) on confirm', async () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(false) + ); + }); + + it('calls cleanupUnusedLogos(true) when confirmed with file deletion', async () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok-with-files')); + await waitFor(() => + expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(true) + ); + }); + + it('calls fetchAllLogos after successful cleanup', async () => { + const { mockFetchAllLogos } = setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows "Cleanup Complete" success notification with deleted count', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 3, + local_files_deleted: 0, + }); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: 'green', + title: 'Cleanup Complete', + message: expect.stringContaining('3'), + }) + ) + ); + }); + + it('includes local file count in success notification when files were deleted', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 5, + local_files_deleted: 2, + }); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('2 local files'), + }) + ) + ); + }); + + it('shows "Cleanup Failed" error notification when cleanup rejects', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockRejectedValue(new Error('Server error')); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Cleanup Failed' }) + ) + ); + }); + + it('closes the cleanup dialog on cancel', () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Name filter (debounced) ──────────────────────────────────────────────── + + describe('name filter (debounced)', () => { + it('reflects the typed value in the filter input immediately', () => { + setupMocks(); + render(); + const input = screen.getByPlaceholderText('Filter by name...'); + fireEvent.change(input, { target: { value: 'sports' } }); + expect(input).toHaveValue('sports'); + }); + + it('does not pass filter value to getFilteredLogos immediately after typing', () => { + setupMocks(); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByPlaceholderText('Filter by name...'), { + target: { value: 'sports' }, + }); + + expect(LogosTableUtils.getFilteredLogos).not.toHaveBeenCalledWith( + expect.anything(), + 'sports', + expect.anything() + ); + }); + + it('passes filter value to getFilteredLogos after the 300ms debounce fires', async () => { + setupMocks(); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByPlaceholderText('Filter by name...'), { + target: { value: 'sports' }, + }); + + await waitFor( + () => + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith( + expect.anything(), + 'sports', + expect.anything() + ), + { timeout: 600 } + ); + }); + }); + + // ── Usage filter ─────────────────────────────────────────────────────────── + + describe('usage filter', () => { + it('calls getFilteredLogos with "used" when filter is changed to "Used only"', () => { + const logos = { 1: makeLogo() }; + setupMocks({ logos }); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'used' } }); + + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'used'); + }); + + it('calls getFilteredLogos with "unused" when filter is changed to "Unused only"', () => { + const logos = { 1: makeLogo() }; + setupMocks({ logos }); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'unused' } }); + + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'unused'); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('column cell renderers', () => { + it('renders the preview image with cache_url as src and /logo.png as fallback', () => { + const logo = makeLogo({ cache_url: '/cached/test.png', name: 'My Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'cache_url'); + const { getByAltText } = render( + col.cell({ getValue: () => logo.cache_url, row: { original: logo } }) + ); + + expect(getByAltText('My Logo')).toHaveAttribute('src', '/cached/test.png'); + expect(getByAltText('My Logo')).toHaveAttribute('data-fallback', '/logo.png'); + }); + + it('renders the logo name in the name column', () => { + const logo = makeLogo({ name: 'Channel 4 Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'name'); + const { getByText } = render(col.cell({ getValue: () => logo.name })); + expect(getByText('Channel 4 Logo')).toBeInTheDocument(); + }); + + it('renders an "Unused" badge when channel_count is 0', () => { + const logo = makeLogo({ channel_count: 0 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count'); + const { getByText } = render(col.cell({ getValue: () => 0, row: { original: logo } })); + expect(getByText('Unused')).toBeInTheDocument(); + }); + + it('renders a usage label badge when channel_count is > 0', () => { + const logo = makeLogo({ channel_count: 2, channel_names: ['Channel: HBO', 'Channel: CNN'] }); + vi.mocked(LogosTableUtils.generateUsageLabel).mockReturnValue('2 channels'); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count'); + const { getByText } = render(col.cell({ getValue: () => 2, row: { original: logo } })); + expect(getByText('2 channels')).toBeInTheDocument(); + }); + + it('renders the URL text in the URL column', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByText } = render(col.cell({ getValue: () => logo.url })); + expect(getByText('http://example.com/logo.png')).toBeInTheDocument(); + }); + + it('renders external link icon for http URLs', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByTestId } = render(col.cell({ getValue: () => logo.url })); + expect(getByTestId('icon-external-link')).toBeInTheDocument(); + }); + + it('does not render external link icon for non-http URLs', () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { queryByTestId } = render(col.cell({ getValue: () => logo.url })); + expect(queryByTestId('icon-external-link')).not.toBeInTheDocument(); + }); + + it('opens URL in a new tab when external link button is clicked', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByTestId } = render(col.cell({ getValue: () => logo.url })); + fireEvent.click(getByTestId('icon-external-link').closest('button')); + + expect(window.open).toHaveBeenCalledWith('http://example.com/logo.png', '_blank'); + }); + }); + + // ── Pagination controls ──────────────────────────────────────────────────── + + describe('pagination controls', () => { + it('updates pagination when page size is changed', () => { + setupMocks(); + render(); + fireEvent.change(screen.getByTestId('native-select'), { target: { value: '50' } }); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + + it('shows "Page Size" label', () => { + setupMocks(); + render(); + expect(screen.getByText('Page Size')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/tables/__tests__/M3UsTable.test.jsx b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx new file mode 100644 index 00000000..f0f44a66 --- /dev/null +++ b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx @@ -0,0 +1,1260 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + useDateTimeFormat: vi.fn(() => ({ + fullDateFormat: 'MM/DD/YYYY', + fullDateTimeFormat: 'MM/DD/YYYY HH:mm', + })), + format: vi.fn((val) => `formatted:${val}`), + diff: vi.fn(() => 30), + getNow: vi.fn(() => '2024-06-01T12:00:00Z'), +})); + +vi.mock('../../../utils/tables/M3UsTableUtils.js', () => ({ + deletePlaylist: vi.fn().mockResolvedValue(undefined), + getExpirationInfo: vi.fn(() => ({ color: 'green.5', label: '30d left' })), + getExpirationTooltip: vi.fn(() => 'expiration-tooltip'), + getPlaylistAutoCreatedChannelsCount: vi.fn().mockResolvedValue({ + count: 0, + sample_names: [], + }), + getSortedPlaylists: vi.fn((playlists) => + playlists.filter((p) => p.locked === false) + ), + getStatusColor: vi.fn(() => 'green.5'), + getStatusContent: vi.fn(() => ({ type: 'default', label: 'Idle' })), + formatStatusText: vi.fn((s) => + s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown' + ), + refreshPlaylist: vi.fn().mockResolvedValue(undefined), + updatePlaylist: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../M3uTableUtils.jsx', () => ({ + makeHeaderCellRenderer: vi.fn(() => (header) => ( + + {header.column.columnDef.header} + + )), + makeSortingChangeHandler: vi.fn(() => vi.fn()), +})); + +vi.mock('../../../helpers', () => ({ + TableHelper: { + defaultProperties: { mantineTableProps: { striped: true } }, + }, +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/M3U', () => ({ + default: ({ isOpen, onClose, m3uAccount }) => + isOpen ? ( +
+ {m3uAccount?.name ?? 'new'} + + +
+ ) : null, +})); + +vi.mock('../../ServerGroupsManagerModal', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../components/ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange }) => ( + + ), + Text: ({ children, size, c, fw, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + colors: { red: { 6: '#fa5252' }, green: { 6: '#40c057' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + // Icons used by M3UsTable + RefreshCcw: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + // Icons required by src/config/navigation.js (pulled in via store/auth) + Blocks: () => , + ChartLine: () => , + Database: () => , + Download: () => , + FileImage: () => , + LayoutGrid: () => , + ListOrdered: () => , + Logs: () => , + MonitorCog: () => , + Package: () => , + Play: () => , + PlugZap: () => , + Settings: () => , + User: () => , + Video: () => , + Webhook: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import * as M3UsTableUtils from '../../../utils/tables/M3UsTableUtils.js'; +import * as DateTimeUtils from '../../../utils/dateTimeUtils.js'; +import { useTable } from '../CustomTable'; +import M3UTable from '../M3UsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test M3U', + account_type: 'M3U', + server_url: 'http://example.com/playlist.m3u', + file_path: null, + status: 'success', + last_message: 'Loaded 500 streams', + max_streams: 5, + profiles: [], + is_active: true, + locked: false, + updated_at: '2024-01-01T12:00:00Z', + earliest_expiration: '2024-12-01T00:00:00Z', + all_expirations: [], + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + playlists = [makePlaylist()], + refreshProgress = {}, + editPlaylistId = null, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + tableSize = 'default', +} = {}) => { + const mockSetRefreshProgress = vi.fn(); + const mockSetEditPlaylistId = vi.fn(); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ + playlists, + refreshProgress, + setRefreshProgress: mockSetRefreshProgress, + editPlaylistId, + setEditPlaylistId: mockSetEditPlaylistId, + }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { getRowModel: () => ({ rows: [] }), getHeaderGroups: () => [] }; + }); + + return { mockSetRefreshProgress, mockSetEditPlaylistId }; +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (playlist) => ({ + row: { id: String(playlist.id), original: playlist }, + cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn() }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('M3UTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(M3UsTableUtils.deletePlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.refreshPlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.updatePlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({ + count: 0, + sample_names: [], + }); + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Idle' }); + vi.mocked(DateTimeUtils.format).mockImplementation((val) => `formatted:${val}`); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "M3U Accounts" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('M3U Accounts')).toBeInTheDocument(); + }); + + it('renders the "Add M3U" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add M3U')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the M3U form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + + it('filters out locked playlists from the table data', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Unlocked', locked: false }), + makePlaylist({ id: 2, name: 'Locked', locked: true }), + ], + }); + render(); + expect(capturedTableOptions.data.every((p) => p.locked === false)).toBe(true); + }); + + it('places active playlists before inactive ones', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Inactive', is_active: false }), + makePlaylist({ id: 2, name: 'Active', is_active: true }), + ], + }); + render(); + expect(capturedTableOptions.data[0].name).toBe('Active'); + expect(capturedTableOptions.data[1].name).toBe('Inactive'); + }); + + it('sorts playlists alphabetically within the same active group', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Zebra', is_active: true }), + makePlaylist({ id: 2, name: 'Alpha', is_active: true }), + ], + }); + render(); + expect(capturedTableOptions.data[0].name).toBe('Alpha'); + expect(capturedTableOptions.data[1].name).toBe('Zebra'); + }); + }); + + // ── Add M3U ──────────────────────────────────────────────────────────────── + + describe('Add M3U', () => { + it('opens M3UForm with no account when "Add M3U" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('new'); + }); + + it('closes the form when onClose(null) is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + fireEvent.click(screen.getByTestId('m3u-form-close')); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + + it('keeps form open and updates playlist when onClose receives a new playlist', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + fireEvent.click(screen.getByTestId('m3u-form-close-with-playlist')); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('New Playlist'); + }); + }); + + // ── Edit playlist via RowActions ─────────────────────────────────────────── + + describe('edit playlist via RowActions', () => { + it('opens M3UForm populated with the playlist when edit icon is clicked', () => { + const playlist = makePlaylist({ name: 'My M3U' }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('My M3U'); + }); + + it('closes the form after editing when onClose(null) is called', () => { + const playlist = makePlaylist({ name: 'My M3U' }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('m3u-form-close')); + + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + }); + + // ── editPlaylistId (from notifications) ─────────────────────────────────── + + describe('editPlaylistId from store', () => { + it('auto-opens M3UForm for the matching playlist', () => { + const playlists = [makePlaylist({ id: 42, name: 'Notification Playlist' })]; + setupMocks({ playlists, editPlaylistId: 42 }); + render(); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('Notification Playlist'); + }); + + it('calls setEditPlaylistId(null) after handling editPlaylistId', () => { + const playlists = [makePlaylist({ id: 42 })]; + const { mockSetEditPlaylistId } = setupMocks({ playlists, editPlaylistId: 42 }); + render(); + expect(mockSetEditPlaylistId).toHaveBeenCalledWith(null); + }); + + it('does not open form when editPlaylistId does not match any playlist', () => { + setupMocks({ playlists: [makePlaylist({ id: 1 })], editPlaylistId: 999 }); + render(); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + }); + + // ── Refresh playlist ─────────────────────────────────────────────────────── + + describe('refresh playlist', () => { + it('calls setRefreshProgress with initializing state immediately', async () => { + const playlist = makePlaylist({ id: 1 }); + const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(mockSetRefreshProgress).toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: 'initializing', progress: 0 }) + ); + }); + + it('calls refreshPlaylist with the playlist id', async () => { + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => + expect(M3UsTableUtils.refreshPlaylist).toHaveBeenCalledWith(1) + ); + }); + + it('sets error progress when refreshPlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.refreshPlaylist).mockRejectedValue(new Error('fail')); + const playlist = makePlaylist({ id: 1 }); + const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => + expect(mockSetRefreshProgress).toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: 'error', status: 'error' }) + ) + ); + }); + + it('disables the refresh button when playlist is inactive', () => { + const playlist = makePlaylist({ id: 1, is_active: false }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + + it('enables the refresh button when playlist is active', () => { + const playlist = makePlaylist({ id: 1, is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-refresh').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete playlist ──────────────────────────────────────────────────────── + + describe('delete playlist', () => { + const openDeleteDialog = async (playlist) => { + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + }; + + it('opens "Confirm M3U Account Deletion" dialog', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm M3U Account Deletion' + ); + }); + + it('shows rich message content in the dialog', async () => { + const playlist = makePlaylist({ name: 'My Channel List' }); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirm-message')).toHaveTextContent('rich-message'); + }); + + it('calls deletePlaylist with the correct id on confirm', async () => { + const playlist = makePlaylist({ id: 7 }); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(7) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes dialog on cancel without calling deletePlaylist', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(M3UsTableUtils.deletePlaylist).not.toHaveBeenCalled(); + }); + + it('skips dialog and deletes directly when warning suppressed and 0 auto-channels', async () => { + const playlist = makePlaylist({ id: 5 }); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(5) + ); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('opens dialog when warning suppressed but auto-channels count > 0', async () => { + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({ + count: 3, + sample_names: ['Ch 1'], + }); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('opens dialog when auto-channel count fetch fails', async () => { + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockRejectedValue( + new Error('Network error') + ); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('does not throw when deletePlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.deletePlaylist).mockRejectedValue(new Error('server error')); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + await expect( + act(async () => fireEvent.click(screen.getByTestId('confirm-ok'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Toggle active ────────────────────────────────────────────────────────── + + describe('toggle active', () => { + const renderSwitch = (playlist) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => playlist.is_active }, + row: { original: playlist }, + }); + }; + + it('calls updatePlaylist with is_active:false when toggling an active playlist', async () => { + const playlist = makePlaylist({ is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith( + { is_active: false }, + playlist, + true + ) + ); + }); + + it('calls updatePlaylist with is_active:true when toggling an inactive playlist', async () => { + const playlist = makePlaylist({ is_active: false }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith( + { is_active: true }, + playlist, + true + ) + ); + }); + + it('does not throw when updatePlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.updatePlaylist).mockRejectedValue(new Error('toggle error')); + const playlist = makePlaylist({ is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + await expect( + act(async () => fireEvent.click(getByTestId('active-switch'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Column: Type ─────────────────────────────────────────────────────────── + + describe('Type column', () => { + it('renders "XC" for Xtream Codes type', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('account_type').cell({ cell: { getValue: () => 'XC' } }) + ); + expect(getByText('XC')).toBeInTheDocument(); + }); + + it('renders "M3U" for standard type', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('account_type').cell({ cell: { getValue: () => 'M3U' } }) + ); + expect(getByText('M3U')).toBeInTheDocument(); + }); + }); + + // ── Column: URL / File ───────────────────────────────────────────────────── + + describe('URL / File column', () => { + it('renders server_url when present', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ server_url: 'http://example.com/list.m3u' }); + const { getByText } = render( + getCol('server_url').cell({ + cell: { getValue: () => playlist.server_url }, + row: { original: playlist }, + }) + ); + expect(getByText('http://example.com/list.m3u')).toBeInTheDocument(); + }); + + it('falls back to file_path when server_url is empty', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ server_url: '', file_path: '/files/list.m3u' }); + const { getByText } = render( + getCol('server_url').cell({ + cell: { getValue: () => '' }, + row: { original: playlist }, + }) + ); + expect(getByText('/files/list.m3u')).toBeInTheDocument(); + }); + }); + + // ── Column: Status ───────────────────────────────────────────────────────── + + describe('Status column', () => { + it('returns null when status value is empty', () => { + setupMocks(); + render(); + expect(getCol('status').cell({ cell: { getValue: () => null } })).toBeNull(); + }); + + it('renders formatted status text', () => { + vi.mocked(M3UsTableUtils.formatStatusText).mockReturnValue('Success'); + setupMocks(); + render(); + const { getByText } = render( + getCol('status').cell({ cell: { getValue: () => 'success' } }) + ); + expect(getByText('Success')).toBeInTheDocument(); + }); + }); + + // ── Column: Status Message ───────────────────────────────────────────────── + + describe('Status Message column', () => { + it('returns null when last_message is empty and no active progress', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'idle' }); + expect( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ).toBeNull(); + }); + + it('renders the last_message text for a generic status', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'idle' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Loaded 200 streams' }, + row: { original: playlist }, + }) + ); + expect(getByText('Loaded 200 streams')).toBeInTheDocument(); + }); + + it('shows progress UI when active progress (< 100) exists', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'initializing' }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ); + expect(getByText('Initializing refresh...')).toBeInTheDocument(); + }); + + it('bypasses progress UI when progress equals 100', () => { + const playlist = makePlaylist({ id: 1, status: 'success' }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Done' }, + row: { original: playlist }, + }) + ); + expect(getByText('Done')).toBeInTheDocument(); + }); + }); + + // ── Column: Max Streams ──────────────────────────────────────────────────── + + describe('Max Streams column', () => { + const renderMaxStreams = (playlist) => { + setupMocks({ playlists: [playlist] }); + render(); + return getCol('max_streams').cell({ row: { original: playlist } }); + }; + + it('renders max_streams when no active profiles', () => { + const { getByText } = render( + renderMaxStreams(makePlaylist({ max_streams: 10, profiles: [] })) + ); + expect(getByText('10')).toBeInTheDocument(); + }); + + it('renders "∞" when max_streams is 0 and no active profiles', () => { + const { getByText } = render( + renderMaxStreams(makePlaylist({ max_streams: 0, profiles: [] })) + ); + expect(getByText('∞')).toBeInTheDocument(); + }); + + it('renders the sum of active profile max_streams', () => { + const playlist = makePlaylist({ + profiles: [ + { name: 'P1', max_streams: 3, is_active: true }, + { name: 'P2', max_streams: 5, is_active: true }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('8')).toBeInTheDocument(); + }); + + it('renders "∞" when any active profile has max_streams 0', () => { + const playlist = makePlaylist({ + profiles: [ + { name: 'P1', max_streams: 0, is_active: true }, + { name: 'P2', max_streams: 5, is_active: true }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('∞')).toBeInTheDocument(); + }); + }); + + // ── Column: Expiration ───────────────────────────────────────────────────── + + describe('Expiration column', () => { + it('returns null when earliest_expiration is absent', () => { + setupMocks(); + render(); + expect( + getCol('earliest_expiration').cell({ + cell: { getValue: () => null }, + row: { original: makePlaylist({ earliest_expiration: null }) }, + }) + ).toBeNull(); + }); + + it('renders the expiration label from getExpirationInfo', () => { + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'orange.5', + label: '7d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-12-01T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + expect(getByText('7d left')).toBeInTheDocument(); + }); + }); + + // ── Column: Updated ──────────────────────────────────────────────────────── + + describe('Updated column', () => { + it('renders "Never" when updated_at is absent', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('updated_at').cell({ cell: { getValue: () => null } }) + ); + expect(getByText('Never')).toBeInTheDocument(); + }); + + it('renders the formatted date string when updated_at is present', () => { + vi.mocked(DateTimeUtils.format).mockReturnValue('formatted:2024-01-01T12:00:00Z'); + setupMocks(); + render(); + const { getByText } = render( + getCol('updated_at').cell({ cell: { getValue: () => '2024-01-01T12:00:00Z' } }) + ); + expect(getByText('formatted:2024-01-01T12:00:00Z')).toBeInTheDocument(); + }); + }); + + // ── generateStatusString content types ──────────────────────────────────── + + describe('generateStatusString via status message column', () => { + const renderProgressCell = (contentOverride, progressData = { progress: 50 }) => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue(contentOverride); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: progressData } }); + render(); + return getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }); + }; + + it('renders download progress with speed and time', () => { + const { getByText } = render( + renderProgressCell({ + type: 'downloading', + progress: 45, + speed: '1.2 MB/s', + timeRemaining: '2m', + }, { progress: 45 }) + ); + expect(getByText(/Downloading/)).toBeInTheDocument(); + expect(getByText('45%')).toBeInTheDocument(); + expect(getByText('1.2 MB/s')).toBeInTheDocument(); + }); + + it('renders parsing progress', () => { + const { getByText } = render( + renderProgressCell({ + type: 'parsing', + progress: 60, + elapsedTime: '5s', + timeRemaining: '3s', + streamsProcessed: '300/500', + }, { progress: 60 }) + ); + expect(getByText(/Parsing/)).toBeInTheDocument(); + expect(getByText('60%')).toBeInTheDocument(); + }); + + it('renders groups processing progress', () => { + const { getByText } = render( + renderProgressCell({ + type: 'groups', + progress: 30, + elapsedTime: '2s', + groupsProcessed: '10/50', + }, { progress: 30 }) + ); + expect(getByText(/Processing groups/)).toBeInTheDocument(); + }); + + it('renders error message from getStatusContent', () => { + const { getByText } = render( + renderProgressCell({ type: 'error', error: 'Connection refused' }) + ); + expect(getByText('Connection refused')).toBeInTheDocument(); + }); + + it('falls back to "Unknown error occurred" when error text is absent', () => { + const { getByText } = render( + renderProgressCell({ type: 'error', error: null }) + ); + expect(getByText('Unknown error occurred')).toBeInTheDocument(); + }); + + it('returns "Idle" string when progress is 100', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'downloading', progress: 100 }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } }); + render(); + // progress === 100 → generateStatusString returns the string 'Idle' + const result = getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }); + // The status message cell bypasses progress when progress === 100, so + // generateStatusString is not called. Verify via the progress-bypass path: + // (this is tested via "bypasses progress UI when progress equals 100" already) + expect(result).toBeNull(); // last_message is null, progress=100 so falls through to null + }); + + it('renders content.label for default type', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Queued' }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ); + expect(getByText('Queued')).toBeInTheDocument(); + }); + + it('renders downloading timeRemaining when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'downloading', + progress: 45, + speed: '1.2 MB/s', + timeRemaining: '30s left', + }, { progress: 45 }) + ); + expect(getByText('30s left')).toBeInTheDocument(); + }); + + it('renders groups elapsedTime and groupsProcessed when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'groups', + progress: 30, + elapsedTime: '4s', + groupsProcessed: '20/80', + }, { progress: 30 }) + ); + expect(getByText('4s')).toBeInTheDocument(); + expect(getByText('20/80')).toBeInTheDocument(); + }); + + it('renders parsing elapsedTime, timeRemaining and streamsProcessed when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'parsing', + progress: 60, + elapsedTime: '10s', + timeRemaining: '5s', + streamsProcessed: '600/1000', + }, { progress: 60 }) + ); + expect(getByText('10s')).toBeInTheDocument(); + expect(getByText('5s')).toBeInTheDocument(); + expect(getByText('600/1000')).toBeInTheDocument(); + }); + }); + + // ── Status Message column – error / success styling ──────────────────────── + + describe('Status Message column – error/success text', () => { + it('renders error-styled text when status is "error"', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'error' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Parse failure' }, + row: { original: playlist }, + }) + ); + expect(getByText('Parse failure')).toBeInTheDocument(); + }); + + it('renders success-styled text when status is "success"', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'success' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Loaded 500 streams' }, + row: { original: playlist }, + }) + ); + expect(getByText('Loaded 500 streams')).toBeInTheDocument(); + }); + }); + + // ── Server Groups modal ──────────────────────────────────────────────────── + + describe('Server Groups modal', () => { + it('renders the "Server Groups" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Server Groups')).toBeInTheDocument(); + }); + + it('opens the Server Groups modal when "Server Groups" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Server Groups')); + expect(screen.getByTestId('server-groups-modal')).toBeInTheDocument(); + }); + + it('closes the Server Groups modal when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Server Groups')); + fireEvent.click(screen.getByTestId('server-groups-close')); + expect(screen.queryByTestId('server-groups-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Max Streams column – inactive profiles excluded ──────────────────────── + + describe('Max Streams column – profile filtering', () => { + const renderMaxStreams = (playlist) => { + setupMocks({ playlists: [playlist] }); + render(); + return getCol('max_streams').cell({ row: { original: playlist } }); + }; + + it('excludes inactive profiles from the sum', () => { + // Need 2+ active profiles to trigger the sum branch; inactive ones must be ignored + const playlist = makePlaylist({ + profiles: [ + { name: 'A1', max_streams: 3, is_active: true }, + { name: 'A2', max_streams: 5, is_active: true }, + { name: 'Inactive', max_streams: 100, is_active: false }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + // 3 + 5 = 8, not 3 + 5 + 100 = 108 + expect(getByText('8')).toBeInTheDocument(); + }); + + it('uses playlist max_streams directly when there is exactly one active profile', () => { + const playlist = makePlaylist({ + max_streams: 7, + profiles: [{ name: 'Solo', max_streams: 7, is_active: true }], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('7')).toBeInTheDocument(); + }); + }); + + // ── Expiration column – bold label when daysLeft ≤ 7 ────────────────────── + + describe('Expiration column – bold label', () => { + it('renders with bold weight (600) when daysLeft is 7 or less', () => { + vi.mocked(DateTimeUtils.diff).mockReturnValue(5); + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'red.6', + label: '5d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-06-06T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + const el = getByText('5d left'); + // The Text mock passes `fw` as data-fw + expect(el).toHaveAttribute('data-fw', '600'); + }); + + it('renders with normal weight (400) when daysLeft > 7', () => { + vi.mocked(DateTimeUtils.diff).mockReturnValue(30); + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'green.5', + label: '30d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-07-01T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + expect(getByText('30d left')).toHaveAttribute('data-fw', '400'); + }); + + it('passes all_expirations to getExpirationTooltip', () => { + const expirations = ['2024-12-01', '2025-01-01']; + setupMocks(); + render(); + const playlist = makePlaylist({ all_expirations: expirations }); + render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-12-01T00:00:00Z' }, + row: { original: playlist }, + }) + ); + expect(M3UsTableUtils.getExpirationTooltip).toHaveBeenCalledWith( + expirations, + expect.any(String), + expect.any(String) + ); + }); + }); + + // ── Table structure (useTable options) ──────────────────────────────────── + + describe('table structure', () => { + it('passes enablePagination: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowVirtualization: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowVirtualization).toBe(true); + }); + + it('passes enableRowSelection: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes renderTopToolbar: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.renderTopToolbar).toBe(false); + }); + + it('passes manualSorting: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes allRowIds as the playlist ids', () => { + const playlists = [ + makePlaylist({ id: 10, name: 'A' }), + makePlaylist({ id: 20, name: 'B' }), + ]; + setupMocks({ playlists }); + render(); + expect(capturedTableOptions.allRowIds).toEqual( + expect.arrayContaining([10, 20]) + ); + }); + + it('actions column size is 75 in compact mode', () => { + setupMocks({ tableSize: 'compact' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(75); + }); + + it('actions column size is 100 in default mode', () => { + setupMocks({ tableSize: 'default' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(100); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx new file mode 100644 index 00000000..7ad3a002 --- /dev/null +++ b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from '../M3uTableUtils'; + +vi.mock('@mantine/core', () => ({ + Center: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Text: ({ children, size, name }) => ( + + {children} + + ), +})); + +vi.mock('lucide-react', () => ({ + ArrowUpDown: (props) => ( + + ), + ArrowUpNarrowWide: (props) => ( + + ), + ArrowDownWideNarrow: (props) => ( + + ), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Build a minimal header object that matches what TanStack Table provides */ +const makeHeader = ({ id = 'name', label = 'Name', sortable = true } = {}) => ({ + id, + column: { + columnDef: { + header: label, + sortable, + }, + }, +}); + +// ── makeHeaderCellRenderer ─────────────────────────────────────────────────── + +describe('makeHeaderCellRenderer', () => { + describe('with no active sort', () => { + const sorting = []; + let onSortingChange; + let renderHeader; + + beforeEach(() => { + onSortingChange = vi.fn(); + renderHeader = makeHeaderCellRenderer(sorting, onSortingChange); + }); + + it('renders the column label text', () => { + render(renderHeader(makeHeader({ label: 'Channel' }))); + expect(screen.getByTestId('text')).toHaveTextContent('Channel'); + }); + + it('renders the neutral ArrowUpDown icon when no sort is active', () => { + render(renderHeader(makeHeader())); + expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument(); + }); + + it('does not render a sort icon when column is not sortable', () => { + render(renderHeader(makeHeader({ sortable: false }))); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-narrow-wide') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-down-wide-narrow') + ).not.toBeInTheDocument(); + }); + + it('calls onSortingChange with the header id when icon is clicked', () => { + render(renderHeader(makeHeader({ id: 'title' }))); + fireEvent.click(screen.getByTestId('icon-arrow-up-down')); + expect(onSortingChange).toHaveBeenCalledTimes(1); + expect(onSortingChange).toHaveBeenCalledWith('title'); + }); + + it('sets the data-name attribute on the Text element to the header id', () => { + render(renderHeader(makeHeader({ id: 'status' }))); + expect(screen.getByTestId('text')).toHaveAttribute('data-name', 'status'); + }); + }); + + describe('when sorting asc on the current column (desc: false)', () => { + it('renders ArrowUpNarrowWide icon', () => { + const sorting = [{ id: 'name', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.getByTestId('icon-arrow-up-narrow-wide') + ).toBeInTheDocument(); + }); + + it('does not render ArrowDownWideNarrow or ArrowUpDown', () => { + const sorting = [{ id: 'name', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.queryByTestId('icon-arrow-down-wide-narrow') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + }); + }); + + describe('when sorting desc on the current column (desc: true)', () => { + it('renders ArrowDownWideNarrow icon', () => { + const sorting = [{ id: 'name', desc: true }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.getByTestId('icon-arrow-down-wide-narrow') + ).toBeInTheDocument(); + }); + + it('does not render ArrowUpNarrowWide or ArrowUpDown', () => { + const sorting = [{ id: 'name', desc: true }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.queryByTestId('icon-arrow-up-narrow-wide') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + }); + }); + + describe('when a different column is sorted', () => { + it('renders the neutral ArrowUpDown icon for the unsorted column', () => { + const sorting = [{ id: 'status', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument(); + }); + }); +}); + +// ── makeSortingChangeHandler ───────────────────────────────────────────────── + +describe('makeSortingChangeHandler', () => { + let setSorting; + let onDataSort; + + beforeEach(() => { + setSorting = vi.fn(); + onDataSort = vi.fn(); + }); + + describe('first click on a new column', () => { + it('sets ascending sort (desc: false)', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]); + }); + + it('calls onDataSort with the column and desc: false', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', false); + }); + }); + + describe('second click on the same column (currently asc)', () => { + it('sets descending sort (desc: true)', () => { + const sorting = [{ id: 'name', desc: false }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: true }]); + }); + + it('calls onDataSort with the column and desc: true', () => { + const sorting = [{ id: 'name', desc: false }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', true); + }); + }); + + describe('third click on the same column (currently desc) → clears sort', () => { + it('sets sorting to an empty array', () => { + const sorting = [{ id: 'name', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([]); + }); + + it('does NOT call onDataSort when sorting is cleared', () => { + const sorting = [{ id: 'name', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).not.toHaveBeenCalled(); + }); + }); + + describe('switching to a different column while another is sorted', () => { + it('resets to ascending sort on the new column', () => { + const sorting = [{ id: 'status', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]); + }); + + it('calls onDataSort with the new column and desc: false', () => { + const sorting = [{ id: 'status', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', false); + }); + }); + + describe('always calls setSorting', () => { + it('calls setSorting exactly once per handler invocation', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('title'); + expect(setSorting).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx new file mode 100644 index 00000000..47e66324 --- /dev/null +++ b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx @@ -0,0 +1,524 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/tables/OutputProfilesTableUtils.js', () => ({ + deleteOutputProfile: vi.fn().mockResolvedValue(undefined), + updateOutputProfile: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/OutputProfile', () => ({ + default: ({ isOpen, onClose, profile }) => + isOpen ? ( +
+ {profile?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, size, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useOutputProfilesStore from '../../../store/outputProfiles'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import * as OutputProfilesTableUtils from '../../../utils/tables/OutputProfilesTableUtils.js'; +import { useTable } from '../CustomTable'; +import OutputProfiles from '../OutputProfilesTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeProfile = (overrides = {}) => ({ + id: 1, + name: 'Test Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + is_active: true, + locked: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + profiles = [makeProfile()], + tableSize = 'default', +} = {}) => { + vi.mocked(useOutputProfilesStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (profile) => ({ + row: { id: String(profile.id), original: profile }, + cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn(() => undefined) }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('OutputProfiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockResolvedValue(undefined); + vi.mocked(OutputProfilesTableUtils.updateOutputProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add Output Profile" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Output Profile')).toBeInTheDocument(); + }); + + it('renders the hide/show inactive toggle button', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + + it('passes all unlocked+active profiles to useTable when hideInactive is false', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Add Output Profile ───────────────────────────────────────────────────── + + describe('Add Output Profile', () => { + it('opens the form with no profile when "Add Output Profile" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Output Profile')); + expect(screen.getByTestId('output-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Output Profile')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit Output Profile (RowActions) ─────────────────────────────────────── + + describe('edit profile via RowActions', () => { + it('opens the form populated with the profile when edit icon is clicked', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('output-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile'); + }); + + it('closes the form after editing when onClose is called', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete Output Profile (RowActions) ──────────────────────────────────── + + describe('delete profile via RowActions', () => { + it('calls deleteOutputProfile with the profile id when delete icon is clicked', async () => { + const profile = makeProfile({ id: 7 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(OutputProfilesTableUtils.deleteOutputProfile).toHaveBeenCalledWith(7) + ); + }); + + it('delete button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + + it('does not throw when deleteOutputProfile rejects', async () => { + vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockRejectedValue( + new Error('server error') + ); + const profile = makeProfile({ id: 1 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + await expect( + act(async () => fireEvent.click(getByTestId('icon-square-minus').closest('button'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Toggle active (is_active Switch) ────────────────────────────────────── + + describe('toggle profile is_active', () => { + const renderSwitch = (profile) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => profile.is_active }, + row: { original: profile }, + }); + }; + + it('calls updateOutputProfile with is_active toggled to false', async () => { + const profile = makeProfile({ is_active: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: profile.id, is_active: false }) + ) + ); + }); + + it('calls updateOutputProfile with is_active toggled to true', async () => { + const profile = makeProfile({ is_active: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: profile.id, is_active: true }) + ) + ); + }); + + it('switch is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).toBeDisabled(); + }); + + it('switch is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).not.toBeDisabled(); + }); + }); + + // ── Hide inactive toggle ─────────────────────────────────────────────────── + + describe('hide inactive toggle', () => { + it('shows Eye icon when hideInactive is false (default)', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument(); + }); + + it('shows EyeOff icon after the toggle is clicked', () => { + setupMocks(); + render(); + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows Eye icon again after toggling twice', () => { + setupMocks(); + render(); + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('excludes inactive profiles from table data when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + + expect(capturedTableOptions.data).toHaveLength(1); + expect(capturedTableOptions.data[0].name).toBe('Active'); + }); + + it('restores all profiles when hideInactive is turned back off', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); // hide inactive + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); // show all + + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('does not filter already-active profiles when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: true }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Column: Name ─────────────────────────────────────────────────────────── + + describe('Name column', () => { + it('renders the profile name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'My Profile' } }) + ); + expect(getByText('My Profile')).toBeInTheDocument(); + }); + }); + + // ── Column: Command ──────────────────────────────────────────────────────── + + describe('Command column', () => { + it('renders the command value', () => { + setupMocks(); + render(); + const col = getCol('command'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'ffmpeg' } }) + ); + expect(getByText('ffmpeg')).toBeInTheDocument(); + }); + }); + + // ── Column: Parameters ───────────────────────────────────────────────────── + + describe('Parameters column', () => { + it('renders the parameters value', () => { + setupMocks(); + render(); + const col = getCol('parameters'); + const { getByText } = render( + col.cell({ cell: { getValue: () => '-c:v copy -preset fast' } }) + ); + expect(getByText('-c:v copy -preset fast')).toBeInTheDocument(); + }); + }); + + // ── Store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes store profiles directly to the table data', () => { + const profiles = [ + makeProfile({ id: 1, name: 'Alpha' }), + makeProfile({ id: 2, name: 'Beta' }), + ]; + setupMocks({ profiles }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + expect(capturedTableOptions.data.map((p) => p.name)).toEqual(['Alpha', 'Beta']); + }); + + it('passes an empty array to the table when no profiles exist', () => { + setupMocks({ profiles: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx new file mode 100644 index 00000000..e13db683 --- /dev/null +++ b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx @@ -0,0 +1,540 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteStreamProfile: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({ + updateStreamProfile: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/StreamProfile', () => ({ + default: ({ isOpen, onClose, profile }) => + isOpen ? ( +
+ {profile?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useStreamProfilesStore from '../../../store/streamProfiles'; +import useSettingsStore from '../../../store/settings'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { updateStreamProfile } from '../../../utils/forms/StreamProfileUtils.js'; +import API from '../../../api'; +import StreamProfiles from '../StreamProfilesTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeProfile = (overrides = {}) => ({ + id: 1, + name: 'Test Profile', + command: 'ffmpeg', + parameters: '-c copy', + is_active: true, + locked: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + profiles = [makeProfile()], + defaultProfileId = 99, +} = {}) => { + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { default_stream_profile: defaultProfileId } }) + ); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (profile) => ({ + row: { id: String(profile.id), original: profile }, + cell: { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => undefined), + }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('StreamProfiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteStreamProfile).mockResolvedValue(undefined); + vi.mocked(updateStreamProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add Stream Profile" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Stream Profile')).toBeInTheDocument(); + }); + + it('renders the hide/show inactive toggle button', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + + it('passes all profiles to useTable when hideInactive is false', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Add Stream Profile ───────────────────────────────────────────────────── + + describe('Add Stream Profile', () => { + it('opens the form with no profile when "Add Stream Profile" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Stream Profile')); + expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Stream Profile')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit profile via RowActions ──────────────────────────────────────────── + + describe('edit profile via RowActions', () => { + it('opens the form populated with the profile when edit icon is clicked', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile'); + }); + + it('closes the form after editing when onClose is called', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete profile via RowActions ────────────────────────────────────────── + + describe('delete profile via RowActions', () => { + it('calls API.deleteStreamProfile with the profile id when delete icon is clicked', async () => { + const profile = makeProfile({ id: 7 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteStreamProfile).toHaveBeenCalledWith(7) + ); + }); + + it('shows a notification and does NOT call API when deleting the default profile', async () => { + const profile = makeProfile({ id: 5 }); + setupMocks({ profiles: [profile], defaultProfileId: 5 }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cannot delete default stream-profile', + color: 'red.5', + }) + ) + ); + expect(API.deleteStreamProfile).not.toHaveBeenCalled(); + }); + + it('delete button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Toggle profile is_active ─────────────────────────────────────────────── + + describe('toggle profile is_active', () => { + const renderSwitch = (profile) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => profile.is_active }, + row: { original: profile }, + }); + }; + + it('calls updateStreamProfile with is_active toggled to false', async () => { + const profile = makeProfile({ is_active: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(updateStreamProfile).toHaveBeenCalledWith( + profile.id, + expect.objectContaining({ id: profile.id, is_active: false }) + ) + ); + }); + + it('calls updateStreamProfile with is_active toggled to true', async () => { + const profile = makeProfile({ is_active: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(updateStreamProfile).toHaveBeenCalledWith( + profile.id, + expect.objectContaining({ id: profile.id, is_active: true }) + ) + ); + }); + + it('switch is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).toBeDisabled(); + }); + + it('switch is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).not.toBeDisabled(); + }); + }); + + // ── Hide inactive toggle ─────────────────────────────────────────────────── + + describe('hide inactive toggle', () => { + it('shows Eye icon when hideInactive is false (default)', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument(); + }); + + it('shows EyeOff icon after the toggle is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows Eye icon again after toggling twice', () => { + setupMocks(); + render(); + const btn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(btn); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('excludes inactive profiles from table data when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: false }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(1); + expect(capturedTableOptions.data[0].id).toBe(1); + }); + + it('restores all profiles when hideInactive is turned back off', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: false }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(1); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('does not filter already-active profiles when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: true }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('Name column', () => { + it('renders the profile name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'My Encoder' } }) + ); + expect(getByText('My Encoder')).toBeInTheDocument(); + }); + }); + + describe('Command column', () => { + it('renders the command value', () => { + setupMocks(); + render(); + const col = getCol('command'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'ffmpeg' } }) + ); + expect(getByText('ffmpeg')).toBeInTheDocument(); + }); + }); + + describe('Parameters column', () => { + it('renders the parameters value inside a Tooltip', () => { + setupMocks(); + render(); + const col = getCol('parameters'); + const { getByText } = render( + col.cell({ cell: { getValue: () => '-c copy -f mpegts' } }) + ); + expect(getByText('-c copy -f mpegts')).toBeInTheDocument(); + }); + }); + + // ── Store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes store profiles directly to the table data', () => { + const profiles = [ + makeProfile({ id: 1, name: 'P1' }), + makeProfile({ id: 2, name: 'P2' }), + makeProfile({ id: 3, name: 'P3' }), + ]; + setupMocks({ profiles }); + render(); + expect(capturedTableOptions.data).toHaveLength(3); + }); + + it('passes an empty array to the table when no profiles exist', () => { + setupMocks({ profiles: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/StreamsTable.test.jsx b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx new file mode 100644 index 00000000..84554ca7 --- /dev/null +++ b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx @@ -0,0 +1,833 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/channelsTable', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); +vi.mock('../../../store/streamsTable', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => [{}, vi.fn()]), +})); + +// ── Router mock ──────────────────────────────────────────────────────────────── +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), + useDebounce: vi.fn((value) => value), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => path), +})); + +vi.mock('../../../utils/forms/ChannelUtils.js', () => ({ + requeryChannels: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../utils/tables/StreamsTableUtils.js', () => ({ + addStreamsToChannel: vi.fn().mockResolvedValue(undefined), + appendFetchPageParams: vi.fn(), + createChannelFromStream: vi.fn().mockResolvedValue(undefined), + createChannelsFromStreamsAsync: vi.fn().mockResolvedValue({ task_id: 'task-1', stream_count: 1 }), + deleteStream: vi.fn().mockResolvedValue(undefined), + deleteStreams: vi.fn().mockResolvedValue(undefined), + getAllStreamIds: vi.fn().mockResolvedValue([]), + getChannelNumberValue: vi.fn((mode) => mode === 'provider' ? null : 1), + getChannelProfileIds: vi.fn((profileIds) => profileIds), + getFilterParams: vi.fn(() => new URLSearchParams()), + getStatsTooltip: vi.fn(() => ({ compactDisplay: '1080p', tooltipContent: '1920x1080' })), + getStreamFilterOptions: vi.fn().mockResolvedValue({ groups: [], m3u_accounts: [] }), + getStreams: vi.fn().mockResolvedValue([]), + queryStreamsTable: vi.fn().mockResolvedValue({ count: 5, results: [] }), + requeryStreams: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/Stream', () => ({ + default: ({ isOpen, onClose, stream }) => + isOpen ? ( +
+ {stream?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, message, confirmLabel, cancelLabel, loading }) => + opened ? ( +
+ {title} + {typeof message === 'string' ? message : 'message'} + + +
+ ) : null, +})); + +vi.mock('../../modals/CreateChannelModal', () => ({ + default: ({ opened, onClose, onConfirm }) => + opened ? ( +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Card: ({ children, style }) => ( +
{children}
+ ), + Center: ({ children, style }) =>
{children}
, + Divider: ({ label }) =>
, + Flex: ({ children, style }) => ( +
{children}
+ ), + Group: ({ children, style }) =>
{children}
, + LoadingOverlay: ({ visible }) => visible ?
: null, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) =>
{children}
, + Label: ({ children }) =>
{children}
, + Item: ({ children, onClick, leftSection }) => ( + + ), + Divider: () =>
, + } + ), + MenuDivider: () =>
, + MenuDropdown: ({ children }) =>
{children}
, + MenuItem: ({ children, onClick, leftSection }) => ( + + ), + MenuLabel: ({ children }) =>
{children}
, + MenuTarget: ({ children }) =>
{children}
, + MultiSelect: ({ onChange, value, data }) => ( + + ), + NativeSelect: ({ onChange, value, data }) => ( + + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style }) => ( + {children} + ), + TextInput: ({ onChange, value, placeholder }) => ( + + ), + Title: ({ children, style }) =>

{children}

, + Tooltip: ({ children, label }) => ( +
{children}
+ ), + UnstyledButton: ({ children, onClick }) => ( + + ), + useMantineTheme: vi.fn(() => ({ + tailwind: { blue: { 6: '#3b82f6' }, green: { 5: '#22c55e' }, yellow: { 3: '#fde047' } }, + palette: { background: { paper: '#1a1a1a' } }, + colors: {}, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDownWideNarrow: () => , + ArrowUpDown: () => , + ArrowUpNarrowWide: () => , + Copy: () => , + EllipsisVertical: () => , + Eye: () => , + EyeOff: () => , + Filter: () => , + ListPlus: () => , + RotateCcw: () => , + Search: () => , + Square: () => , + SquareCheck: () => , + SquareMinus: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useChannelsStore from '../../../store/channels'; +import useSettingsStore from '../../../store/settings'; +import useVideoStore from '../../../store/useVideoStore'; +import useChannelsTableStore from '../../../store/channelsTable'; +import useWarningsStore from '../../../store/warnings'; +import useStreamsTableStore from '../../../store/streamsTable'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { useNavigate } from 'react-router-dom'; +import { useTable } from '../CustomTable'; +import * as StreamsTableUtils from '../../../utils/tables/StreamsTableUtils.js'; +import StreamsTable from '../StreamsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeStream = (overrides = {}) => ({ + id: 1, + name: 'Test Stream', + url: 'http://example.com/stream', + stream_hash: 'abc123', + channel_group: 'group-1', + m3u_account: 10, + tvg_id: 'tvg-1', + stream_stats: null, + is_custom: true, + is_stale: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 50 }; +const DEFAULT_SORTING = [{ id: 'name', desc: false }]; + +const setupMocks = ({ + streams = [makeStream()], + pageCount = 1, + totalCount = 1, + allQueryIds = [1], + pagination = DEFAULT_PAGINATION, + sorting = DEFAULT_SORTING, + selectedStreamIds = [], + playlists = [{ id: 10, name: 'My M3U' }], + channelGroups = { 'group-1': { name: 'Sports' } }, + expandedChannelId = null, + selectedChannelIds = [], + channelProfiles = {}, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + envMode = 'production', + showVideo = vi.fn(), + isVisible = false, + tableSize = null, +} = {}) => { + vi.mocked(useStreamsTableStore).mockImplementation((sel) => + sel({ + streams, + pageCount, + totalCount, + allQueryIds, + pagination, + sorting, + selectedStreamIds, + setAllQueryIds: vi.fn(), + setPagination: vi.fn(), + setSorting: vi.fn(), + setSelectedStreamIds: vi.fn(), + }) + ); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ playlists, fetchPlaylists: vi.fn(), isLoading: false }) + ); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelGroups, + fetchChannelGroups: vi.fn(), + profiles: channelProfiles, + selectedProfileId: '0', + }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: envMode } }) + ); + + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo, isVisible }) + ); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + expandedChannelId, + selectedChannelIds, + channels: [], + }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ suppressWarning, isWarningSuppressed }) + ); + + // useLocalStorage: first call is column-sizing, second is column-visibility + vi.mocked(useLocalStorage) + .mockReturnValueOnce([{}, vi.fn()]) // streams-table-column-sizing + .mockReturnValueOnce([tableSize, vi.fn()]); // streams-table-column-visibility + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + tableSize: tableSize ?? 'default', + }; + }); +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('StreamsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 5, results: [] }); + vi.mocked(StreamsTableUtils.getAllStreamIds).mockResolvedValue([]); + vi.mocked(StreamsTableUtils.getStreamFilterOptions).mockResolvedValue({ + groups: [], + m3u_accounts: [], + }); + vi.mocked(StreamsTableUtils.deleteStream).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.deleteStreams).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.addStreamsToChannel).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.requeryStreams).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Streams" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Streams')).toBeInTheDocument(); + }); + + it('renders the "Create Stream" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Create Stream')).toBeInTheDocument(); + }); + + it('renders the "Delete" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('renders the "Add to Channel" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add to Channel')).toBeInTheDocument(); + }); + + it('renders "Create Channel (0)" button when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + expect(screen.getByText('Create Channel (0)')).toBeInTheDocument(); + }); + + it('renders "Create Channels (N)" button when multiple streams are selected', () => { + setupMocks({ selectedStreamIds: [1, 2] }); + render(); + expect(screen.getByText('Create Channels (2)')).toBeInTheDocument(); + }); + + it('does not render the stream form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument(); + }); + + it('shows getting-started card when totalCount is 0', async () => { + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] }); + setupMocks({ totalCount: 0, streams: [] }); + render(); + await waitFor(() => { + expect(screen.getByText('Getting started')).toBeInTheDocument(); + }); + }); + + it('renders the custom table when totalCount > 0', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + }); + + it('loading overlay is not visible after data finishes loading', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + // After the initial fetch resolves, the overlay is hidden + await waitFor(() => { + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Stream form (Create/Edit) ────────────────────────────────────────────── + + describe('Create Stream modal', () => { + it('opens the stream form with no stream when "Create Stream" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + expect(screen.getByTestId('stream-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-stream-name')).toHaveTextContent('new'); + }); + + it('closes the stream form when onClose is called', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + fireEvent.click(screen.getByTestId('form-close')); + await waitFor(() => { + expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument(); + }); + }); + + it('calls requeryStreams after form is closed', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + fireEvent.click(screen.getByTestId('form-close')); + await waitFor(() => { + expect(StreamsTableUtils.requeryStreams).toHaveBeenCalled(); + }); + }); + }); + + // ── Delete button state ──────────────────────────────────────────────────── + + describe('"Delete" button', () => { + it('is disabled when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const deleteBtn = screen.getByText('Delete').closest('button'); + expect(deleteBtn).toBeDisabled(); + }); + + it('is enabled when streams are selected', () => { + setupMocks({ selectedStreamIds: [1] }); + render(); + const deleteBtn = screen.getByText('Delete').closest('button'); + expect(deleteBtn).not.toBeDisabled(); + }); + }); + + // ── "Add to Channel" button state ────────────────────────────────────────── + + describe('"Add to Channel" button', () => { + it('is disabled when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).toBeDisabled(); + }); + + it('is disabled when streams selected but no target channel', () => { + setupMocks({ selectedStreamIds: [1], expandedChannelId: null, selectedChannelIds: [] }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).toBeDisabled(); + }); + + it('is enabled when streams selected and target channel exists', () => { + setupMocks({ + selectedStreamIds: [1], + expandedChannelId: 42, + }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).not.toBeDisabled(); + }); + }); + + // ── Single delete confirmation dialog ───────────────────────────────────── + + describe('single stream delete', () => { + it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm Bulk Stream Deletion'); + }); + + it('calls deleteStreams when delete is confirmed', async () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => { + expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled(); + }); + }); + + it('closes the dialog after confirming delete', async () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + it('closes the dialog on Cancel', () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('skips the dialog and calls deleteStreams directly when warning is suppressed', async () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => true), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + await waitFor(() => { + expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + }); + + // ── "Create Channel" button and modal ───────────────────────────────────── + + describe('Create Channel modal', () => { + it('opens CreateChannelModal when "Create Channel" is clicked with 1+ streams selected and warning not suppressed', () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + expect(screen.getByTestId('create-channel-modal')).toBeInTheDocument(); + }); + + it('closes CreateChannelModal when Close is clicked', () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + fireEvent.click(screen.getByTestId('create-channel-close')); + expect(screen.queryByTestId('create-channel-modal')).not.toBeInTheDocument(); + }); + + it('calls createChannelsFromStreamsAsync when modal is confirmed', async () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + fireEvent.click(screen.getByTestId('create-channel-confirm')); + await waitFor(() => { + expect(StreamsTableUtils.createChannelsFromStreamsAsync).toHaveBeenCalled(); + }); + }); + + it('"Create Channel" button is disabled when no streams selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const btn = screen.getByText('Create Channel (0)').closest('button'); + expect(btn).toBeDisabled(); + }); + }); + + // ── Getting started card navigation ─────────────────────────────────────── + + describe('getting started card', () => { + const renderEmpty = async () => { + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] }); + setupMocks({ totalCount: 0, streams: [] }); + render(); + await waitFor(() => screen.getByText('Getting started')); + }; + + it('shows "Add M3U" button', async () => { + await renderEmpty(); + expect(screen.getByText('Add M3U')).toBeInTheDocument(); + }); + + it('shows "Add Individual Stream" button', async () => { + await renderEmpty(); + expect(screen.getByText('Add Individual Stream')).toBeInTheDocument(); + }); + + it('navigates to /sources when "Add M3U" is clicked', async () => { + const mockNavigate = vi.fn(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + await renderEmpty(); + fireEvent.click(screen.getByText('Add M3U')); + expect(mockNavigate).toHaveBeenCalledWith('/sources'); + }); + + it('opens stream form when "Add Individual Stream" is clicked', async () => { + await renderEmpty(); + fireEvent.click(screen.getByText('Add Individual Stream')); + expect(screen.getByTestId('stream-form')).toBeInTheDocument(); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('renders pagination controls when totalCount > 0', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + }); + + it('renders current page number', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()], pagination: { pageIndex: 0, pageSize: 50 } }); + render(); + await waitFor(() => { + expect(screen.getByTestId('page-current')).toHaveTextContent('1'); + }); + }); + + it('renders native select for page size', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + }); + }); + + // ── Column visibility (Table Settings menu) ──────────────────────────────── + + describe('column visibility toggle menu', () => { + it('renders "Toggle Columns" label in the settings menu', () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + // Menu label is always rendered even in collapsed state due to mock + expect(screen.getByText('Toggle Columns')).toBeInTheDocument(); + }); + + it('renders all column toggle menu items', () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Group')).toBeInTheDocument(); + expect(screen.getByText('M3U')).toBeInTheDocument(); + expect(screen.getByText('TVG-ID')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + }); + }); + + // ── useTable integration ─────────────────────────────────────────────────── + + describe('useTable integration', () => { + it('passes stream data to useTable', async () => { + const streams = [makeStream({ id: 1 }), makeStream({ id: 2 })]; + setupMocks({ streams, totalCount: 2 }); + render(); + await waitFor(() => { + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + it('passes manualPagination: true to useTable', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(capturedTableOptions.manualPagination).toBe(true); + }); + }); + + it('passes manualSorting: true to useTable', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(capturedTableOptions.manualSorting).toBe(true); + }); + }); + + it('passes pagination state to useTable', async () => { + const pagination = { pageIndex: 2, pageSize: 25 }; + setupMocks({ totalCount: 5, streams: [makeStream()], pagination }); + render(); + await waitFor(() => { + expect(capturedTableOptions.state.pagination).toEqual(pagination); + }); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls queryStreamsTable on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.queryStreamsTable).toHaveBeenCalled(); + }); + }); + + it('calls getAllStreamIds on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.getAllStreamIds).toHaveBeenCalled(); + }); + }); + + it('calls getStreamFilterOptions on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.getStreamFilterOptions).toHaveBeenCalled(); + }); + }); + + it('calls onReady callback once data is loaded', async () => { + setupMocks(); + const onReady = vi.fn(); + render(); + await waitFor(() => { + expect(onReady).toHaveBeenCalledTimes(1); + }); + }); + }); + + // ── "Add to Channel" action ──────────────────────────────────────────────── + + describe('"Add to Channel" action', () => { + it('calls addStreamsToChannel with selected streams', async () => { + const stream = makeStream({ id: 5 }); + setupMocks({ + streams: [stream], + selectedStreamIds: [5], + expandedChannelId: 42, + }); + render(); + fireEvent.click(screen.getByText('Add to Channel')); + await waitFor(() => { + expect(StreamsTableUtils.addStreamsToChannel).toHaveBeenCalledWith( + 42, + undefined, + expect.arrayContaining([expect.objectContaining({ id: 5 })]) + ); + }); + }); + }); + + // ── handleWatchStream ────────────────────────────────────────────────────── + + describe('handleWatchStream (via row actions)', () => { + it('calls buildLiveStreamUrl and showVideo via the actions cell renderer', async () => { + const mockShowVideo = vi.fn(); + setupMocks({ totalCount: 5, streams: [makeStream()], showVideo: mockShowVideo }); + render(); + await waitFor(() => expect(capturedTableOptions).not.toBeNull()); + + const actionsCell = capturedTableOptions.bodyCellRenderFns?.actions; + expect(actionsCell).toBeDefined(); + + // Render the actions cell to get the StreamRowActions component + const row = { + original: makeStream({ stream_hash: 'hash-abc', name: 'My Stream' }), + }; + const cell = { column: { id: 'actions' } }; + + // The actions renderer returns a StreamRowActions element; find Preview Stream button + const { getByText } = render(actionsCell({ cell, row })); + fireEvent.click(getByText('Preview Stream')); + expect(mockShowVideo).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx new file mode 100644 index 00000000..906bb0e6 --- /dev/null +++ b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx @@ -0,0 +1,348 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteUserAgent: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/userAgents', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/UserAgent', () => ({ + default: ({ isOpen, onClose, userAgent }) => + isOpen ? ( +
+ {userAgent?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: ({ color }) => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + X: ({ color }) => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useUserAgentsStore from '../../../store/userAgents'; +import useSettingsStore from '../../../store/settings'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import API from '../../../api'; +import UserAgentsTable from '../UserAgentsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeUA = (overrides = {}) => ({ + id: 1, + name: 'Chrome Default', + user_agent: 'Mozilla/5.0 Chrome/120', + description: 'Standard Chrome UA', + is_active: true, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + userAgents = [makeUA()], + defaultUserAgentId = 99, +} = {}) => { + vi.mocked(useUserAgentsStore).mockImplementation((sel) => + sel({ userAgents }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { default_user_agent: defaultUserAgentId } }) + ); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const makeRowCtx = (ua) => ({ + row: { id: String(ua.id), original: ua }, + cell: { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => undefined), + }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('UserAgentsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteUserAgent).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add User-Agent" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add User-Agent')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + + it('passes all userAgents to useTable as data', () => { + const uas = [makeUA({ id: 1 }), makeUA({ id: 2 })]; + setupMocks({ userAgents: uas }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('passes an empty array when no userAgents exist', () => { + setupMocks({ userAgents: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); + + // ── Add User-Agent ───────────────────────────────────────────────────────── + + describe('Add User-Agent', () => { + it('opens the form with no user-agent when "Add User-Agent" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User-Agent')); + expect(screen.getByTestId('user-agent-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-ua-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User-Agent')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit via RowActions ──────────────────────────────────────────────────── + + describe('edit user-agent via RowActions', () => { + it('opens the form populated with the user-agent when edit icon is clicked', () => { + const ua = makeUA({ name: 'Firefox UA' }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('user-agent-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-ua-name')).toHaveTextContent('Firefox UA'); + }); + + it('closes the form after editing when onClose is called', () => { + const ua = makeUA({ name: 'Firefox UA' }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + }); + + // ── Delete via RowActions (single) ───────────────────────────────────────── + + describe('delete user-agent via RowActions (single id)', () => { + it('calls API.deleteUserAgent with the user-agent id', async () => { + const ua = makeUA({ id: 7 }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteUserAgent).toHaveBeenCalledWith(7) + ); + }); + + it('shows a notification and does NOT call API when deleting the default user-agent', async () => { + const ua = makeUA({ id: 5 }); + setupMocks({ userAgents: [ua], defaultUserAgentId: 5 }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cannot delete default user-agent', + color: 'red.5', + }) + ) + ); + expect(API.deleteUserAgent).not.toHaveBeenCalled(); + }); + }); + + // ── Active column cell renderer ──────────────────────────────────────────── + + describe('is_active column cell renderer', () => { + const renderIsActiveCell = (value) => { + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + return col.cell({ cell: { getValue: () => value } }); + }; + + it('renders Check icon when is_active is true', () => { + setupMocks(); + render(); + + const { getByTestId } = render(renderIsActiveCell(true)); + expect(getByTestId('icon-check')).toBeInTheDocument(); + }); + + it('renders X icon when is_active is false', () => { + setupMocks(); + render(); + + const { getByTestId } = render(renderIsActiveCell(false)); + expect(getByTestId('icon-x')).toBeInTheDocument(); + }); + }); + + // ── user_agent column cell renderer ─────────────────────────────────────── + + describe('user_agent column cell renderer', () => { + it('renders the user_agent string', () => { + setupMocks(); + render(); + + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'user_agent' + ); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'Mozilla/5.0 Safari/537' } }) + ); + expect(getByText('Mozilla/5.0 Safari/537')).toBeInTheDocument(); + }); + }); + + // ── description column cell renderer ────────────────────────────────────── + + describe('description column cell renderer', () => { + it('renders the description string', () => { + setupMocks(); + render(); + + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'description' + ); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'A custom user agent' } }) + ); + expect(getByText('A custom user agent')).toBeInTheDocument(); + }); + }); + + // ── store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes allRowIds derived from userAgent ids to useTable', () => { + const uas = [makeUA({ id: 10 }), makeUA({ id: 20 }), makeUA({ id: 30 })]; + setupMocks({ userAgents: uas }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([10, 20, 30]); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/UsersTable.test.jsx b/frontend/src/components/tables/__tests__/UsersTable.test.jsx new file mode 100644 index 00000000..c972fd81 --- /dev/null +++ b/frontend/src/components/tables/__tests__/UsersTable.test.jsx @@ -0,0 +1,674 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteUser: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/users', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + useDateTimeFormat: vi.fn(), + format: vi.fn((val) => `formatted:${val}`), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/User', () => ({ + default: ({ isOpen, onClose, user }) => + isOpen ? ( +
+ {user?.username ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Group: ({ children, style }) => ( +
{children}
+ ), + LoadingOverlay: ({ visible }) => visible ?
: null, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + tailwind: { + yellow: { 3: '#fde047' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useUsersStore from '../../../store/users'; +import useChannelsStore from '../../../store/channels'; +import useAuthStore from '../../../store/auth'; +import useWarningsStore from '../../../store/warnings'; +import { useDateTimeFormat, format } from '../../../utils/dateTimeUtils.js'; +import { useTable } from '../CustomTable'; +import API from '../../../api'; +import { USER_LEVELS, USER_LEVEL_LABELS } from '../../../constants'; +import UsersTable from '../UsersTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeUser = (overrides = {}) => ({ + id: 1, + username: 'testuser', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + user_level: USER_LEVELS.STANDARD, + date_joined: '2024-01-15T10:00:00Z', + last_login: '2024-06-01T12:00:00Z', + custom_properties: { xc_password: 'secret123' }, + channel_profiles: [], + ...overrides, +}); + +const makeAdminUser = (overrides = {}) => + makeUser({ id: 99, username: 'admin', user_level: USER_LEVELS.ADMIN, ...overrides }); + +let capturedTableOptions = null; + +const setupMocks = ({ + users = [makeUser()], + authUser = makeAdminUser(), + profiles = { 10: { id: 10, name: 'HD Profile' } }, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), +} = {}) => { + vi.mocked(useUsersStore).mockImplementation((sel) => + sel({ users }) + ); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: authUser }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateFormat: 'MM/DD/YYYY', + fullDateTimeFormat: 'MM/DD/YYYY HH:mm', + }); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getActionsCell = () => + capturedTableOptions.columns.find((c) => c.id === 'actions'); + +const getCol = (key) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === key || c.id === key + ); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('UsersTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteUser).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Users" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Users')).toBeInTheDocument(); + }); + + it('renders the "Add User" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add User')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the user form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('does not render the confirmation dialog on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('passes users sorted by id to useTable', () => { + const users = [ + makeUser({ id: 3, username: 'c' }), + makeUser({ id: 1, username: 'a' }), + makeUser({ id: 2, username: 'b' }), + ]; + setupMocks({ users }); + render(); + expect(capturedTableOptions.data.map((u) => u.id)).toEqual([1, 2, 3]); + }); + + it('passes allRowIds derived from user ids', () => { + const users = [makeUser({ id: 1 }), makeUser({ id: 2 })]; + setupMocks({ users }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([1, 2]); + }); + }); + + // ── "Add User" button state ──────────────────────────────────────────────── + + describe('"Add User" button access control', () => { + it('is enabled for admin users', () => { + setupMocks({ authUser: makeAdminUser() }); + render(); + expect(screen.getByText('Add User').closest('button')).not.toBeDisabled(); + }); + + it('is disabled for non-admin users', () => { + setupMocks({ authUser: makeUser({ user_level: USER_LEVELS.STANDARD }) }); + render(); + expect(screen.getByText('Add User').closest('button')).toBeDisabled(); + }); + }); + + // ── Add / Edit User form ─────────────────────────────────────────────────── + + describe('Add User form', () => { + it('opens the form with no user when "Add User" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User')); + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-user-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + }); + + describe('Edit user via actions column', () => { + it('opens the form populated with the user when edit icon is clicked', () => { + const user = makeUser({ username: 'janedoe' }); + setupMocks({ users: [user] }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-user-name')).toHaveTextContent('janedoe'); + }); + + it('closes the form after editing when onClose is called', () => { + const user = makeUser({ username: 'janedoe' }); + setupMocks({ users: [user] }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled for non-admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled for admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeAdminUser() }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete user ──────────────────────────────────────────────────────────── + + describe('Delete user via actions column', () => { + it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm User Deletion'); + }); + + it('calls API.deleteUser when confirmed via dialog', async () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(API.deleteUser).toHaveBeenCalledWith(5) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes the dialog when Cancel is clicked', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('skips dialog and calls API.deleteUser directly when warning is suppressed', async () => { + const user = makeUser({ id: 7 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => true) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteUser).toHaveBeenCalledWith(7) + ); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('delete button is disabled for non-admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is disabled when admin tries to delete themselves', () => { + const admin = makeAdminUser({ id: 99 }); + setupMocks({ users: [admin], authUser: admin }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: admin } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled for admin deleting a different user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeAdminUser({ id: 99 }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + }); + + // ── XCPasswordCell ───────────────────────────────────────────────────────── + + describe('XCPasswordCell', () => { + const renderXCCell = (customProperties) => { + const XCCell = getCol('custom_properties').cell; + return render( customProperties} />); + }; + + it('hides password by default (shows bullets)', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell({ xc_password: 'mypassword' }); + expect(getByText('••••••••')).toBeInTheDocument(); + }); + + it('shows the password after clicking the eye toggle', () => { + setupMocks(); + render(); + + const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' }); + fireEvent.click(getByTestId('icon-eye').closest('button')); + expect(getByText('mypassword')).toBeInTheDocument(); + }); + + it('hides the password again after toggling twice', () => { + setupMocks(); + render(); + + const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' }); + const toggleBtn = getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + fireEvent.click(getByTestId('icon-eye-off').closest('button')); + expect(getByText('••••••••')).toBeInTheDocument(); + }); + + it('shows "N/A" when no xc_password', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell({}); + expect(getByText('N/A')).toBeInTheDocument(); + }); + + it('does not render the eye toggle when password is N/A', () => { + setupMocks(); + render(); + + const { queryByTestId } = renderXCCell({}); + expect(queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows "N/A" when custom_properties is null', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell(null); + expect(getByText('N/A')).toBeInTheDocument(); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('user_level column', () => { + it('renders the label for ADMIN level', () => { + setupMocks(); + render(); + const col = getCol('user_level'); + const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.ADMIN })); + expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.ADMIN])).toBeInTheDocument(); + }); + + it('renders the label for STANDARD level', () => { + setupMocks(); + render(); + const col = getCol('user_level'); + const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.STANDARD })); + expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.STANDARD])).toBeInTheDocument(); + }); + }); + + describe('name column (accessorFn)', () => { + it('combines first_name and last_name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const value = col.accessorFn({ first_name: 'Jane', last_name: 'Doe' }); + expect(value).toBe('Jane Doe'); + }); + + it('trims when only first_name is set', () => { + setupMocks(); + render(); + const col = getCol('name'); + const value = col.accessorFn({ first_name: 'Jane', last_name: '' }); + expect(value).toBe('Jane'); + }); + + it('cell renders "-" when value is empty', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render(col.cell({ getValue: () => '' })); + expect(getByText('-')).toBeInTheDocument(); + }); + + it('cell renders the full name when set', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render(col.cell({ getValue: () => 'Jane Doe' })); + expect(getByText('Jane Doe')).toBeInTheDocument(); + }); + }); + + describe('date_joined column', () => { + it('calls format with fullDateFormat when date is present', () => { + setupMocks(); + render(); + const col = getCol('date_joined'); + const { getByText } = render(col.cell({ getValue: () => '2024-01-15T10:00:00Z' })); + expect(vi.mocked(format)).toHaveBeenCalledWith('2024-01-15T10:00:00Z', 'MM/DD/YYYY'); + expect(getByText('formatted:2024-01-15T10:00:00Z')).toBeInTheDocument(); + }); + + it('renders "-" when date is null', () => { + setupMocks(); + render(); + const col = getCol('date_joined'); + const { getByText } = render(col.cell({ getValue: () => null })); + expect(getByText('-')).toBeInTheDocument(); + }); + }); + + describe('last_login column', () => { + it('calls format with fullDateTimeFormat when date is present', () => { + setupMocks(); + render(); + const col = getCol('last_login'); + const { getByText } = render(col.cell({ getValue: () => '2024-06-01T12:00:00Z' })); + expect(vi.mocked(format)).toHaveBeenCalledWith('2024-06-01T12:00:00Z', 'MM/DD/YYYY HH:mm'); + expect(getByText('formatted:2024-06-01T12:00:00Z')).toBeInTheDocument(); + }); + + it('renders "Never" when last_login is null', () => { + setupMocks(); + render(); + const col = getCol('last_login'); + const { getByText } = render(col.cell({ getValue: () => null })); + expect(getByText('Never')).toBeInTheDocument(); + }); + }); + + describe('channel_profiles column', () => { + it('renders "All" badge when user has no profiles assigned', () => { + setupMocks({ profiles: { 10: { id: 10, name: 'HD Profile' } } }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [] })); + expect(getByText('All')).toBeInTheDocument(); + }); + + it('renders a badge for each assigned profile', () => { + setupMocks({ + profiles: { 10: { id: 10, name: 'HD Profile' }, 20: { id: 20, name: 'SD Profile' } }, + }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [10, 20] })); + expect(getByText('HD Profile')).toBeInTheDocument(); + expect(getByText('SD Profile')).toBeInTheDocument(); + }); + + it('renders "All" when profile ids do not match any profiles', () => { + setupMocks({ profiles: {} }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [99] })); + expect(getByText('All')).toBeInTheDocument(); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes enablePagination: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes manualSorting: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(false); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx new file mode 100644 index 00000000..3375121e --- /dev/null +++ b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx @@ -0,0 +1,1026 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/vodLogos', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, indeterminate, onChange }) => ( + + ), + Group: ({ children, style }) => ( +
{children}
+ ), + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + LoadingOverlay: ({ visible }) => + visible ?
: null, + NativeSelect: ({ value, data, onChange }) => ( + + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Select: ({ value, onChange, data }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style, name }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder }) => ( + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + tailwind: { + red: { 6: '#dc2626' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ExternalLink: () => , + SquareMinus: () => , + Trash: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useVODLogosStore from '../../../store/vodLogos'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import VODLogosTable from '../VODLogosTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'Test Logo', + url: 'http://example.com/logo.png', + cache_url: '/cache/logo.png', + movie_count: 0, + series_count: 0, + item_names: [], + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + logos = [makeLogo()], + totalCount = 1, + isLoading = false, + unusedCount = 0, +} = {}) => { + const fetchVODLogos = vi.fn().mockResolvedValue(undefined); + const deleteVODLogo = vi.fn().mockResolvedValue(undefined); + const deleteVODLogos = vi.fn().mockResolvedValue(undefined); + const cleanupUnusedVODLogos = vi.fn().mockResolvedValue({ deleted_count: 3 }); + const getUnusedLogosCount = vi.fn().mockResolvedValue(unusedCount); + + vi.mocked(useVODLogosStore).mockReturnValue({ + logos, + totalCount, + isLoading, + fetchVODLogos, + deleteVODLogo, + deleteVODLogos, + cleanupUnusedVODLogos, + getUnusedLogosCount, + }); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + }; + }); + + return { + fetchVODLogos, + deleteVODLogo, + deleteVODLogos, + cleanupUnusedVODLogos, + getUnusedLogosCount, + }; +}; + +const getCol = (key) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === key || c.id === key + ); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('VODLogosTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the name filter input', () => { + setupMocks(); + render(); + expect(screen.getByTestId('name-filter')).toBeInTheDocument(); + }); + + it('renders the usage filter select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('usage-filter')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders pagination controls', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + expect(screen.getByTestId('page-size-select')).toBeInTheDocument(); + }); + + it('does not render confirmation dialogs on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('renders "Cleanup Unused" button', () => { + setupMocks(); + render(); + expect(screen.getByText(/Cleanup Unused/)).toBeInTheDocument(); + }); + + it('renders "Delete" button', () => { + setupMocks(); + render(); + expect(screen.getByText(/^Delete/)).toBeInTheDocument(); + }); + + it('shows LoadingOverlay when isLoading is true', () => { + setupMocks({ isLoading: true }); + render(); + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); + }); + + it('does not show LoadingOverlay when isLoading is false', () => { + setupMocks({ isLoading: false }); + render(); + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + it('renders pagination string on initial load', () => { + setupMocks({ totalCount: 50 }); + render(); + expect(screen.getByText('1 to 25 of 50')).toBeInTheDocument(); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls fetchVODLogos with default params on mount', () => { + const { fetchVODLogos } = setupMocks(); + render(); + expect(fetchVODLogos).toHaveBeenCalledWith({ + page: 1, + page_size: 25, + name: '', + usage: undefined, + }); + }); + + it('calls getUnusedLogosCount on mount', () => { + const { getUnusedLogosCount } = setupMocks(); + render(); + expect(getUnusedLogosCount).toHaveBeenCalled(); + }); + + it('"Cleanup Unused" button shows count and becomes enabled when unusedCount resolves > 0', async () => { + setupMocks({ unusedCount: 7 }); + render(); + await waitFor(() => { + expect(screen.getByText(/Cleanup Unused \(7\)/)).toBeInTheDocument(); + expect( + screen.getByText(/Cleanup Unused \(7\)/).closest('button') + ).not.toBeDisabled(); + }); + }); + }); + + // ── Name filter ──────────────────────────────────────────────────────────── + + describe('name filter', () => { + it('calls fetchVODLogos with updated name when filter changes', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: 'HBO' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ name: 'HBO', page: 1 }) + ); + }); + + it('passes name: "" when filter is cleared', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: 'ESPN' }, + }); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: '' }, + }); + expect(fetchVODLogos).toHaveBeenLastCalledWith( + expect.objectContaining({ name: '' }) + ); + }); + }); + + // ── Usage filter ─────────────────────────────────────────────────────────── + + describe('usage filter', () => { + it('passes usage: undefined when filter is "all"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: undefined }) + ); + }); + + it('passes usage: "used" when filter changes to "used"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('usage-filter'), { + target: { value: 'used' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: 'used' }) + ); + }); + + it('passes usage: "unused" when filter changes to "unused"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('usage-filter'), { + target: { value: 'unused' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: 'unused' }) + ); + }); + }); + + // ── "Cleanup Unused" button ──────────────────────────────────────────────── + + describe('"Cleanup Unused" button', () => { + it('is disabled when unusedLogosCount is 0', () => { + setupMocks({ unusedCount: 0 }); + render(); + expect( + screen.getByText(/Cleanup Unused/).closest('button') + ).toBeDisabled(); + }); + + it('opens cleanup dialog when clicked', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Cleanup Unused Logos' + ); + }); + + it('calls cleanupUnusedVODLogos when confirmed', async () => { + const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(cleanupUnusedVODLogos).toHaveBeenCalled() + ); + }); + + it('shows success notification after cleanup', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'Cleaned up 3 unused VOD logos', + color: 'green', + }) + ) + ); + }); + + it('closes cleanup dialog after confirmation', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes cleanup dialog on cancel', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('shows error notification when cleanupUnusedVODLogos throws', async () => { + const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 }); + cleanupUnusedVODLogos.mockRejectedValue(new Error('Server error')); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── "Delete selected" button ─────────────────────────────────────────────── + + describe('"Delete selected" button', () => { + it('is disabled when no rows are selected', () => { + setupMocks(); + render(); + // Find the Delete button (not the cleanup button) + const buttons = screen.getAllByTestId('button'); + const deleteBtn = buttons.find((b) => b.textContent.includes('Delete') && !b.textContent.includes('Cleanup')); + expect(deleteBtn).toBeDisabled(); + }); + + it('shows row count in button label when rows are selected', async () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + // Render row checkbox and click to select + const selectCol = getCol('select'); + const { getByTestId: getRowCheckbox } = render( + selectCol.cell({ row: { original: logo } }) + ); + fireEvent.click(getRowCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(1\)/)).toBeInTheDocument() + ); + }); + + it('opens delete dialog with "Delete Multiple Logos" title when multiple rows are selected', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + + // Select all via header checkbox + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render( + selectCol.header() + ); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Delete Multiple Logos' + ); + }); + + it('calls deleteVODLogos when bulk delete is confirmed', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + const { deleteVODLogos } = setupMocks({ logos }); + render(); + + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render(selectCol.header()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(deleteVODLogos).toHaveBeenCalledWith([1, 2]) + ); + }); + + it('shows success notification with count after bulk delete', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render(selectCol.header()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: '2 VOD logos deleted successfully', + color: 'green', + }) + ) + ); + }); + }); + + // ── Delete via row actions ───────────────────────────────────────────────── + + describe('delete via row actions', () => { + it('opens delete dialog when row delete button is clicked', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + }); + + it('shows "Delete Logo" title for single row delete', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Delete Logo' + ); + }); + + it('closes delete dialog on cancel', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Single delete confirmation ───────────────────────────────────────────── + + describe('single delete confirmation', () => { + const setupSingleDeleteFlow = () => { + const logo = makeLogo({ id: 5, name: 'ESPN Logo' }); + const mocks = setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + return { logo, ...mocks }; + }; + + it('calls deleteVODLogo with the logo id when confirmed', async () => { + const { deleteVODLogo } = setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(deleteVODLogo).toHaveBeenCalledWith(5)); + }); + + it('shows success notification after single delete', async () => { + setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'VOD logo deleted successfully', + color: 'green', + }) + ) + ); + }); + + it('closes dialog after single delete', async () => { + setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('shows error notification when deleteVODLogo throws', async () => { + const logo = makeLogo({ id: 5 }); + const { deleteVODLogo } = setupMocks({ logos: [logo] }); + deleteVODLogo.mockRejectedValue(new Error('Network error')); + + render(); + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + message: 'Network error', + color: 'red', + }) + ) + ); + }); + + it('closes dialog even when deleteVODLogo throws', async () => { + const logo = makeLogo({ id: 5 }); + const { deleteVODLogo } = setupMocks({ logos: [logo] }); + deleteVODLogo.mockRejectedValue(new Error('Network error')); + + render(); + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('calls fetchVODLogos with page 2 when next page is clicked', () => { + const { fetchVODLogos } = setupMocks({ totalCount: 100 }); + render(); + fireEvent.click(screen.getByTestId('pagination-next')); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 2 }) + ); + }); + + it('resets to page 1 when page size changes', () => { + const { fetchVODLogos } = setupMocks({ totalCount: 200 }); + render(); + + // Go to page 2 first + fireEvent.click(screen.getByTestId('pagination-next')); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 2 }) + ); + + // Change page size — should reset to page 1 + fireEvent.change(screen.getByTestId('page-size-select'), { + target: { value: '50' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, page_size: 50 }) + ); + }); + + it('renders correct pagination string based on page and totalCount', () => { + setupMocks({ totalCount: 75 }); + render(); + expect(screen.getByText('1 to 25 of 75')).toBeInTheDocument(); + }); + + it('passes correct pageCount to useTable', () => { + setupMocks({ totalCount: 75 }); + render(); + // ceil(75 / 25) = 3 + expect(capturedTableOptions.pageCount).toBe(3); + }); + + it('updates pagination string after changing page size', () => { + setupMocks({ totalCount: 75 }); + render(); + fireEvent.change(screen.getByTestId('page-size-select'), { + target: { value: '50' }, + }); + expect(screen.getByText('1 to 50 of 75')).toBeInTheDocument(); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('usage column', () => { + it('shows "Unused" badge when movie_count and series_count are both 0', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { movie_count: 0, series_count: 0, item_names: [] }, + }, + }) + ); + expect(getByText('Unused')).toBeInTheDocument(); + }); + + it('shows movie count label when only movies use the logo', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 2, + series_count: 0, + item_names: ['Movie A', 'Movie B'], + }, + }, + }) + ); + expect(getByText('2 movies')).toBeInTheDocument(); + }); + + it('uses singular "movie" for exactly 1 movie', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { movie_count: 1, series_count: 0, item_names: ['A'] }, + }, + }) + ); + expect(getByText('1 movie')).toBeInTheDocument(); + }); + + it('shows combined item count when both movies and series are used', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 2, + series_count: 1, + item_names: ['A', 'B', 'C'], + }, + }, + }) + ); + expect(getByText('3 items')).toBeInTheDocument(); + }); + + it('shows series count label when only series use the logo', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 0, + series_count: 3, + item_names: ['S1', 'S2', 'S3'], + }, + }, + }) + ); + expect(getByText('3 series')).toBeInTheDocument(); + }); + }); + + describe('url column', () => { + it('renders the URL text', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + expect(getByText('http://example.com/logo.png')).toBeInTheDocument(); + }); + + it('shows ExternalLink icon for http URLs', () => { + setupMocks(); + render(); + const { getByTestId } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + expect(getByTestId('icon-external-link')).toBeInTheDocument(); + }); + + it('hides ExternalLink for non-http (local) URLs', () => { + setupMocks(); + render(); + const { queryByTestId } = render( + getCol('url').cell({ getValue: () => '/data/logos/logo.png' }) + ); + expect(queryByTestId('icon-external-link')).not.toBeInTheDocument(); + }); + + it('opens URL in new tab when ExternalLink is clicked', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + setupMocks(); + render(); + const { getByTestId } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + fireEvent.click(getByTestId('icon-external-link').closest('button')); + expect(openSpy).toHaveBeenCalledWith( + 'http://example.com/logo.png', + '_blank' + ); + openSpy.mockRestore(); + }); + }); + + describe('name column', () => { + it('renders the logo name', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('name').cell({ getValue: () => 'ESPN Logo' }) + ); + expect(getByText('ESPN Logo')).toBeInTheDocument(); + }); + }); + + describe('cache_url (Preview) column', () => { + it('renders an img with the correct src and alt', () => { + setupMocks(); + render(); + const { getByAltText } = render( + getCol('cache_url').cell({ + getValue: () => '/cache/espn.png', + row: { original: { name: 'ESPN Logo' } }, + }) + ); + const img = getByAltText('ESPN Logo'); + expect(img).toHaveAttribute('src', '/cache/espn.png'); + }); + }); + + describe('select column', () => { + it('renders an unchecked header checkbox when nothing is selected', () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + const { getByTestId } = render(getCol('select').header()); + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + + it('renders an unchecked row checkbox for an unselected row', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + const { getByTestId } = render( + getCol('select').cell({ row: { original: logo } }) + ); + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes enablePagination: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(true); + }); + + it('passes manualPagination: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualPagination).toBe(true); + }); + + it('passes logos as data', () => { + const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })]; + setupMocks({ logos }); + render(); + expect(capturedTableOptions.data).toEqual(logos); + }); + + it('passes allRowIds derived from logo ids', () => { + const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })]; + setupMocks({ logos }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([10, 20]); + }); + + it('passes manualSorting: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(false); + }); + }); +}); diff --git a/frontend/src/constants.js b/frontend/src/constants.js index b130553e..ecb7daca 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,19 +43,27 @@ export const PROXY_SETTINGS_OPTIONS = { }, redis_chunk_ttl: { label: 'Buffer Chunk TTL', + advanced: true, description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', }, channel_shutdown_delay: { label: 'Channel Shutdown Delay', description: - 'Delay in seconds before shutting down a channel after last client disconnects', + 'Delay in seconds before shutting down a channel after the last client disconnects', }, channel_init_grace_period: { label: 'Channel Initialization Timeout', + advanced: true, description: 'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.', }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + advanced: true, + description: + 'Seconds to keep a buffered channel alive when no viewer is connected yet. Rarely needed unless you start channels programmatically.', + }, new_client_behind_seconds: { label: 'New Client Buffer (seconds)', description: @@ -63,6 +71,14 @@ export const PROXY_SETTINGS_OPTIONS = { }, }; +export const EPG_SETTINGS_OPTIONS = { + xmltv_prev_days_override: { + label: 'XMLTV prev_days Override (catch-up)', + description: + 'Days of past programmes in the XC EPG output. 0 = auto-detect from the providers’ tv_archive_duration (capped at 30).', + }, +}; + export const USER_LIMITS_OPTIONS = { terminate_on_limit_exceeded: { label: 'Terminate on Limit Exceeded', diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 29d46dd8..1c7964cc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -45,6 +45,9 @@ const DvrSettingsForm = React.lazy( const SystemSettingsForm = React.lazy( () => import('../components/forms/settings/SystemSettingsForm.jsx') ); +const EpgSettingsForm = React.lazy( + () => import('../components/forms/settings/EpgSettingsForm.jsx') +); const NavOrderForm = React.lazy( () => import('../components/forms/settings/NavOrderForm.jsx') ); @@ -122,6 +125,19 @@ const SettingsPage = () => { + + EPG + + + }> + + + + + + System Settings diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 5ab6ed48..3bc642af 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -73,11 +73,21 @@ describe('dateTimeUtils', () => { it('should handle strict parsing', () => { // With strict=true, a date that doesn't match the format should be invalid - const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true); + const invalid = dateTimeUtils.initializeTime( + '15-01-2024', + 'YYYY-MM-DD', + null, + true + ); expect(invalid.isValid()).toBe(false); // With strict=true and a matching format, it should be valid - const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true); + const valid = dateTimeUtils.initializeTime( + '2024-01-15', + 'YYYY-MM-DD', + null, + true + ); expect(valid.isValid()).toBe(true); }); }); @@ -787,4 +797,141 @@ describe('dateTimeUtils', () => { expect(dateTimeUtils.getMillisecond(date)).toBe(0); }); }); + + describe('formatDuration', () => { + describe('default precision (hms)', () => { + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0)).toBe('0:00'); + }); + + it('should return "0:00" for falsy input', () => { + expect(dateTimeUtils.formatDuration(null)).toBe('0:00'); + expect(dateTimeUtils.formatDuration(undefined)).toBe('0:00'); + }); + + it('should return custom zeroValue when seconds is 0', () => { + expect(dateTimeUtils.formatDuration(0, { zeroValue: 'N/A' })).toBe( + 'N/A' + ); + }); + + it('should format seconds under 1 minute without hours', () => { + expect(dateTimeUtils.formatDuration(45)).toBe('0:45'); + }); + + it('should format minutes and seconds without hours when under 1 hour', () => { + expect(dateTimeUtils.formatDuration(90)).toBe('1:30'); + }); + + it('should format minutes with padded seconds', () => { + expect(dateTimeUtils.formatDuration(65)).toBe('1:05'); + }); + + it('should format hours when >= 1 hour', () => { + expect(dateTimeUtils.formatDuration(3661)).toBe('01:01:01'); + }); + + it('should pad all segments when hours are present', () => { + expect(dateTimeUtils.formatDuration(3600 + 60 + 5)).toBe('01:01:05'); + }); + + it('should handle exactly 1 hour', () => { + expect(dateTimeUtils.formatDuration(3600)).toBe('01:00:00'); + }); + + it('should handle negative seconds by taking absolute value', () => { + expect(dateTimeUtils.formatDuration(-90)).toBe('1:30'); + }); + + it('should show hours when alwaysShowHours is true even under 1 hour', () => { + expect( + dateTimeUtils.formatDuration(90, { alwaysShowHours: true }) + ).toBe('00:01:30'); + }); + + it('should show hours when alwaysShowHours is true for 0 minutes', () => { + expect( + dateTimeUtils.formatDuration(45, { alwaysShowHours: true }) + ).toBe('00:00:45'); + }); + }); + + describe('precision: hm', () => { + it('should return minutes only when under 1 hour and no alwaysShowHours', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'hm' })).toBe('1'); + }); + + it('should return HH:MM when >= 1 hour', () => { + expect(dateTimeUtils.formatDuration(3661, { precision: 'hm' })).toBe( + '01:01' + ); + }); + + it('should return HH:MM when alwaysShowHours is true', () => { + expect( + dateTimeUtils.formatDuration(90, { + precision: 'hm', + alwaysShowHours: true, + }) + ).toBe('00:01'); + }); + + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0, { precision: 'hm' })).toBe( + '0:00' + ); + }); + }); + + describe('precision: human', () => { + it('should format sub-hour content as minutes with suffix', () => { + expect(dateTimeUtils.formatDuration(2700, { precision: 'human' })).toBe( + '45m' + ); + }); + + it('should floor partial minutes for short content', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'human' })).toBe( + '1m' + ); + }); + + it('should format hour-plus content as hours and minutes', () => { + expect(dateTimeUtils.formatDuration(5400, { precision: 'human' })).toBe( + '1h 30m' + ); + }); + + it('should return custom zeroValue when seconds is 0', () => { + expect( + dateTimeUtils.formatDuration(0, { + precision: 'human', + zeroValue: 'Unknown', + }) + ).toBe('Unknown'); + }); + }); + + describe('precision: m', () => { + it('should return total minutes as integer', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'm' })).toBe('1'); + }); + + it('should return total minutes across hours', () => { + expect(dateTimeUtils.formatDuration(3661, { precision: 'm' })).toBe( + '61' + ); + }); + + it('should truncate partial minutes', () => { + expect(dateTimeUtils.formatDuration(89, { precision: 'm' })).toBe('1'); + }); + + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0, { precision: 'm' })).toBe( + '0:00' + ); + }); + }); + }); }); diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index 5bec7d9a..2514571a 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -1,27 +1,5 @@ import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js'; -export const formatDuration = (seconds) => { - if (!seconds) return 'Unknown'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; -}; - -// Format time for display (e.g., "1:23:45" or "23:45") -export const formatTime = (seconds) => { - if (!seconds || seconds === 0) return '0:00'; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } else { - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } -}; - export const getMovieDisplayTitle = (vodContent) => { return vodContent.content_name; }; diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js index 489b781a..0f266693 100644 --- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js @@ -2,87 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as VodConnectionCardUtils from '../VodConnectionCardUtils'; import * as dateTimeUtils from '../../dateTimeUtils.js'; -vi.mock('../../dateTimeUtils.js'); +vi.mock('../../dateTimeUtils.js', () => ({ + getNowMs: vi.fn(), + format: vi.fn(), + toFriendlyDuration: vi.fn(), +})); describe('VodConnectionCardUtils', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('formatDuration', () => { - it('should format duration with hours and minutes when hours > 0', () => { - const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s - expect(result).toBe('1h 1m'); - }); - - it('should format duration with only minutes when less than an hour', () => { - const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s - expect(result).toBe('2m'); - }); - - it('should format duration with 0 minutes when less than 60 seconds', () => { - const result = VodConnectionCardUtils.formatDuration(45); - expect(result).toBe('0m'); - }); - - it('should handle multiple hours correctly', () => { - const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s - expect(result).toBe('2h 2m'); - }); - - it('should return Unknown for zero seconds', () => { - const result = VodConnectionCardUtils.formatDuration(0); - expect(result).toBe('Unknown'); - }); - - it('should return Unknown for null', () => { - const result = VodConnectionCardUtils.formatDuration(null); - expect(result).toBe('Unknown'); - }); - - it('should return Unknown for undefined', () => { - const result = VodConnectionCardUtils.formatDuration(undefined); - expect(result).toBe('Unknown'); - }); - }); - - describe('formatTime', () => { - it('should format time with hours when hours > 0', () => { - const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05 - expect(result).toBe('1:01:05'); - }); - - it('should format time without hours when less than an hour', () => { - const result = VodConnectionCardUtils.formatTime(125); // 2:05 - expect(result).toBe('2:05'); - }); - - it('should pad minutes and seconds with zeros', () => { - const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05 - expect(result).toBe('1:00:05'); - }); - - it('should handle only seconds', () => { - const result = VodConnectionCardUtils.formatTime(45); // 0:45 - expect(result).toBe('0:45'); - }); - - it('should return 0:00 for zero seconds', () => { - const result = VodConnectionCardUtils.formatTime(0); - expect(result).toBe('0:00'); - }); - - it('should return 0:00 for null', () => { - const result = VodConnectionCardUtils.formatTime(null); - expect(result).toBe('0:00'); - }); - - it('should return 0:00 for undefined', () => { - const result = VodConnectionCardUtils.formatTime(undefined); - expect(result).toBe('0:00'); - }); - }); - describe('getMovieDisplayTitle', () => { it('should return content_name from vodContent', () => { const vodContent = { content_name: 'The Matrix' }; diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index f973e0b0..2b613a32 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -18,7 +18,12 @@ export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); export const convertToSec = (dateTime) => dayjs(dateTime).unix(); -export const initializeTime = (dateTime, format = null, locale = null, strict = false) => { +export const initializeTime = ( + dateTime, + format = null, + locale = null, + strict = false +) => { if (format && locale) { return dayjs(dateTime, format, locale, strict); } else if (format) { @@ -26,7 +31,7 @@ export const initializeTime = (dateTime, format = null, locale = null, strict = } else { return dayjs(dateTime); } -} +}; export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); @@ -90,7 +95,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value); export const setSecond = (dateTime, value) => dayjs(dateTime).second(value); -export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value); +export const setMillisecond = (dateTime, value) => + dayjs(dateTime).millisecond(value); export const getMonth = (dateTime) => dayjs(dateTime).month(); @@ -372,3 +378,41 @@ export const MONTH_ABBR = [ 'nov', 'dec', ]; + +/** + * @param {number} seconds + * @param {object} [options] + * @param {'hms'|'hm'|'m'|'human'} [options.precision='hms'] - Segments to include + * @param {boolean} [options.alwaysShowHours=false] - Always include hours segment + * @param {string|null} [options.zeroValue=null] - Return this when seconds is 0/falsy + */ +export const formatDuration = (seconds, options = {}) => { + const { + precision = 'hms', + alwaysShowHours = false, + zeroValue = null, + } = options; + + if (!seconds || seconds === 0) return zeroValue ?? '0:00'; + + const abs = Math.abs(seconds); + const h = Math.floor(abs / 3600); + const m = Math.floor((abs % 3600) / 60); + const s = Math.floor(abs % 60); + + const mm = m.toString().padStart(2, '0'); + const ss = s.toString().padStart(2, '0'); + const hh = h.toString().padStart(2, '0'); + + switch (precision) { + case 'human': + return h > 0 ? `${h}h ${m}m` : `${m}m`; + case 'm': + return `${Math.floor(abs / 60)}`; + case 'hm': + return alwaysShowHours || h > 0 ? `${hh}:${mm}` : `${m}`; + case 'hms': + default: + return alwaysShowHours || h > 0 ? `${hh}:${mm}:${ss}` : `${m}:${ss}`; + } +}; diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js index cc3743e3..7eabab0a 100644 --- a/frontend/src/utils/forms/ChannelUtils.js +++ b/frontend/src/utils/forms/ChannelUtils.js @@ -34,15 +34,13 @@ export const createLogo = (newLogoData) => { const setChannelEPG = (channel, values) => { return API.setChannelEPG(channel.id, values.epg_data_id); }; -const updateChannel = (values) => { +export const updateChannel = (values) => { return API.updateChannel(values); }; export const addChannel = (channel) => { return API.addChannel(channel); }; -export const requeryChannels = () => { - API.requeryChannels(); -}; +export const requeryChannels = () => API.requeryChannels(); // PATCH semantic: `override: null` deletes the override row; per-field // nulls only clear the matching field. diff --git a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js index 88002c09..53cd0d99 100644 --- a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js +++ b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js @@ -114,13 +114,20 @@ describe('ChannelUtils', () => { describe('requeryChannels', () => { it('calls API.requeryChannels', () => { + vi.mocked(API.requeryChannels).mockResolvedValue(undefined); requeryChannels(); expect(API.requeryChannels).toHaveBeenCalledTimes(1); }); - it('returns undefined', () => { - const result = requeryChannels(); - expect(result).toBeUndefined(); + it('returns the promise from API.requeryChannels', async () => { + const response = { results: [{ id: 1 }] }; + vi.mocked(API.requeryChannels).mockResolvedValue(response); + await expect(requeryChannels()).resolves.toBe(response); + }); + + it('propagates rejection from API.requeryChannels', async () => { + vi.mocked(API.requeryChannels).mockRejectedValue(new Error('network')); + await expect(requeryChannels()).rejects.toThrow('network'); }); }); diff --git a/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js b/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js new file mode 100644 index 00000000..d2138b6a --- /dev/null +++ b/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js @@ -0,0 +1,3 @@ +export const getEpgSettingsFormInitialValues = () => ({ + xmltv_prev_days_override: 0, +}); diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js index eddbba91..ccbabdeb 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -13,7 +13,8 @@ export const getProxySettingDefaults = () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 5, + channel_init_grace_period: 60, + channel_client_wait_period: 5, new_client_behind_seconds: 5, }; }; diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js index 21c48269..3c17c42e 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -60,7 +60,8 @@ describe('ProxySettingsFormUtils', () => { buffering_speed: 1.0, redis_chunk_ttl: 60, channel_shutdown_delay: 0, - channel_init_grace_period: 5, + channel_init_grace_period: 60, + channel_client_wait_period: 5, new_client_behind_seconds: 5, }); }); @@ -81,6 +82,7 @@ describe('ProxySettingsFormUtils', () => { expect(typeof result.redis_chunk_ttl).toBe('number'); expect(typeof result.channel_shutdown_delay).toBe('number'); expect(typeof result.channel_init_grace_period).toBe('number'); + expect(typeof result.channel_client_wait_period).toBe('number'); expect(typeof result.new_client_behind_seconds).toBe('number'); }); }); diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index b097accd..bd46ac52 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -39,6 +39,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom', + 'xmltv_prev_days_override', ]; const dvrFields = [ 'tv_template', @@ -125,6 +126,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'retention_count', 'schedule_day_of_week', 'max_system_events', + 'xmltv_prev_days_override', ]; if (numericFields.includes(formKey) && value != null) { value = typeof value === 'number' ? value : parseInt(value, 10); @@ -222,6 +224,20 @@ export const getChangedSettings = (values, settings) => { continue; } + if (settingKey === 'xmltv_prev_days_override') { + const baseline = Number( + settings['epg_settings']?.value?.xmltv_prev_days_override ?? 0, + ); + const nextVal = + typeof actualValue === 'number' + ? actualValue + : parseInt(actualValue, 10) || 0; + if (nextVal !== baseline) { + changedSettings[settingKey] = nextVal; + } + continue; + } + // Convert array values (like m3u_hash_key) to comma-separated strings for comparison if (Array.isArray(actualValue)) { actualValue = actualValue.join(','); @@ -296,6 +312,12 @@ export const parseSettings = (settings) => { epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom) ? epgSettings.epg_match_ignore_custom : []; + parsed.xmltv_prev_days_override = + epgSettings && epgSettings.xmltv_prev_days_override != null + ? typeof epgSettings.xmltv_prev_days_override === 'number' + ? epgSettings.xmltv_prev_days_override + : parseInt(epgSettings.xmltv_prev_days_override, 10) || 0 + : 0; // DVR settings - direct mapping with underscore keys const dvrSettings = settings['dvr_settings']?.value; diff --git a/frontend/src/utils/tables/ChannelTableStreamsUtils.js b/frontend/src/utils/tables/ChannelTableStreamsUtils.js new file mode 100644 index 00000000..8fdbc7a7 --- /dev/null +++ b/frontend/src/utils/tables/ChannelTableStreamsUtils.js @@ -0,0 +1,100 @@ +import API from '../../api.js'; +import { formatBytes } from '../networkUtils.js'; +import { formatDuration } from '../dateTimeUtils.js'; + +const categoryMapping = { + basic: [ + 'resolution', + 'video_codec', + 'source_fps', + 'audio_codec', + 'audio_channels', + ], + video: [ + 'video_bitrate', + 'pixel_format', + 'width', + 'height', + 'aspect_ratio', + 'frame_rate', + ], + audio: [ + 'audio_bitrate', + 'sample_rate', + 'audio_format', + 'audio_channels_layout', + ], + technical: [ + 'stream_type', + 'container_format', + 'duration', + 'file_size', + 'ffmpeg_output_bitrate', + 'input_bitrate', + ], + other: [], +}; + +export const categorizeStreamStats = (stats) => { + if (!stats) + return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; + + const categories = { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }; + + Object.entries(stats).forEach(([key, value]) => { + let categorized = false; + for (const [category, keys] of Object.entries(categoryMapping)) { + if (keys.includes(key)) { + categories[category][key] = value; + categorized = true; + break; + } + } + if (!categorized) { + categories.other[key] = value; + } + }); + + return categories; +}; + +export const formatStatValue = (key, value) => { + if (value === null || value === undefined) return 'N/A'; + + switch (key) { + case 'video_bitrate': + case 'audio_bitrate': + case 'ffmpeg_output_bitrate': + return `${value} kbps`; + case 'source_fps': + case 'frame_rate': + return `${value} fps`; + case 'sample_rate': + return `${value} Hz`; + case 'file_size': + return typeof value === 'number' ? formatBytes(value) : value; + case 'duration': + return typeof value === 'number' + ? formatDuration(value, { alwaysShowHours: true }) + : value; + default: + return value.toString(); + } +}; + +export const formatStatKey = (key) => { + return key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +}; + +export const getChannelStreamStats = (channelId, since, ids) => { + return API.getChannelStreamStats(channelId, since, ids); +}; +export const reorderChannelStreams = (channelId, streamIds) => { + return API.reorderChannelStreams(channelId, streamIds); +}; diff --git a/frontend/src/utils/tables/ChannelsTableUtils.js b/frontend/src/utils/tables/ChannelsTableUtils.js new file mode 100644 index 00000000..a5cadb45 --- /dev/null +++ b/frontend/src/utils/tables/ChannelsTableUtils.js @@ -0,0 +1,233 @@ +import { + normalizeFieldValue, + OVERRIDABLE_FIELDS, +} from '../forms/ChannelUtils.js'; +import API from '../../api.js'; + +// Inline edits on auto-synced channels route into the override row so +// sync cannot overwrite them. If the new value matches the provider's, +// clear that field's override instead of writing a duplicate. Manual +// channels keep direct Channel.* writes. +export const buildInlinePatch = (rowOriginal, fieldId, newValue) => { + if (rowOriginal?.auto_created && OVERRIDABLE_FIELDS.includes(fieldId)) { + // Normalize both sides so a stringified form value compares + // cleanly against the typed provider value. + const formValue = normalizeFieldValue(fieldId, newValue); + const providerValue = normalizeFieldValue(fieldId, rowOriginal[fieldId]); + const overrideFieldValue = formValue === providerValue ? null : formValue; + return { + id: rowOriginal.id, + override: { [fieldId]: overrideFieldValue }, + }; + } + const normalized = + newValue === undefined || newValue === '' ? null : newValue; + return { + id: rowOriginal.id, + [fieldId]: normalized, + }; +}; + +export const getEpgOptions = (tvgsById, epgs) => { + const options = [{ value: 'null', label: 'Not Assigned' }]; + + // Convert tvgsById to an array and sort by EPG source name, then by tvg_id + const tvgsArray = Object.values(tvgsById).sort((a, b) => { + const aEpgName = + a.epg_source && epgs[a.epg_source] + ? epgs[a.epg_source].name + : a.epg_source || ''; + const bEpgName = + b.epg_source && epgs[b.epg_source] + ? epgs[b.epg_source].name + : b.epg_source || ''; + const epgCompare = aEpgName.localeCompare(bEpgName); + if (epgCompare !== 0) return epgCompare; + // Secondary sort by tvg_id + return (a.tvg_id || '').localeCompare(b.tvg_id || ''); + }); + + tvgsArray.forEach((tvg) => { + const epgSourceName = + tvg.epg_source && epgs[tvg.epg_source] + ? epgs[tvg.epg_source].name + : tvg.epg_source; + const tvgName = tvg.name; + // Create a comprehensive label: "EPG Name | TVG-ID | TVG Name" + let label; + if (epgSourceName && tvg.tvg_id) { + label = `${epgSourceName} | ${tvg.tvg_id}`; + if (tvgName && tvgName !== tvg.tvg_id) { + label += ` | ${tvgName}`; + } + } else if (tvgName) { + label = tvgName; + } else { + label = `ID: ${tvg.id}`; + } + + options.push({ + value: String(tvg.id), + label: label, + }); + }); + + return options; +}; + +export const getLogoOptions = (channelLogos) => { + const options = [ + { + value: 'null', + label: 'Default', + logo: null, + }, + ]; + + // Convert channelLogos object to array and sort by name + const logosArray = Object.values(channelLogos).sort((a, b) => + (a.name || '').localeCompare(b.name || '') + ); + + logosArray.forEach((logo) => { + options.push({ + value: String(logo.id), + label: logo.name || `Logo ${logo.id}`, + logo: logo, + }); + }); + + return options; +}; + +export const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; +export const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; +export const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; + +export const reorderChannel = (channelId, insertAfterId) => { + return API.reorderChannel(channelId, insertAfterId); +}; +export const deleteChannel = (id) => { + return API.deleteChannel(id); +}; +export const deleteChannels = (channelIds) => { + return API.deleteChannels(channelIds); +}; +export const queryChannels = (params) => { + return API.queryChannels(params); +}; +export const getAllChannelIds = (params) => { + return API.getAllChannelIds(params); +}; +export const updateProfileChannels = (channelIds, profileId, enabled) => { + return API.updateProfileChannels(channelIds, profileId, enabled); +}; +export const updateProfileChannel = (channelId, profileId, enabled) => { + return API.updateProfileChannel(channelId, profileId, enabled); +}; +export const addChannelProfile = (values) => { + return API.addChannelProfile(values); +}; +export const deleteChannelProfile = (id) => { + return API.deleteChannelProfile(id); +}; + +const getSortParam = (sorting) => { + let sortField = sorting[0].id; + // Map frontend column ids to backend ordering field names + const fieldMapping = { + channel_group: 'channel_group__name', + epg: 'epg_data__name', + }; + if (fieldMapping[sortField]) { + sortField = fieldMapping[sortField]; + } + const sortDirection = sorting[0].desc ? '-' : ''; + return { sortField, sortDirection }; +}; + +const applyDebouncedFilters = (debouncedFilters, params) => { + // Apply debounced filters + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (value) { + if (Array.isArray(value)) { + // Convert null values to "null" string for URL parameter + const processedValue = value + .map((v) => (v === null ? 'null' : v)) + .join(','); + params.append(key, processedValue); + } else { + params.append(key, value); + } + } + }); +}; + +// Build URLs with parameters +export const buildM3UUrl = (m3uParams, m3uUrl) => { + const params = new URLSearchParams(); + if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); + if (m3uParams.direct) params.append('direct', 'true'); + if (m3uParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', m3uParams.tvg_id_source); + if (m3uParams.output_format) + params.append('output_format', m3uParams.output_format); + if (m3uParams.output_profile) + params.append('output_profile', m3uParams.output_profile); + + const baseUrl = m3uUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildEPGUrl = (epgParams, epgUrl) => { + const params = new URLSearchParams(); + if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); + if (epgParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', epgParams.tvg_id_source); + if (epgParams.days > 0) params.append('days', epgParams.days.toString()); + if (epgParams.prev_days > 0) + params.append('prev_days', epgParams.prev_days.toString()); + + const baseUrl = epgUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildFetchParams = ({ + pagination, + sorting, + debouncedFilters, + selectedProfileId, + showDisabled, + showOnlyStreamlessChannels, + showOnlyStaleChannels, + showOnlyOverriddenChannels, + visibilityFilter, +}) => { + const params = new URLSearchParams(); + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + params.append('include_streams', 'true'); + + if (selectedProfileId !== '0') + params.append('channel_profile_id', selectedProfileId); + if (showDisabled) params.append('show_disabled', true); + if (showOnlyStreamlessChannels) params.append('only_streamless', true); + if (showOnlyStaleChannels) params.append('only_stale', true); + if (showOnlyOverriddenChannels) params.append('only_has_overrides', true); + if (visibilityFilter && visibilityFilter !== 'active') + params.append('visibility_filter', visibilityFilter); + if (sorting.length > 0) { + const { sortField, sortDirection } = getSortParam(sorting); + params.append('ordering', `${sortDirection}${sortField}`); + } + + applyDebouncedFilters(debouncedFilters, params); + return params; +}; + +export const buildHDHRUrl = (hdhrOutputProfileId, hdhrUrl) => { + if (!hdhrOutputProfileId) return hdhrUrl; + // Insert output_profile segment before the trailing slash (or at end) + const base = hdhrUrl.replace(/\/$/, ''); + return `${base}/output_profile/${hdhrOutputProfileId}`; +}; diff --git a/frontend/src/utils/tables/EPGsTableUtils.js b/frontend/src/utils/tables/EPGsTableUtils.js new file mode 100644 index 00000000..f409d1e2 --- /dev/null +++ b/frontend/src/utils/tables/EPGsTableUtils.js @@ -0,0 +1,55 @@ +import API from '../../api.js'; + +// Helper function to format status text +export const formatStatusText = (status) => { + if (!status) return 'Unknown'; + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +}; + +export const updateEpg = async (values, epg, isToggle) => { + return API.updateEPG({ ...values, id: epg.id }, isToggle); +}; +export const deleteEpg = (id) => { + return API.deleteEPG(id); +}; +export const refreshEpg = (id, force = false) => { + return API.refreshEPG(id, force); +}; + +export const getProgressLabel = (action) => { + switch (action) { + case 'downloading': + return 'Downloading'; + case 'extracting': + return 'Extracting'; + case 'parsing_channels': + return 'Parsing Channels'; + case 'parsing_programs': + return 'Parsing Programs'; + default: + return null; + } +}; + +export const getProgressInfo = (progress) => { + // Build additional info string from progress data + if (progress.message) { + return progress.message; + } else if ( + progress.processed !== undefined && + progress.channels !== undefined + ) { + return `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`; + } else if (progress.processed !== undefined && progress.total !== undefined) { + return `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`; + } + return null; +}; + +export const getSortedEpgs = (epgs, compareColumn, compareDesc) => { + return [...epgs].sort((a, b) => { + if (a[compareColumn] < b[compareColumn]) return compareDesc ? 1 : -1; + if (a[compareColumn] > b[compareColumn]) return compareDesc ? -1 : 1; + return 0; + }); +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/LogosTableUtils.js b/frontend/src/utils/tables/LogosTableUtils.js new file mode 100644 index 00000000..b80e0a32 --- /dev/null +++ b/frontend/src/utils/tables/LogosTableUtils.js @@ -0,0 +1,62 @@ +import API from '../../api.js'; + +export const getFilteredLogos = (logos, debouncedNameFilter, filtersUsed) => { + // Apply filters + let filteredLogos = Object.values(logos || {}); + + if (debouncedNameFilter) { + filteredLogos = filteredLogos.filter((logo) => + logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) + ); + } + + if (filtersUsed === 'used') { + filteredLogos = filteredLogos.filter((logo) => logo.is_used); + } else if (filtersUsed === 'unused') { + filteredLogos = filteredLogos.filter((logo) => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); +}; + +export const deleteLogo = (id, deleteFile) => { + return API.deleteLogo(id, deleteFile); +}; +export const deleteLogos = (ids, deleteFiles) => { + return API.deleteLogos(ids, deleteFiles); +}; +export const cleanupUnusedLogos = (deleteFiles) => { + return API.cleanupUnusedLogos(deleteFiles); +}; + +// Generate smart label based on usage +const categorizeUsage = (names) => { + const types = { channels: 0, movies: 0, series: 0 }; + + names.forEach((name) => { + if (name.startsWith('Channel:')) types.channels++; + else if (name.startsWith('Movie:')) types.movies++; + else if (name.startsWith('Series:')) types.series++; + }); + + return types; +}; + +// Analyze channel_names to categorize types +export const generateUsageLabel = (channelNames, channelCount) => { + const types = categorizeUsage(channelNames); + const typeCount = Object.values(types).filter( + (count) => count > 0 + ).length; + if (typeCount === 1) { + // Only one type - be specific + if (types.channels > 0) + return `${types.channels} ${types.channels !== 1 ? 'channels' : 'channel'}`; + if (types.movies > 0) + return `${types.movies} ${types.movies !== 1 ? 'movies' : 'movie'}`; + if (types.series > 0) return `${types.series} series`; + } else { + // Multiple types - use generic "items" + return `${channelCount} ${channelCount !== 1 ? 'items' : 'item'}`; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/M3UsTableUtils.js b/frontend/src/utils/tables/M3UsTableUtils.js new file mode 100644 index 00000000..9416d13f --- /dev/null +++ b/frontend/src/utils/tables/M3UsTableUtils.js @@ -0,0 +1,142 @@ +import API from '../../api.js'; +import { format } from '../dateTimeUtils.js'; +import { formatDuration } from '../dateTimeUtils.js'; +import { formatSpeed } from '../networkUtils.js'; + +export const refreshPlaylist = (id) => API.refreshPlaylist(id); + +export const getPlaylistAutoCreatedChannelsCount = (id) => + API.getPlaylistAutoCreatedChannelsCount(id); + +export const deletePlaylist = (id) => API.deletePlaylist(id); + +export const updatePlaylist = (values, playlist, isToggle = false) => + API.updatePlaylist({ ...values, id: playlist.id }, isToggle); + +export const formatStatusText = (status) => { + switch (status) { + case 'idle': return 'Idle'; + case 'fetching': return 'Fetching'; + case 'parsing': return 'Parsing'; + case 'error': return 'Error'; + case 'success': return 'Success'; + case 'pending_setup': return 'Pending Setup'; + default: + return status + ? status.charAt(0).toUpperCase() + status.slice(1) + : 'Unknown'; + } +}; + +export const getStatusColor = (status) => { + switch (status) { + case 'idle': return 'gray.5'; + case 'fetching': return 'blue.5'; + case 'parsing': return 'indigo.5'; + case 'error': return 'red.5'; + case 'success': return 'green.5'; + case 'pending_setup': return 'orange.5'; + default: return 'gray.5'; + } +}; + +export const getExpirationInfo = (daysLeft, earliest, fullDateFormat) => { + let color; + let label; + if (daysLeft < 0) { + color = 'red.7'; + label = 'Expired'; + } else if (daysLeft === 0) { + color = 'red.5'; + label = 'Expires today'; + } else if (daysLeft <= 7) { + color = 'orange.5'; + label = `${daysLeft}d left`; + } else if (daysLeft <= 30) { + color = 'yellow.5'; + label = `${daysLeft}d left`; + } else { + label = format(earliest, fullDateFormat); + } + return { color, label }; +}; + +export const getExpirationTooltip = (allExpirations, fullDateTimeFormat, label) => { + return allExpirations.length > 0 + ? allExpirations + .map( + (e) => + `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${ + !e.is_active ? ' (inactive)' : '' + }` + ) + .join('\n') + : label; +}; + +export const getSortedPlaylists = (playlists, compareColumn, compareDesc) => { + return playlists + .filter((playlist) => playlist.locked === false) + .sort((a, b) => { + const aVal = a[compareColumn]; + const bVal = b[compareColumn]; + + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + const comparison = + typeof aVal === 'string' + ? aVal.localeCompare(bVal) + : aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + + return compareDesc ? -comparison : comparison; + }); +}; + +export const getStatusContent = (data) => { + if (data.progress === 100) return null; + + switch (data.action) { + case 'initializing': + return { type: 'initializing' }; + + case 'downloading': + if (data.progress === 0) return { type: 'simple', label: 'Downloading...' }; + return { + type: 'downloading', + progress: parseInt(data.progress), + speed: formatSpeed(data.speed), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + }; + + case 'processing_groups': + if (data.progress === 0) return { type: 'simple', label: 'Processing groups...' }; + return { + type: 'groups', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + groupsProcessed: data.groups_processed, + }; + + case 'parsing': + if (data.progress === 0) return { type: 'simple', label: 'Parsing...' }; + return { + type: 'parsing', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + streamsProcessed: data.streams_processed, + }; + + default: + return data.status === 'error' + ? { type: 'error', error: data.error } + : { type: 'simple', label: `${data.action || 'Processing'}...` }; + } +}; + diff --git a/frontend/src/utils/tables/OutputProfilesTableUtils.js b/frontend/src/utils/tables/OutputProfilesTableUtils.js new file mode 100644 index 00000000..dc2761bd --- /dev/null +++ b/frontend/src/utils/tables/OutputProfilesTableUtils.js @@ -0,0 +1,6 @@ +import API from '../../api.js'; + +export const updateOutputProfile = (values) => API.updateOutputProfile(values); +export const deleteOutputProfile = async (id) => { + await API.deleteOutputProfile(id); +}; diff --git a/frontend/src/utils/tables/StreamsTableUtils.js b/frontend/src/utils/tables/StreamsTableUtils.js new file mode 100644 index 00000000..238446dc --- /dev/null +++ b/frontend/src/utils/tables/StreamsTableUtils.js @@ -0,0 +1,127 @@ +import API from '../../api.js'; + +export const addStreamsToChannel = (channelId, existingStreams, newStreams) => { + return API.addStreamsToChannel(channelId, existingStreams, newStreams); +}; +export const queryStreamsTable = (params) => { + return API.queryStreamsTable(params); +}; +export const getStreams = (streamIds) => { + return API.getStreams(streamIds); +}; +export const createChannelsFromStreamsAsync = ( + streamIds, + channelProfileIds, + startingChannelNumber +) => + API.createChannelsFromStreamsAsync( + streamIds, + channelProfileIds, + startingChannelNumber + ); +export const deleteStream = (id) => API.deleteStream(id); +export const deleteStreams = (ids) => { + return API.deleteStreams(ids); +}; +export const requeryStreams = () => { + return API.requeryStreams(); +}; +export const createChannelFromStream = (values) => + API.createChannelFromStream(values); +export const getAllStreamIds = (params) => { + return API.getAllStreamIds(params); +}; +export const getStreamFilterOptions = (params) => { + return API.getStreamFilterOptions(params); +}; + +export const getStatsTooltip = (stats) => { + // Build compact display (resolution + video codec) + const parts = []; + if (stats.resolution) { + // Convert "1920x1080" to "1080p" format + const height = stats.resolution.split('x')[1]; + if (height) parts.push(`${height}p`); + } + if (stats.video_codec) { + parts.push(stats.video_codec.toUpperCase()); + } + const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; + + // Build tooltip content with friendly labels + const tooltipLines = []; + if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`); + if (stats.video_codec) + tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`); + if (stats.video_bitrate) + tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); + if (stats.source_fps) + tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); + if (stats.audio_codec) + tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`); + if (stats.audio_channels) + tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); + if (stats.audio_bitrate) + tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); + + const tooltipContent = + tooltipLines.length > 0 + ? tooltipLines.join('\n') + : 'No source info available'; + return { compactDisplay, tooltipContent }; +}; + +export const appendFetchPageParams = (params, pagination, sorting) => { + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + + if (sorting.length > 0) { + const columnId = sorting[0].id; + const fieldMapping = { + name: 'name', + group: 'channel_group__name', + m3u: 'm3u_account__name', + tvg_id: 'tvg_id', + }; + const sortField = fieldMapping[columnId] || columnId; + const sortDirection = sorting[0].desc ? '-' : ''; + params.append('ordering', `${sortDirection}${sortField}`); + } +}; + +export const getChannelProfileIds = (profileIds, selectedProfileId) => { + // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise + if (profileIds) { + if (profileIds.includes('none')) { + return []; + } else if (profileIds.includes('all')) { + return null; + } else { + return profileIds.map((id) => parseInt(id)); + } + } else { + return selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; + } +}; + +export const getChannelNumberValue = (mode, startNumber) => { + return mode === 'provider' + ? null + : mode === 'auto' + ? 0 + : mode === 'highest' + ? -1 + : Number(startNumber); +}; + +export const getFilterParams = (debouncedFilters) => { + const params = new URLSearchParams(); + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (typeof value === 'boolean') { + if (value) params.append(key, 'true'); + } else if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } + }); + return params; +}; diff --git a/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js new file mode 100644 index 00000000..f65e7f2c --- /dev/null +++ b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as ChannelTableStreamsUtils from '../ChannelTableStreamsUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + getChannelStreamStats: vi.fn(), + reorderChannelStreams: vi.fn(), + }, +})); + +vi.mock('../../networkUtils.js', () => ({ + formatBytes: vi.fn((bytes) => `${bytes} B`), +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + formatDuration: vi.fn((seconds) => `duration-${seconds}`), +})); + +import API from '../../../api.js'; +import { formatBytes } from '../../networkUtils.js'; +import { formatDuration } from '../../dateTimeUtils.js'; + +describe('ChannelTableStreamsUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── categorizeStreamStats ─────────────────────────────────────────────────── + describe('categorizeStreamStats', () => { + it('returns empty categories for null input', () => { + expect(ChannelTableStreamsUtils.categorizeStreamStats(null)).toEqual({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + + it('returns empty categories for undefined input', () => { + expect(ChannelTableStreamsUtils.categorizeStreamStats(undefined)).toEqual( + { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + } + ); + }); + + it('categorizes basic fields correctly', () => { + const stats = { + resolution: '1920x1080', + video_codec: 'h264', + source_fps: 30, + audio_codec: 'aac', + audio_channels: 2, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.basic).toEqual(stats); + expect(result.video).toEqual({}); + expect(result.audio).toEqual({}); + expect(result.technical).toEqual({}); + expect(result.other).toEqual({}); + }); + + it('categorizes video fields correctly', () => { + const stats = { + video_bitrate: 5000, + pixel_format: 'yuv420p', + width: 1920, + height: 1080, + aspect_ratio: '16:9', + frame_rate: 29.97, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.video).toEqual(stats); + expect(result.basic).toEqual({}); + }); + + it('categorizes audio fields correctly', () => { + const stats = { + audio_bitrate: 192, + sample_rate: 48000, + audio_format: 'flac', + audio_channels_layout: 'stereo', + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.audio).toEqual(stats); + }); + + it('categorizes technical fields correctly', () => { + const stats = { + stream_type: 'video', + container_format: 'mpegts', + duration: 3600, + file_size: 1024000, + ffmpeg_output_bitrate: 8000, + input_bitrate: 7500, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.technical).toEqual(stats); + }); + + it('places unknown fields in other', () => { + const stats = { custom_field: 'value', another_field: 42 }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.other).toEqual(stats); + }); + + it('handles mixed fields across categories', () => { + const stats = { + resolution: '1080p', + audio_bitrate: 192, + duration: 7200, + unknown_key: 'test', + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.basic.resolution).toBe('1080p'); + expect(result.audio.audio_bitrate).toBe(192); + expect(result.technical.duration).toBe(7200); + expect(result.other.unknown_key).toBe('test'); + }); + + it('returns empty categories for empty stats object', () => { + const result = ChannelTableStreamsUtils.categorizeStreamStats({}); + expect(result).toEqual({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + }); + + // ── formatStatValue ───────────────────────────────────────────────────────── + describe('formatStatValue', () => { + it('returns "N/A" for null value', () => { + expect(ChannelTableStreamsUtils.formatStatValue('resolution', null)).toBe( + 'N/A' + ); + }); + + it('returns "N/A" for undefined value', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('resolution', undefined) + ).toBe('N/A'); + }); + + it('formats video_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('video_bitrate', 5000) + ).toBe('5000 kbps'); + }); + + it('formats audio_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('audio_bitrate', 192) + ).toBe('192 kbps'); + }); + + it('formats ffmpeg_output_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('ffmpeg_output_bitrate', 8000) + ).toBe('8000 kbps'); + }); + + it('formats source_fps with fps', () => { + expect(ChannelTableStreamsUtils.formatStatValue('source_fps', 30)).toBe( + '30 fps' + ); + }); + + it('formats frame_rate with fps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('frame_rate', 29.97) + ).toBe('29.97 fps'); + }); + + it('formats sample_rate with Hz', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('sample_rate', 48000) + ).toBe('48000 Hz'); + }); + + it('formats file_size using formatBytes when numeric', () => { + vi.mocked(formatBytes).mockReturnValue('1.0 MB'); + expect( + ChannelTableStreamsUtils.formatStatValue('file_size', 1048576) + ).toBe('1.0 MB'); + expect(formatBytes).toHaveBeenCalledWith(1048576); + }); + + it('returns raw value for file_size when not numeric', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('file_size', 'unknown') + ).toBe('unknown'); + expect(formatBytes).not.toHaveBeenCalled(); + }); + + it('formats duration using formatDuration with alwaysShowHours when numeric', () => { + vi.mocked(formatDuration).mockReturnValue('01:00:00'); + expect(ChannelTableStreamsUtils.formatStatValue('duration', 3600)).toBe( + '01:00:00' + ); + expect(formatDuration).toHaveBeenCalledWith(3600, { + alwaysShowHours: true, + }); + }); + + it('returns raw value for duration when not numeric', () => { + expect(ChannelTableStreamsUtils.formatStatValue('duration', 'live')).toBe( + 'live' + ); + expect(formatDuration).not.toHaveBeenCalled(); + }); + + it('converts default values to string', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('resolution', '1920x1080') + ).toBe('1920x1080'); + }); + + it('converts numeric default values to string', () => { + expect(ChannelTableStreamsUtils.formatStatValue('width', 1920)).toBe( + '1920' + ); + }); + }); + + // ── formatStatKey ─────────────────────────────────────────────────────────── + describe('formatStatKey', () => { + it('replaces underscores with spaces and title-cases each word', () => { + expect(ChannelTableStreamsUtils.formatStatKey('video_bitrate')).toBe( + 'Video Bitrate' + ); + }); + + it('handles single word keys', () => { + expect(ChannelTableStreamsUtils.formatStatKey('resolution')).toBe( + 'Resolution' + ); + }); + + it('handles multi-word keys', () => { + expect( + ChannelTableStreamsUtils.formatStatKey('audio_channels_layout') + ).toBe('Audio Channels Layout'); + }); + + it('handles already-capitalized keys', () => { + expect(ChannelTableStreamsUtils.formatStatKey('FPS')).toBe('FPS'); + }); + }); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('getChannelStreamStats', () => { + it('calls API.getChannelStreamStats with correct args', () => { + const mockReturn = Promise.resolve({ data: [] }); + vi.mocked(API.getChannelStreamStats).mockReturnValue(mockReturn); + const result = ChannelTableStreamsUtils.getChannelStreamStats( + 'ch-1', + '2024-01-01', + [1, 2] + ); + expect(API.getChannelStreamStats).toHaveBeenCalledWith( + 'ch-1', + '2024-01-01', + [1, 2] + ); + expect(result).toBe(mockReturn); + }); + }); + + describe('reorderChannelStreams', () => { + it('calls API.reorderChannelStreams with correct args', () => { + const mockReturn = Promise.resolve(undefined); + vi.mocked(API.reorderChannelStreams).mockReturnValue(mockReturn); + const result = ChannelTableStreamsUtils.reorderChannelStreams( + 'ch-1', + [3, 1, 2] + ); + expect(API.reorderChannelStreams).toHaveBeenCalledWith('ch-1', [3, 1, 2]); + expect(result).toBe(mockReturn); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js new file mode 100644 index 00000000..c4fc9e3e --- /dev/null +++ b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js @@ -0,0 +1,611 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as ChannelsTableUtils from '../ChannelsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../forms/ChannelUtils.js', () => ({ + normalizeFieldValue: vi.fn((field, value) => { + if (value === '' || value === null || value === undefined) return null; + if (field === 'channel_number') return parseFloat(value); + if (['channel_group_id', 'logo_id', 'epg_data_id', 'stream_profile_id'].includes(field)) { + return parseInt(value, 10); + } + return value; + }), + OVERRIDABLE_FIELDS: [ + 'name', + 'channel_number', + 'channel_group_id', + 'logo_id', + 'tvg_id', + 'tvc_guide_stationid', + 'epg_data_id', + 'stream_profile_id', + ], +})); + +vi.mock('../../../api.js', () => ({ + default: { + reorderChannel: vi.fn(), + deleteChannel: vi.fn(), + deleteChannels: vi.fn(), + queryChannels: vi.fn(), + getAllChannelIds: vi.fn(), + updateProfileChannels: vi.fn(), + updateProfileChannel: vi.fn(), + addChannelProfile: vi.fn(), + deleteChannelProfile: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('ChannelsTableUtils', () => { + // ── buildInlinePatch ──────────────────────────────────────────────────────── + describe('buildInlinePatch', () => { + describe('manual channel (auto_created = false)', () => { + const row = { id: 1, auto_created: false, name: 'CNN' }; + + it('returns direct patch for a string field', () => { + expect(ChannelsTableUtils.buildInlinePatch(row, 'name', 'BBC')).toEqual( + { + id: 1, + name: 'BBC', + } + ); + }); + + it('normalizes empty string to null', () => { + expect(ChannelsTableUtils.buildInlinePatch(row, 'name', '')).toEqual({ + id: 1, + name: null, + }); + }); + + it('normalizes undefined to null', () => { + expect( + ChannelsTableUtils.buildInlinePatch(row, 'name', undefined) + ).toEqual({ + id: 1, + name: null, + }); + }); + + it('passes through numeric values', () => { + expect( + ChannelsTableUtils.buildInlinePatch(row, 'channel_number', 5) + ).toEqual({ + id: 1, + channel_number: 5, + }); + }); + }); + + describe('auto-synced channel (auto_created = true)', () => { + const row = { + id: 2, + auto_created: true, + name: 'ESPN', + channel_number: 10, + epg_data_id: 99, + }; + + it('returns override patch when value differs from provider', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'name', + 'ESPN HD' + ); + expect(result).toEqual({ + id: 2, + override: { name: 'ESPN HD' }, + }); + }); + + it('clears override when new value matches provider value', () => { + const result = ChannelsTableUtils.buildInlinePatch(row, 'name', 'ESPN'); + expect(result).toEqual({ + id: 2, + override: { name: null }, + }); + }); + + it('returns override patch for channel_number field', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'channel_number', + 20 + ); + expect(result).toEqual({ + id: 2, + override: { channel_number: 20 }, + }); + }); + + it('clears override for channel_number when value matches provider', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'channel_number', + 10 + ); + expect(result).toEqual({ + id: 2, + override: { channel_number: null }, + }); + }); + + it('uses direct patch for non-overridable field on auto-synced channel', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'some_other_field', + 'value' + ); + expect(result).toEqual({ + id: 2, + some_other_field: 'value', + }); + }); + }); + }); + + // ── getEpgOptions ─────────────────────────────────────────────────────────── + describe('getEpgOptions', () => { + const epgs = { + 1: { id: 1, name: 'EPG Alpha' }, + 2: { id: 2, name: 'EPG Beta' }, + }; + + const tvgsById = { + 10: { id: 10, tvg_id: 'cnn', name: 'CNN', epg_source: 1 }, + 11: { id: 11, tvg_id: 'bbc', name: 'BBC', epg_source: 2 }, + 12: { id: 12, tvg_id: 'espn', name: 'ESPN', epg_source: 1 }, + }; + + it('includes "Not Assigned" as the first option', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + expect(options[0]).toEqual({ value: 'null', label: 'Not Assigned' }); + }); + + it('returns an option for each tvg entry', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + expect(options).toHaveLength(4); // 1 null + 3 tvgs + }); + + it('formats label as "EPG Name | tvg_id | tvg name" when all present and name differs from tvg_id', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + const cnn = options.find((o) => o.value === '10'); + expect(cnn?.label).toBe('EPG Alpha | cnn | CNN'); + }); + + it('omits tvg name from label when name equals tvg_id', () => { + const tvgs = { + 10: { id: 10, tvg_id: 'CNN', name: 'CNN', epg_source: 1 }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, epgs); + const opt = options.find((o) => o.value === '10'); + expect(opt?.label).toBe('EPG Alpha | CNN'); + }); + + it('uses tvg name as label when no epg_source and no tvg_id', () => { + const tvgs = { + 20: { id: 20, tvg_id: null, name: 'Standalone', epg_source: null }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, {}); + const opt = options.find((o) => o.value === '20'); + expect(opt?.label).toBe('Standalone'); + }); + + it('falls back to "ID: {id}" when no name and no tvg_id', () => { + const tvgs = { + 30: { id: 30, tvg_id: null, name: null, epg_source: null }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, {}); + const opt = options.find((o) => o.value === '30'); + expect(opt?.label).toBe('ID: 30'); + }); + + it('sorts options by EPG source name then tvg_id', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + // EPG Alpha entries (cnn, espn) should come before EPG Beta (bbc) + const labels = options.slice(1).map((o) => o.label); + const alphaCnn = labels.findIndex((l) => l.includes('cnn')); + const alphaEspn = labels.findIndex((l) => l.includes('espn')); + const betaBbc = labels.findIndex((l) => l.includes('bbc')); + expect(alphaCnn).toBeLessThan(betaBbc); + expect(alphaEspn).toBeLessThan(betaBbc); + }); + + it('returns only the null option for empty tvgsById', () => { + const options = ChannelsTableUtils.getEpgOptions({}, epgs); + expect(options).toHaveLength(1); + expect(options[0].value).toBe('null'); + }); + }); + + // ── getLogoOptions ────────────────────────────────────────────────────────── + describe('getLogoOptions', () => { + const logos = { + 1: { id: 1, name: 'ABC Logo' }, + 2: { id: 2, name: 'NBC Logo' }, + 3: { id: 3, name: null }, + }; + + it('includes "Default" as the first option', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + expect(options[0]).toEqual({ + value: 'null', + label: 'Default', + logo: null, + }); + }); + + it('returns an option for each logo', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + expect(options).toHaveLength(4); // 1 default + 3 logos + }); + + it('uses logo name as label', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const abc = options.find((o) => o.value === '1'); + expect(abc?.label).toBe('ABC Logo'); + expect(abc?.logo).toEqual({ id: 1, name: 'ABC Logo' }); + }); + + it('falls back to "Logo {id}" when name is null', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const noName = options.find((o) => o.value === '3'); + expect(noName?.label).toBe('Logo 3'); + }); + + it('sorts logos by name', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const names = options.slice(2).map((o) => o.label); + expect(names[0]).toBe('ABC Logo'); + expect(names[1]).toBe('NBC Logo'); + }); + + it('returns only the default option for empty logos', () => { + const options = ChannelsTableUtils.getLogoOptions({}); + expect(options).toHaveLength(1); + }); + }); + + // ── buildM3UUrl ───────────────────────────────────────────────────────────── + describe('buildM3UUrl', () => { + const baseUrl = 'http://localhost/output/m3u'; + const defaults = { + cachedlogos: true, + direct: false, + tvg_id_source: 'channel_number', + output_format: '', + output_profile: '', + }; + + it('returns base URL with no params when all defaults', () => { + expect(ChannelsTableUtils.buildM3UUrl(defaults, baseUrl)).toBe(baseUrl); + }); + + it('appends cachedlogos=false when disabled', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, cachedlogos: false }, + baseUrl + ); + expect(result).toContain('cachedlogos=false'); + }); + + it('appends direct=true when enabled', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, direct: true }, + baseUrl + ); + expect(result).toContain('direct=true'); + }); + + it('appends tvg_id_source when not channel_number', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, tvg_id_source: 'tvg_id' }, + baseUrl + ); + expect(result).toContain('tvg_id_source=tvg_id'); + }); + + it('does not append tvg_id_source when channel_number', () => { + const result = ChannelsTableUtils.buildM3UUrl(defaults, baseUrl); + expect(result).not.toContain('tvg_id_source'); + }); + + it('appends output_format when set', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, output_format: 'mpegts' }, + baseUrl + ); + expect(result).toContain('output_format=mpegts'); + }); + + it('appends output_profile when set', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, output_profile: '3' }, + baseUrl + ); + expect(result).toContain('output_profile=3'); + }); + }); + + // ── buildEPGUrl ───────────────────────────────────────────────────────────── + describe('buildEPGUrl', () => { + const baseUrl = 'http://localhost/output/epg'; + const defaults = { + cachedlogos: true, + tvg_id_source: 'channel_number', + days: 0, + prev_days: 0, + }; + + it('returns base URL with no params when all defaults', () => { + expect(ChannelsTableUtils.buildEPGUrl(defaults, baseUrl)).toBe(baseUrl); + }); + + it('appends cachedlogos=false when disabled', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, cachedlogos: false }, + baseUrl + ); + expect(result).toContain('cachedlogos=false'); + }); + + it('appends tvg_id_source when not channel_number', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, tvg_id_source: 'gracenote' }, + baseUrl + ); + expect(result).toContain('tvg_id_source=gracenote'); + }); + + it('appends days when > 0', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, days: 7 }, + baseUrl + ); + expect(result).toContain('days=7'); + }); + + it('does not append days when 0', () => { + const result = ChannelsTableUtils.buildEPGUrl(defaults, baseUrl); + expect(result).not.toContain('days'); + }); + + it('appends prev_days when > 0', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, prev_days: 3 }, + baseUrl + ); + expect(result).toContain('prev_days=3'); + }); + }); + + // ── buildHDHRUrl ──────────────────────────────────────────────────────────── + describe('buildHDHRUrl', () => { + it('returns hdhrUrl unchanged when no output profile', () => { + expect(ChannelsTableUtils.buildHDHRUrl('', 'http://localhost/hdhr')).toBe( + 'http://localhost/hdhr' + ); + }); + + it('appends output_profile segment when profile id provided', () => { + expect( + ChannelsTableUtils.buildHDHRUrl('2', 'http://localhost/hdhr') + ).toBe('http://localhost/hdhr/output_profile/2'); + }); + + it('strips trailing slash before appending', () => { + expect( + ChannelsTableUtils.buildHDHRUrl('1', 'http://localhost/hdhr/') + ).toBe('http://localhost/hdhr/output_profile/1'); + }); + }); + + // ── buildFetchParams ──────────────────────────────────────────────────────── + describe('buildFetchParams', () => { + const defaults = { + pagination: { pageIndex: 0, pageSize: 50 }, + sorting: [], + debouncedFilters: {}, + selectedProfileId: '0', + showDisabled: false, + showOnlyStreamlessChannels: false, + showOnlyStaleChannels: false, + showOnlyOverriddenChannels: false, + visibilityFilter: 'active', + }; + + it('always includes page, page_size, and include_streams', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('page')).toBe('1'); + expect(params.get('page_size')).toBe('50'); + expect(params.get('include_streams')).toBe('true'); + }); + + it('increments page by 1 from pageIndex', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + pagination: { pageIndex: 2, pageSize: 25 }, + }); + expect(params.get('page')).toBe('3'); + expect(params.get('page_size')).toBe('25'); + }); + + it('does not include channel_profile_id when selectedProfileId is "0"', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('channel_profile_id')).toBeNull(); + }); + + it('includes channel_profile_id when selectedProfileId is not "0"', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + selectedProfileId: '3', + }); + expect(params.get('channel_profile_id')).toBe('3'); + }); + + it('includes show_disabled when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showDisabled: true, + }); + expect(params.get('show_disabled')).toBe('true'); + }); + + it('includes only_streamless when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyStreamlessChannels: true, + }); + expect(params.get('only_streamless')).toBe('true'); + }); + + it('includes only_stale when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyStaleChannels: true, + }); + expect(params.get('only_stale')).toBe('true'); + }); + + it('includes only_has_overrides when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyOverriddenChannels: true, + }); + expect(params.get('only_has_overrides')).toBe('true'); + }); + + it('does not include visibility_filter when "active"', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('visibility_filter')).toBeNull(); + }); + + it('includes visibility_filter when not "active"', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + visibilityFilter: 'hidden', + }); + expect(params.get('visibility_filter')).toBe('hidden'); + }); + + it('includes ordering with ascending sort', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'name', desc: false }], + }); + expect(params.get('ordering')).toBe('name'); + }); + + it('includes ordering with descending sort', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'name', desc: true }], + }); + expect(params.get('ordering')).toBe('-name'); + }); + + it('maps channel_group sort field to channel_group__name', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'channel_group', desc: false }], + }); + expect(params.get('ordering')).toBe('channel_group__name'); + }); + + it('maps epg sort field to epg_data__name', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'epg', desc: false }], + }); + expect(params.get('ordering')).toBe('epg_data__name'); + }); + + it('applies string debounced filters', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { name: 'CNN' }, + }); + expect(params.get('name')).toBe('CNN'); + }); + + it('applies array debounced filters joined by comma', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { channel_group: ['News', 'Sports'] }, + }); + expect(params.get('channel_group')).toBe('News,Sports'); + }); + + it('converts null values in array filters to "null" string', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { epg: [null, 'SomeEPG'] }, + }); + expect(params.get('epg')).toBe('null,SomeEPG'); + }); + + it('skips falsy debounced filter values', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { name: '' }, + }); + expect(params.get('name')).toBeNull(); + }); + }); + + // ── API wrapper functions ─────────────────────────────────────────────────── + describe('API wrappers', () => { + beforeEach(() => vi.clearAllMocks()); + + it('reorderChannel calls API.reorderChannel', () => { + ChannelsTableUtils.reorderChannel(1, 2); + expect(API.reorderChannel).toHaveBeenCalledWith(1, 2); + }); + + it('deleteChannel calls API.deleteChannel', () => { + ChannelsTableUtils.deleteChannel(5); + expect(API.deleteChannel).toHaveBeenCalledWith(5); + }); + + it('deleteChannels calls API.deleteChannels', () => { + ChannelsTableUtils.deleteChannels([1, 2, 3]); + expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3]); + }); + + it('queryChannels calls API.queryChannels', () => { + const params = new URLSearchParams(); + ChannelsTableUtils.queryChannels(params); + expect(API.queryChannels).toHaveBeenCalledWith(params); + }); + + it('getAllChannelIds calls API.getAllChannelIds', () => { + const params = new URLSearchParams(); + ChannelsTableUtils.getAllChannelIds(params); + expect(API.getAllChannelIds).toHaveBeenCalledWith(params); + }); + + it('updateProfileChannels calls API.updateProfileChannels', () => { + ChannelsTableUtils.updateProfileChannels([1, 2], '3', true); + expect(API.updateProfileChannels).toHaveBeenCalledWith([1, 2], '3', true); + }); + + it('updateProfileChannel calls API.updateProfileChannel', () => { + ChannelsTableUtils.updateProfileChannel(1, '3', false); + expect(API.updateProfileChannel).toHaveBeenCalledWith(1, '3', false); + }); + + it('addChannelProfile calls API.addChannelProfile', () => { + const values = { name: 'Test Profile' }; + ChannelsTableUtils.addChannelProfile(values); + expect(API.addChannelProfile).toHaveBeenCalledWith(values); + }); + + it('deleteChannelProfile calls API.deleteChannelProfile', () => { + ChannelsTableUtils.deleteChannelProfile(4); + expect(API.deleteChannelProfile).toHaveBeenCalledWith(4); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js new file mode 100644 index 00000000..7dd0b8dc --- /dev/null +++ b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as EPGsTableUtils from '../EPGsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + updateEPG: vi.fn(), + deleteEPG: vi.fn(), + refreshEPG: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('EPGsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── formatStatusText ──────────────────────────────────────────────────────── + describe('formatStatusText', () => { + it('returns "Unknown" for null', () => { + expect(EPGsTableUtils.formatStatusText(null)).toBe('Unknown'); + }); + + it('returns "Unknown" for undefined', () => { + expect(EPGsTableUtils.formatStatusText(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(EPGsTableUtils.formatStatusText('')).toBe('Unknown'); + }); + + it('capitalizes first letter and lowercases the rest', () => { + expect(EPGsTableUtils.formatStatusText('idle')).toBe('Idle'); + expect(EPGsTableUtils.formatStatusText('success')).toBe('Success'); + expect(EPGsTableUtils.formatStatusText('error')).toBe('Error'); + }); + + it('handles already-uppercase input', () => { + expect(EPGsTableUtils.formatStatusText('FETCHING')).toBe('Fetching'); + }); + + it('handles mixed case input', () => { + expect(EPGsTableUtils.formatStatusText('pArSiNg')).toBe('Parsing'); + }); + }); + + // ── updateEpg ─────────────────────────────────────────────────────────────── + describe('updateEpg', () => { + it('calls API.updateEPG with merged values and id', async () => { + API.updateEPG.mockResolvedValue({ id: 1 }); + const epg = { id: 1, name: 'Old Name' }; + await EPGsTableUtils.updateEpg({ is_active: false }, epg, false); + expect(API.updateEPG).toHaveBeenCalledWith( + { is_active: false, id: 1 }, + false + ); + }); + + it('passes isToggle=true to API.updateEPG', async () => { + API.updateEPG.mockResolvedValue({ id: 2 }); + const epg = { id: 2 }; + await EPGsTableUtils.updateEpg({ is_active: true }, epg, true); + expect(API.updateEPG).toHaveBeenCalledWith( + { is_active: true, id: 2 }, + true + ); + }); + + it('returns the API response', async () => { + const mockResponse = { id: 3, name: 'Updated' }; + API.updateEPG.mockResolvedValue(mockResponse); + const result = await EPGsTableUtils.updateEpg({}, { id: 3 }, false); + expect(result).toBe(mockResponse); + }); + + it('propagates API errors', async () => { + API.updateEPG.mockRejectedValue(new Error('Network error')); + await expect( + EPGsTableUtils.updateEpg({}, { id: 1 }, false) + ).rejects.toThrow('Network error'); + }); + }); + + // ── deleteEpg ─────────────────────────────────────────────────────────────── + describe('deleteEpg', () => { + it('calls API.deleteEPG with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteEPG.mockReturnValue(mockReturn); + const result = EPGsTableUtils.deleteEpg(5); + expect(API.deleteEPG).toHaveBeenCalledWith(5); + expect(result).toBe(mockReturn); + }); + }); + + // ── refreshEpg ────────────────────────────────────────────────────────────── + describe('refreshEpg', () => { + it('calls API.refreshEPG with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.refreshEPG.mockReturnValue(mockReturn); + const result = EPGsTableUtils.refreshEpg(7); + expect(API.refreshEPG).toHaveBeenCalledWith(7, expect.anything()); + expect(result).toBe(mockReturn); + }); + }); + + // ── getProgressLabel ──────────────────────────────────────────────────────── + describe('getProgressLabel', () => { + it('returns "Downloading" for downloading action', () => { + expect(EPGsTableUtils.getProgressLabel('downloading')).toBe( + 'Downloading' + ); + }); + + it('returns "Extracting" for extracting action', () => { + expect(EPGsTableUtils.getProgressLabel('extracting')).toBe('Extracting'); + }); + + it('returns "Parsing Channels" for parsing_channels action', () => { + expect(EPGsTableUtils.getProgressLabel('parsing_channels')).toBe( + 'Parsing Channels' + ); + }); + + it('returns "Parsing Programs" for parsing_programs action', () => { + expect(EPGsTableUtils.getProgressLabel('parsing_programs')).toBe( + 'Parsing Programs' + ); + }); + + it('returns null for unknown action', () => { + expect(EPGsTableUtils.getProgressLabel('unknown_action')).toBeNull(); + }); + + it('returns null for null action', () => { + expect(EPGsTableUtils.getProgressLabel(null)).toBeNull(); + }); + + it('returns null for undefined action', () => { + expect(EPGsTableUtils.getProgressLabel(undefined)).toBeNull(); + }); + }); + + // ── getProgressInfo ───────────────────────────────────────────────────────── + describe('getProgressInfo', () => { + it('returns message when progress.message is set', () => { + expect( + EPGsTableUtils.getProgressInfo({ message: 'Loading data...' }) + ).toBe('Loading data...'); + }); + + it('returns programs/channels string when processed and channels are set', () => { + expect( + EPGsTableUtils.getProgressInfo({ processed: 1500, channels: 42 }) + ).toBe('1,500 programs for 42 channels'); + }); + + it('prefers message over processed/channels', () => { + expect( + EPGsTableUtils.getProgressInfo({ + message: 'Custom message', + processed: 100, + channels: 5, + }) + ).toBe('Custom message'); + }); + + it('returns processed/total string when processed and total are set without channels', () => { + expect( + EPGsTableUtils.getProgressInfo({ processed: 250, total: 1000 }) + ).toBe('250 / 1,000'); + }); + + it('returns null when no relevant fields are present', () => { + expect(EPGsTableUtils.getProgressInfo({})).toBeNull(); + }); + + it('returns null when only processed is set without channels or total', () => { + expect(EPGsTableUtils.getProgressInfo({ processed: 100 })).toBeNull(); + }); + + it('formats large numbers with locale separators', () => { + const result = EPGsTableUtils.getProgressInfo({ + processed: 1000000, + total: 5000000, + }); + expect(result).toBe('1,000,000 / 5,000,000'); + }); + }); + + // ── getSortedEpgs ─────────────────────────────────────────────────────────── + describe('getSortedEpgs', () => { + const epgs = [ + { id: 1, name: 'Zebra EPG', is_active: true }, + { id: 2, name: 'Alpha EPG', is_active: false }, + { id: 3, name: 'Middle EPG', is_active: true }, + ]; + + it('sorts ascending when compareDesc is false', () => { + const result = EPGsTableUtils.getSortedEpgs( + [...epgs], + 'is_active', + false + ); + // active=false comes first in ascending (false < true) + expect(result[0].id).toBe(2); + }); + + it('sorts descending when compareDesc is true', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'is_active', true); + // active=true comes first in descending + expect(result[0].is_active).toBe(true); + }); + + it('returns items in original relative order when values are equal', () => { + const items = [ + { id: 1, name: 'Same', is_active: true }, + { id: 2, name: 'Same', is_active: true }, + ]; + const result = EPGsTableUtils.getSortedEpgs([...items], 'name', false); + expect(result[0].id).toBe(1); + expect(result[1].id).toBe(2); + }); + + it('sorts by name field ascending', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', false); + expect(result[0].name).toBe('Alpha EPG'); + }); + + it('sorts by name field descending', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', true); + expect(result[0].name).toBe('Zebra EPG'); + }); + + it('returns an empty array for empty input', () => { + expect(EPGsTableUtils.getSortedEpgs([], 'name', false)).toEqual([]); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js new file mode 100644 index 00000000..332f198a --- /dev/null +++ b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as LogosTableUtils from '../LogosTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + deleteLogo: vi.fn(), + deleteLogos: vi.fn(), + cleanupUnusedLogos: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('LogosTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── getFilteredLogos ──────────────────────────────────────────────────────── + describe('getFilteredLogos', () => { + const logos = { + 1: { id: 1, name: 'ABC Logo', is_used: true }, + 2: { id: 2, name: 'NBC Logo', is_used: false }, + 3: { id: 3, name: 'CBS Logo', is_used: true }, + 4: { id: 4, name: 'abc sports', is_used: false }, + }; + + it('returns all logos sorted by id when no filters applied', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result.map((l) => l.id)).toEqual([1, 2, 3, 4]); + }); + + it('returns empty array for null logos', () => { + expect(LogosTableUtils.getFilteredLogos(null, '', 'all')).toEqual([]); + }); + + it('returns empty array for undefined logos', () => { + expect(LogosTableUtils.getFilteredLogos(undefined, '', 'all')).toEqual( + [] + ); + }); + + it('filters by name case-insensitively', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'all'); + expect(result).toHaveLength(2); + expect(result.map((l) => l.id)).toEqual([1, 4]); + }); + + it('filters by exact name match', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'NBC Logo', 'all'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + + it('returns empty array when name filter matches nothing', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'xyz', 'all'); + expect(result).toHaveLength(0); + }); + + it('filters to used logos only when filtersUsed is "used"', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'used'); + expect(result.every((l) => l.is_used)).toBe(true); + expect(result).toHaveLength(2); + }); + + it('filters to unused logos only when filtersUsed is "unused"', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'unused'); + expect(result.every((l) => !l.is_used)).toBe(true); + expect(result).toHaveLength(2); + }); + + it('applies name filter and used filter together', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'used'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it('applies name filter and unused filter together', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'unused'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(4); + }); + + it('sorts results by id ascending', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + for (let i = 1; i < result.length; i++) { + expect(result[i].id).toBeGreaterThan(result[i - 1].id); + } + }); + + it('returns empty array when logos object is empty', () => { + expect(LogosTableUtils.getFilteredLogos({}, '', 'all')).toEqual([]); + }); + + it('does not filter when debouncedNameFilter is empty string', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + }); + + it('does not apply usage filter for unrecognized filtersUsed value', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + }); + }); + + // ── deleteLogo ────────────────────────────────────────────────────────────── + describe('deleteLogo', () => { + it('calls API.deleteLogo with id and deleteFile', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteLogo.mockReturnValue(mockReturn); + const result = LogosTableUtils.deleteLogo(5, true); + expect(API.deleteLogo).toHaveBeenCalledWith(5, true); + expect(result).toBe(mockReturn); + }); + + it('passes deleteFile=false correctly', () => { + LogosTableUtils.deleteLogo(3, false); + expect(API.deleteLogo).toHaveBeenCalledWith(3, false); + }); + }); + + // ── deleteLogos ───────────────────────────────────────────────────────────── + describe('deleteLogos', () => { + it('calls API.deleteLogos with ids and deleteFiles', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteLogos.mockReturnValue(mockReturn); + const result = LogosTableUtils.deleteLogos([1, 2, 3], true); + expect(API.deleteLogos).toHaveBeenCalledWith([1, 2, 3], true); + expect(result).toBe(mockReturn); + }); + + it('passes deleteFiles=false correctly', () => { + LogosTableUtils.deleteLogos([4, 5], false); + expect(API.deleteLogos).toHaveBeenCalledWith([4, 5], false); + }); + }); + + // ── cleanupUnusedLogos ────────────────────────────────────────────────────── + describe('cleanupUnusedLogos', () => { + it('calls API.cleanupUnusedLogos with deleteFiles=true', () => { + const mockReturn = Promise.resolve(undefined); + API.cleanupUnusedLogos.mockReturnValue(mockReturn); + const result = LogosTableUtils.cleanupUnusedLogos(true); + expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(true); + expect(result).toBe(mockReturn); + }); + + it('calls API.cleanupUnusedLogos with deleteFiles=false', () => { + LogosTableUtils.cleanupUnusedLogos(false); + expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(false); + }); + }); + + // ── generateUsageLabel ────────────────────────────────────────────────────── + describe('generateUsageLabel', () => { + describe('single type — channels only', () => { + it('returns singular "channel" for 1 channel', () => { + const names = ['Channel: HBO']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel'); + }); + + it('returns plural "channels" for multiple channels', () => { + const names = ['Channel: HBO', 'Channel: CNN', 'Channel: ESPN']; + expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 channels'); + }); + }); + + describe('single type — movies only', () => { + it('returns singular "movie" for 1 movie', () => { + const names = ['Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 movie'); + }); + + it('returns plural "movies" for multiple movies', () => { + const names = ['Movie: Inception', 'Movie: Interstellar']; + expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 movies'); + }); + }); + + describe('single type — series only', () => { + it('returns "series" for 1 series', () => { + const names = ['Series: Breaking Bad']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 series'); + }); + + it('returns "series" for multiple series', () => { + const names = ['Series: Breaking Bad', 'Series: The Wire']; + expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 series'); + }); + }); + + describe('multiple types — generic items', () => { + it('returns singular "item" when channelCount is 1', () => { + const names = ['Channel: HBO', 'Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 item'); + }); + + it('returns plural "items" when channelCount > 1', () => { + const names = ['Channel: HBO', 'Movie: Inception', 'Series: Lost']; + expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 items'); + }); + + it('uses channelCount not array length for generic label', () => { + const names = ['Channel: HBO', 'Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 5)).toBe('5 items'); + }); + }); + + describe('edge cases', () => { + it('returns "0 items" for empty names array (no types match)', () => { + expect(LogosTableUtils.generateUsageLabel([], 0)).toBe('0 items'); + }); + + it('ignores unrecognized name prefixes', () => { + const names = ['Unknown: Foo', 'Channel: HBO']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel'); + }); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js new file mode 100644 index 00000000..de63e0e9 --- /dev/null +++ b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js @@ -0,0 +1,474 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as M3UsTableUtils from '../M3UsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + refreshPlaylist: vi.fn(), + getPlaylistAutoCreatedChannelsCount: vi.fn(), + deletePlaylist: vi.fn(), + updatePlaylist: vi.fn(), + }, +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + format: vi.fn((val, fmt) => `formatted:${fmt}`), + formatDuration: vi.fn((seconds) => `duration:${seconds}`), +})); + +vi.mock('../../networkUtils.js', () => ({ + formatSpeed: vi.fn((speed) => `speed:${speed}`), +})); + +import API from '../../../api.js'; +import { format, formatDuration } from '../../dateTimeUtils.js'; +import { formatSpeed } from '../../networkUtils.js'; + +describe('M3UsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('refreshPlaylist', () => { + it('calls API.refreshPlaylist with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.refreshPlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.refreshPlaylist(3); + expect(API.refreshPlaylist).toHaveBeenCalledWith(3); + expect(result).toBe(mockReturn); + }); + }); + + describe('getPlaylistAutoCreatedChannelsCount', () => { + it('calls API.getPlaylistAutoCreatedChannelsCount with the correct id', () => { + const mockReturn = Promise.resolve({ count: 5 }); + API.getPlaylistAutoCreatedChannelsCount.mockReturnValue(mockReturn); + const result = M3UsTableUtils.getPlaylistAutoCreatedChannelsCount(7); + expect(API.getPlaylistAutoCreatedChannelsCount).toHaveBeenCalledWith(7); + expect(result).toBe(mockReturn); + }); + }); + + describe('deletePlaylist', () => { + it('calls API.deletePlaylist with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.deletePlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.deletePlaylist(2); + expect(API.deletePlaylist).toHaveBeenCalledWith(2); + expect(result).toBe(mockReturn); + }); + }); + + describe('updatePlaylist', () => { + it('merges values with playlist id and passes isToggle', () => { + const mockReturn = Promise.resolve({}); + API.updatePlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.updatePlaylist( + { is_active: false }, + { id: 10, name: 'Test' }, + true + ); + expect(API.updatePlaylist).toHaveBeenCalledWith( + { is_active: false, id: 10 }, + true + ); + expect(result).toBe(mockReturn); + }); + + it('defaults isToggle to false when not provided', () => { + API.updatePlaylist.mockReturnValue(Promise.resolve({})); + M3UsTableUtils.updatePlaylist({ name: 'New' }, { id: 5 }); + expect(API.updatePlaylist).toHaveBeenCalledWith( + { name: 'New', id: 5 }, + false + ); + }); + }); + + // ── formatStatusText ──────────────────────────────────────────────────────── + describe('formatStatusText', () => { + it.each([ + ['idle', 'Idle'], + ['fetching', 'Fetching'], + ['parsing', 'Parsing'], + ['error', 'Error'], + ['success', 'Success'], + ['pending_setup', 'Pending Setup'], + ])('returns "%s" for status "%s"', (status, expected) => { + expect(M3UsTableUtils.formatStatusText(status)).toBe(expected); + }); + + it('capitalizes first letter for unknown status', () => { + expect(M3UsTableUtils.formatStatusText('loading')).toBe('Loading'); + }); + + it('returns "Unknown" for null', () => { + expect(M3UsTableUtils.formatStatusText(null)).toBe('Unknown'); + }); + + it('returns "Unknown" for undefined', () => { + expect(M3UsTableUtils.formatStatusText(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(M3UsTableUtils.formatStatusText('')).toBe('Unknown'); + }); + }); + + // ── getStatusColor ────────────────────────────────────────────────────────── + describe('getStatusColor', () => { + it.each([ + ['idle', 'gray.5'], + ['fetching', 'blue.5'], + ['parsing', 'indigo.5'], + ['error', 'red.5'], + ['success', 'green.5'], + ['pending_setup', 'orange.5'], + ])('returns "%s" for status "%s"', (status, expected) => { + expect(M3UsTableUtils.getStatusColor(status)).toBe(expected); + }); + + it('returns "gray.5" for unknown status', () => { + expect(M3UsTableUtils.getStatusColor('unknown')).toBe('gray.5'); + }); + + it('returns "gray.5" for null', () => { + expect(M3UsTableUtils.getStatusColor(null)).toBe('gray.5'); + }); + }); + + // ── getExpirationInfo ─────────────────────────────────────────────────────── + describe('getExpirationInfo', () => { + it('returns red.7 and "Expired" when daysLeft < 0', () => { + const result = M3UsTableUtils.getExpirationInfo( + -1, + '2024-01-01', + 'MM/DD/YYYY' + ); + expect(result).toEqual({ color: 'red.7', label: 'Expired' }); + }); + + it('returns red.5 and "Expires today" when daysLeft === 0', () => { + const result = M3UsTableUtils.getExpirationInfo( + 0, + '2024-06-01', + 'MM/DD/YYYY' + ); + expect(result).toEqual({ color: 'red.5', label: 'Expires today' }); + }); + + it('returns orange.5 and "{n}d left" when daysLeft is 1–7', () => { + expect(M3UsTableUtils.getExpirationInfo(1, null, 'MM/DD/YYYY')).toEqual({ + color: 'orange.5', + label: '1d left', + }); + expect(M3UsTableUtils.getExpirationInfo(7, null, 'MM/DD/YYYY')).toEqual({ + color: 'orange.5', + label: '7d left', + }); + }); + + it('returns yellow.5 and "{n}d left" when daysLeft is 8–30', () => { + expect(M3UsTableUtils.getExpirationInfo(8, null, 'MM/DD/YYYY')).toEqual({ + color: 'yellow.5', + label: '8d left', + }); + expect(M3UsTableUtils.getExpirationInfo(30, null, 'MM/DD/YYYY')).toEqual({ + color: 'yellow.5', + label: '30d left', + }); + }); + + it('returns formatted date label with no color when daysLeft > 30', () => { + format.mockReturnValue('12/31/2024'); + const result = M3UsTableUtils.getExpirationInfo( + 60, + '2024-12-31', + 'MM/DD/YYYY' + ); + expect(format).toHaveBeenCalledWith('2024-12-31', 'MM/DD/YYYY'); + expect(result.label).toBe('12/31/2024'); + expect(result.color).toBeUndefined(); + }); + }); + + // ── getExpirationTooltip ──────────────────────────────────────────────────── + describe('getExpirationTooltip', () => { + it('returns the fallback label when allExpirations is empty', () => { + const result = M3UsTableUtils.getExpirationTooltip( + [], + 'MM/DD/YYYY HH:mm', + '7d left' + ); + expect(result).toBe('7d left'); + }); + + it('formats each expiration entry with profile name and date', () => { + format.mockImplementation(() => `2024-12-31`); + const expirations = [ + { profile_name: 'Profile A', exp_date: '2024-12-31', is_active: true }, + { profile_name: 'Profile B', exp_date: '2024-11-30', is_active: false }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY HH:mm', + 'fallback' + ); + expect(result).toContain('Profile A: 2024-12-31'); + expect(result).toContain('Profile B: 2024-12-31 (inactive)'); + }); + + it('does not append "(inactive)" for active profiles', () => { + format.mockReturnValue('2024-12-31'); + const expirations = [ + { profile_name: 'Active', exp_date: '2024-12-31', is_active: true }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY', + 'fallback' + ); + expect(result).not.toContain('(inactive)'); + }); + + it('joins multiple entries with newline', () => { + format.mockReturnValue('2024-12-31'); + const expirations = [ + { profile_name: 'A', exp_date: '2024-12-31', is_active: true }, + { profile_name: 'B', exp_date: '2024-12-31', is_active: true }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY', + 'fallback' + ); + expect(result.split('\n')).toHaveLength(2); + }); + }); + + // ── getSortedPlaylists ────────────────────────────────────────────────────── + describe('getSortedPlaylists', () => { + const playlists = [ + { id: 1, name: 'Zebra', locked: false, max_streams: 5 }, + { id: 2, name: 'Alpha', locked: false, max_streams: 10 }, + { id: 3, name: 'Middle', locked: true, max_streams: 1 }, + { id: 4, name: 'Beta', locked: false, max_streams: 3 }, + ]; + + it('excludes locked playlists', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'name', + false + ); + expect(result.find((p) => p.id === 3)).toBeUndefined(); + expect(result).toHaveLength(3); + }); + + it('sorts by string column ascending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'name', + false + ); + expect(result.map((p) => p.name)).toEqual(['Alpha', 'Beta', 'Zebra']); + }); + + it('sorts by string column descending', () => { + const result = M3UsTableUtils.getSortedPlaylists(playlists, 'name', true); + expect(result.map((p) => p.name)).toEqual(['Zebra', 'Beta', 'Alpha']); + }); + + it('sorts by numeric column ascending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'max_streams', + false + ); + expect(result.map((p) => p.max_streams)).toEqual([3, 5, 10]); + }); + + it('sorts by numeric column descending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'max_streams', + true + ); + expect(result.map((p) => p.max_streams)).toEqual([10, 5, 3]); + }); + + it('sorts nulls to the end regardless of direction', () => { + const withNulls = [ + { id: 1, name: null, locked: false }, + { id: 2, name: 'Alpha', locked: false }, + { id: 3, name: null, locked: false }, + ]; + const asc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', false); + expect(asc[asc.length - 1].name).toBeNull(); + expect(asc[asc.length - 2].name).toBeNull(); + + const desc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', true); + expect(desc[desc.length - 1].name).toBeNull(); + }); + + it('returns empty array when all playlists are locked', () => { + const locked = [{ id: 1, name: 'Locked', locked: true }]; + expect(M3UsTableUtils.getSortedPlaylists(locked, 'name', false)).toEqual( + [] + ); + }); + }); + + // ── getStatusContent ──────────────────────────────────────────────────────── + describe('getStatusContent', () => { + it('returns null when progress is 100', () => { + expect( + M3UsTableUtils.getStatusContent({ progress: 100, action: 'parsing' }) + ).toBeNull(); + }); + + it('returns initializing type for initializing action', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 50, + action: 'initializing', + }) + ).toEqual({ type: 'initializing' }); + }); + + describe('downloading', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 0, + action: 'downloading', + }) + ).toEqual({ type: 'simple', label: 'Downloading...' }); + }); + + it('returns downloading object with formatted fields when progress > 0', () => { + formatSpeed.mockReturnValue('speed:512'); + formatDuration.mockReturnValue('duration:30'); + const result = M3UsTableUtils.getStatusContent({ + action: 'downloading', + progress: 50, + speed: 512, + time_remaining: 30, + }); + expect(result).toEqual({ + type: 'downloading', + progress: 50, + speed: 'speed:512', + timeRemaining: 'duration:30', + }); + expect(formatSpeed).toHaveBeenCalledWith(512); + expect(formatDuration).toHaveBeenCalledWith(30); + }); + + it('returns "calculating..." when time_remaining is absent', () => { + formatSpeed.mockReturnValue('speed:512'); + const result = M3UsTableUtils.getStatusContent({ + action: 'downloading', + progress: 25, + speed: 512, + }); + expect(result.timeRemaining).toBe('calculating...'); + }); + }); + + describe('processing_groups', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 0, + action: 'processing_groups', + }) + ).toEqual({ type: 'simple', label: 'Processing groups...' }); + }); + + it('returns groups object with formatted elapsed time', () => { + formatDuration.mockReturnValue('duration:120'); + const result = M3UsTableUtils.getStatusContent({ + action: 'processing_groups', + progress: 40, + elapsed_time: 120, + groups_processed: 15, + }); + expect(result).toEqual({ + type: 'groups', + progress: 40, + elapsedTime: 'duration:120', + groupsProcessed: 15, + }); + expect(formatDuration).toHaveBeenCalledWith(120); + }); + }); + + describe('parsing', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ progress: 0, action: 'parsing' }) + ).toEqual({ type: 'simple', label: 'Parsing...' }); + }); + + it('returns parsing object with all fields', () => { + formatDuration.mockReturnValue('duration:60'); + const result = M3UsTableUtils.getStatusContent({ + action: 'parsing', + progress: 75, + elapsed_time: 60, + time_remaining: 20, + streams_processed: 1000, + }); + expect(result).toEqual({ + type: 'parsing', + progress: 75, + elapsedTime: 'duration:60', + timeRemaining: 'duration:60', + streamsProcessed: 1000, + }); + }); + + it('returns "calculating..." when time_remaining is absent', () => { + formatDuration.mockReturnValue('duration:60'); + const result = M3UsTableUtils.getStatusContent({ + action: 'parsing', + progress: 50, + elapsed_time: 60, + }); + expect(result.timeRemaining).toBe('calculating...'); + }); + }); + + describe('default / error', () => { + it('returns error type when status is error', () => { + const result = M3UsTableUtils.getStatusContent({ + action: 'unknown', + progress: 50, + status: 'error', + error: 'Something went wrong', + }); + expect(result).toEqual({ + type: 'error', + error: 'Something went wrong', + }); + }); + + it('returns simple label with action name for unknown non-error action', () => { + const result = M3UsTableUtils.getStatusContent({ + action: 'custom_action', + progress: 50, + status: 'running', + }); + expect(result).toEqual({ type: 'simple', label: 'custom_action...' }); + }); + + it('returns "Processing..." label when action is undefined', () => { + const result = M3UsTableUtils.getStatusContent({ + progress: 30, + status: 'running', + }); + expect(result).toEqual({ type: 'simple', label: 'Processing...' }); + }); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js new file mode 100644 index 00000000..8ef7bf3b --- /dev/null +++ b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as OutputProfilesTableUtils from '../OutputProfilesTableUtils'; + +vi.mock('../../../api.js', () => ({ + default: { + updateOutputProfile: vi.fn(), + deleteOutputProfile: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('OutputProfilesTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + describe('updateOutputProfile', () => { + it('calls API.updateOutputProfile with the provided values', () => { + const mockReturn = Promise.resolve({ id: 1, name: 'Updated' }); + API.updateOutputProfile.mockReturnValue(mockReturn); + const values = { id: 1, name: 'Updated', is_active: true }; + const result = OutputProfilesTableUtils.updateOutputProfile(values); + expect(API.updateOutputProfile).toHaveBeenCalledWith(values); + expect(result).toBe(mockReturn); + }); + + it('passes through the return value from the API', async () => { + API.updateOutputProfile.mockResolvedValue({ id: 2, name: 'Profile' }); + const result = await OutputProfilesTableUtils.updateOutputProfile({ + id: 2, + }); + expect(result).toEqual({ id: 2, name: 'Profile' }); + }); + }); + + describe('deleteOutputProfile', () => { + it('calls API.deleteOutputProfile with the correct id', async () => { + API.deleteOutputProfile.mockResolvedValue(undefined); + await OutputProfilesTableUtils.deleteOutputProfile(5); + expect(API.deleteOutputProfile).toHaveBeenCalledWith(5); + }); + + it('resolves without a return value', async () => { + API.deleteOutputProfile.mockResolvedValue(undefined); + const result = await OutputProfilesTableUtils.deleteOutputProfile(3); + expect(result).toBeUndefined(); + }); + + it('propagates errors thrown by the API', async () => { + API.deleteOutputProfile.mockRejectedValue(new Error('Not found')); + await expect( + OutputProfilesTableUtils.deleteOutputProfile(99) + ).rejects.toThrow('Not found'); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js new file mode 100644 index 00000000..dd6cf8ab --- /dev/null +++ b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as StreamsTableUtils from '../StreamsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + addStreamsToChannel: vi.fn(), + queryStreamsTable: vi.fn(), + getStreams: vi.fn(), + createChannelsFromStreamsAsync: vi.fn(), + deleteStream: vi.fn(), + deleteStreams: vi.fn(), + requeryStreams: vi.fn(), + createChannelFromStream: vi.fn(), + getAllStreamIds: vi.fn(), + getStreamFilterOptions: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('StreamsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('API wrappers', () => { + it('addStreamsToChannel calls API with correct args', () => { + const mockReturn = Promise.resolve(undefined); + API.addStreamsToChannel.mockReturnValue(mockReturn); + const result = StreamsTableUtils.addStreamsToChannel('ch-1', [1], [2, 3]); + expect(API.addStreamsToChannel).toHaveBeenCalledWith('ch-1', [1], [2, 3]); + expect(result).toBe(mockReturn); + }); + + it('queryStreamsTable calls API with params', () => { + const params = new URLSearchParams({ page: '1' }); + const mockReturn = Promise.resolve({ results: [] }); + API.queryStreamsTable.mockReturnValue(mockReturn); + const result = StreamsTableUtils.queryStreamsTable(params); + expect(API.queryStreamsTable).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + + it('getStreams calls API with streamIds', () => { + const mockReturn = Promise.resolve([]); + API.getStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getStreams([10, 20]); + expect(API.getStreams).toHaveBeenCalledWith([10, 20]); + expect(result).toBe(mockReturn); + }); + + it('createChannelsFromStreamsAsync calls API with correct args', () => { + const mockReturn = Promise.resolve(undefined); + API.createChannelsFromStreamsAsync.mockReturnValue(mockReturn); + const result = StreamsTableUtils.createChannelsFromStreamsAsync( + [1, 2], + [3], + 100 + ); + expect(API.createChannelsFromStreamsAsync).toHaveBeenCalledWith( + [1, 2], + [3], + 100 + ); + expect(result).toBe(mockReturn); + }); + + it('deleteStream calls API with id', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteStream.mockReturnValue(mockReturn); + const result = StreamsTableUtils.deleteStream(5); + expect(API.deleteStream).toHaveBeenCalledWith(5); + expect(result).toBe(mockReturn); + }); + + it('deleteStreams calls API with ids', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.deleteStreams([1, 2, 3]); + expect(API.deleteStreams).toHaveBeenCalledWith([1, 2, 3]); + expect(result).toBe(mockReturn); + }); + + it('requeryStreams calls API', () => { + const mockReturn = Promise.resolve(undefined); + API.requeryStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.requeryStreams(); + expect(API.requeryStreams).toHaveBeenCalled(); + expect(result).toBe(mockReturn); + }); + + it('createChannelFromStream calls API with values', () => { + const mockReturn = Promise.resolve({ id: 1 }); + API.createChannelFromStream.mockReturnValue(mockReturn); + const values = { name: 'New Channel' }; + const result = StreamsTableUtils.createChannelFromStream(values); + expect(API.createChannelFromStream).toHaveBeenCalledWith(values); + expect(result).toBe(mockReturn); + }); + + it('getAllStreamIds calls API with params', () => { + const params = new URLSearchParams(); + const mockReturn = Promise.resolve([1, 2, 3]); + API.getAllStreamIds.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getAllStreamIds(params); + expect(API.getAllStreamIds).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + + it('getStreamFilterOptions calls API with params', () => { + const params = new URLSearchParams(); + const mockReturn = Promise.resolve({}); + API.getStreamFilterOptions.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getStreamFilterOptions(params); + expect(API.getStreamFilterOptions).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + }); + + // ── getStatsTooltip ───────────────────────────────────────────────────────── + describe('getStatsTooltip', () => { + it('returns "-" compact display for empty stats', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({}); + expect(compactDisplay).toBe('-'); + }); + + it('returns "No source info available" tooltip for empty stats', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({}); + expect(tooltipContent).toBe('No source info available'); + }); + + it('converts resolution "1920x1080" to "1080p" in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + }); + expect(compactDisplay).toBe('1080p'); + }); + + it('converts resolution "1280x720" to "720p" in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1280x720', + }); + expect(compactDisplay).toBe('720p'); + }); + + it('uppercases video_codec in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + video_codec: 'h264', + }); + expect(compactDisplay).toBe('H264'); + }); + + it('combines resolution and video_codec in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + video_codec: 'hevc', + }); + expect(compactDisplay).toBe('1080p HEVC'); + }); + + it('includes Resolution in tooltip when present', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + }); + expect(tooltipContent).toContain('Resolution: 1920x1080'); + }); + + it('includes uppercased Video Codec in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + video_codec: 'h264', + }); + expect(tooltipContent).toContain('Video Codec: H264'); + }); + + it('includes Video Bitrate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + video_bitrate: 5000, + }); + expect(tooltipContent).toContain('Video Bitrate: 5000 kbps'); + }); + + it('includes Frame Rate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + source_fps: 30, + }); + expect(tooltipContent).toContain('Frame Rate: 30 FPS'); + }); + + it('includes uppercased Audio Codec in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_codec: 'aac', + }); + expect(tooltipContent).toContain('Audio Codec: AAC'); + }); + + it('includes Audio Channels in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_channels: 2, + }); + expect(tooltipContent).toContain('Audio Channels: 2'); + }); + + it('includes Audio Bitrate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_bitrate: 192, + }); + expect(tooltipContent).toContain('Audio Bitrate: 192 kbps'); + }); + + it('builds multi-line tooltip joined by newlines', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + video_codec: 'h264', + audio_codec: 'aac', + }); + const lines = tooltipContent.split('\n'); + expect(lines).toHaveLength(3); + }); + + it('handles resolution with no height part gracefully', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: 'unknown', + }); + // No 'x' separator — height is undefined, so no height part added + expect(compactDisplay).toBe('-'); + }); + }); + + // ── appendFetchPageParams ─────────────────────────────────────────────────── + describe('appendFetchPageParams', () => { + it('appends page incremented by 1 from pageIndex', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 2, pageSize: 25 }, + [] + ); + expect(params.get('page')).toBe('3'); + expect(params.get('page_size')).toBe('25'); + }); + + it('does not append ordering when sorting is empty', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [] + ); + expect(params.get('ordering')).toBeNull(); + }); + + it('appends ascending ordering for known column', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'name', desc: false }] + ); + expect(params.get('ordering')).toBe('name'); + }); + + it('appends descending ordering with "-" prefix', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'name', desc: true }] + ); + expect(params.get('ordering')).toBe('-name'); + }); + + it('maps "group" column to "channel_group__name"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'group', desc: false }] + ); + expect(params.get('ordering')).toBe('channel_group__name'); + }); + + it('maps "m3u" column to "m3u_account__name"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'm3u', desc: false }] + ); + expect(params.get('ordering')).toBe('m3u_account__name'); + }); + + it('maps "tvg_id" column to "tvg_id"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'tvg_id', desc: false }] + ); + expect(params.get('ordering')).toBe('tvg_id'); + }); + + it('uses column id directly for unmapped columns', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'custom_field', desc: false }] + ); + expect(params.get('ordering')).toBe('custom_field'); + }); + }); + + // ── getChannelProfileIds ──────────────────────────────────────────────────── + describe('getChannelProfileIds', () => { + it('returns [] when profileIds includes "none"', () => { + expect(StreamsTableUtils.getChannelProfileIds(['none'], '0')).toEqual([]); + }); + + it('returns null when profileIds includes "all"', () => { + expect(StreamsTableUtils.getChannelProfileIds(['all'], '0')).toBeNull(); + }); + + it('returns parsed integer array for specific profile ids', () => { + expect( + StreamsTableUtils.getChannelProfileIds(['1', '2', '3'], '0') + ).toEqual([1, 2, 3]); + }); + + it('returns [selectedProfileId as int] when profileIds is null and selectedProfileId is not "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(null, '3')).toEqual([3]); + }); + + it('returns null when profileIds is null and selectedProfileId is "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(null, '0')).toBeNull(); + }); + + it('returns null when profileIds is undefined and selectedProfileId is "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(undefined, '0')).toBeNull(); + }); + }); + + // ── getChannelNumberValue ─────────────────────────────────────────────────── + describe('getChannelNumberValue', () => { + it('returns null for "provider" mode', () => { + expect( + StreamsTableUtils.getChannelNumberValue('provider', 100) + ).toBeNull(); + }); + + it('returns 0 for "auto" mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('auto', 100)).toBe(0); + }); + + it('returns -1 for "highest" mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('highest', 100)).toBe(-1); + }); + + it('returns startNumber as Number for any other mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('manual', 42)).toBe(42); + }); + + it('converts string startNumber to number', () => { + expect(StreamsTableUtils.getChannelNumberValue('manual', '200')).toBe( + 200 + ); + }); + }); + + // ── getFilterParams ───────────────────────────────────────────────────────── + describe('getFilterParams', () => { + it('returns empty URLSearchParams for empty filters', () => { + const result = StreamsTableUtils.getFilterParams({}); + expect(result.toString()).toBe(''); + }); + + it('appends string filter values', () => { + const result = StreamsTableUtils.getFilterParams({ name: 'CNN' }); + expect(result.get('name')).toBe('CNN'); + }); + + it('appends "true" for boolean true values', () => { + const result = StreamsTableUtils.getFilterParams({ is_active: true }); + expect(result.get('is_active')).toBe('true'); + }); + + it('does not append boolean false values', () => { + const result = StreamsTableUtils.getFilterParams({ is_active: false }); + expect(result.get('is_active')).toBeNull(); + }); + + it('does not append null values', () => { + const result = StreamsTableUtils.getFilterParams({ name: null }); + expect(result.get('name')).toBeNull(); + }); + + it('does not append undefined values', () => { + const result = StreamsTableUtils.getFilterParams({ name: undefined }); + expect(result.get('name')).toBeNull(); + }); + + it('does not append empty string values', () => { + const result = StreamsTableUtils.getFilterParams({ name: '' }); + expect(result.get('name')).toBeNull(); + }); + + it('appends numeric values as strings', () => { + const result = StreamsTableUtils.getFilterParams({ page: 2 }); + expect(result.get('page')).toBe('2'); + }); + + it('handles multiple filters', () => { + const result = StreamsTableUtils.getFilterParams({ + name: 'ESPN', + is_active: true, + group: '', + }); + expect(result.get('name')).toBe('ESPN'); + expect(result.get('is_active')).toBe('true'); + expect(result.get('group')).toBeNull(); + }); + }); +}); diff --git a/tests/test_celery_plugin_discovery.py b/tests/test_celery_plugin_discovery.py index f001b56f..218ecea5 100644 --- a/tests/test_celery_plugin_discovery.py +++ b/tests/test_celery_plugin_discovery.py @@ -1,5 +1,5 @@ """ -Regression tests for the worker_ready hook in `dispatcharr/celery.py` +Regression tests for the worker_process_init hook in `dispatcharr/celery.py` that eagerly discovers plugins so their @shared_task definitions register with the worker before beat starts firing. @@ -15,63 +15,71 @@ from unittest.mock import MagicMock, patch from django.test import SimpleTestCase -class WorkerReadyPluginDiscoveryTests(SimpleTestCase): +class WorkerProcessInitPluginDiscoveryTests(SimpleTestCase): def test_invokes_discover_plugins_with_sync_db_false(self): """The handler must call PluginManager.discover_plugins(sync_db=False). sync_db=False is intentional: discovery on every worker boot must not touch the DB schema, just import plugin modules so their @shared_task decorators run.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process mock_pm = MagicMock() with patch( "apps.plugins.loader.PluginManager.get", return_value=mock_pm ) as mock_get: - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + init_worker_process() mock_get.assert_called_once() mock_pm.discover_plugins.assert_called_once_with(sync_db=False) + def test_closes_inherited_db_connections_before_discovery(self): + from dispatcharr.celery import init_worker_process + + with patch("django.db.connections.close_all") as mock_close_all: + with patch("apps.plugins.loader.PluginManager.get"): + init_worker_process() + + mock_close_all.assert_called_once() + def test_swallows_plugin_loader_errors(self): """If the plugin loader explodes, the worker must still come up — the handler must not propagate exceptions.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process - with patch( - "apps.plugins.loader.PluginManager.get", - side_effect=RuntimeError("plugin loader exploded"), - ): - # Must NOT raise. - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + with patch( + "apps.plugins.loader.PluginManager.get", + side_effect=RuntimeError("plugin loader exploded"), + ): + # Must NOT raise. + init_worker_process() def test_swallows_discover_plugins_errors(self): """Failure inside discover_plugins itself (e.g. one plugin's plugin.py has an import error) must also be swallowed — one bad plugin shouldn't keep the worker from coming up.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process mock_pm = MagicMock() mock_pm.discover_plugins.side_effect = ImportError("bad plugin") - with patch( - "apps.plugins.loader.PluginManager.get", return_value=mock_pm - ): - # Must NOT raise. - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + with patch( + "apps.plugins.loader.PluginManager.get", return_value=mock_pm + ): + # Must NOT raise. + init_worker_process() - def test_handler_is_connected_to_worker_ready(self): + def test_handler_is_connected_to_worker_process_init(self): """The connect decorator must have wired the handler into the - worker_ready signal so Celery actually fires it at startup.""" - from celery.signals import worker_ready - from dispatcharr.celery import discover_plugins_on_worker_ready + worker_process_init signal so Celery actually fires it after fork.""" + from celery.signals import worker_process_init + from dispatcharr.celery import init_worker_process - # signal.receivers is a list of (lookup_key, weakref_or_func) pairs. - # The handler is registered with weak=False so the function appears - # directly (not as a dead weakref). - receivers = [r for _, r in worker_ready.receivers] - # Dereference weakrefs; pass direct references through as-is. + receivers = [r for _, r in worker_process_init.receivers] callables = [r() if isinstance(r, weakref.ref) else r for r in receivers] - assert discover_plugins_on_worker_ready in receivers or \ - any(getattr(c, "__wrapped__", c) is discover_plugins_on_worker_ready for c in callables), ( - "discover_plugins_on_worker_ready was not connected to " - "Celery's worker_ready signal" + assert init_worker_process in receivers or \ + any(getattr(c, "__wrapped__", c) is init_worker_process for c in callables), ( + "init_worker_process was not connected to " + "Celery's worker_process_init signal" ) diff --git a/tests/test_process_label.py b/tests/test_process_label.py index e4525a85..a919f9e1 100644 --- a/tests/test_process_label.py +++ b/tests/test_process_label.py @@ -2,7 +2,7 @@ from unittest.mock import patch from django.test import SimpleTestCase -from dispatcharr.db.process_label import get_process_role +from dispatcharr.db.process_label import get_process_role, uses_geventpool_database_backend class ProcessLabelTests(SimpleTestCase): @@ -27,3 +27,26 @@ class ProcessLabelTests(SimpleTestCase): with patch.dict("sys.modules", {"uwsgi": fake_uwsgi}): role = get_process_role(["/dispatcharrpy/bin/python", "-c", "pass"]) self.assertEqual(role, "django") + + +class DatabaseBackendSelectionTests(SimpleTestCase): + def test_celery_workers_use_standard_postgres_backend(self): + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "worker", "-Q", "celery"] + )) + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "worker", "-Q", "dvr", "--pool=threads"] + )) + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "beat"] + )) + + def test_uwsgi_uses_geventpool_backend(self): + self.assertTrue(uses_geventpool_database_backend( + ["uwsgi", "--ini", "/app/docker/uwsgi.ini"] + )) + + def test_manage_uses_geventpool_backend(self): + self.assertTrue(uses_geventpool_database_backend( + ["manage.py", "migrate"] + )) diff --git a/version.py b/version.py index db62d71f..25ccd35c 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.27.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.27.2' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process