Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/francescodg89-crypto/1398

This commit is contained in:
SergeantPanda 2026-07-02 13:55:27 -05:00
commit 2dcae97d01
122 changed files with 23975 additions and 2438 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -448,6 +448,7 @@ class ChannelSerializer(serializers.ModelSerializer):
"logo_id",
"user_level",
"is_adult",
"is_catchup",
"hidden_from_output",
"auto_created",
"auto_created_by",

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
f' <title>{title}</title>\n'
f' </programme>\n'
)
def _xmltv_file(programmes):
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<tv generator-info-name="test">\n'
f'{programmes}'
'</tv>\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'
)

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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 <x>",
"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"(?<season>\d+)", r"S$1"),
(r"(?P<n>x)(?P<n>y)", 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))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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():
"""

View file

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

View file

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

View file

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

View file

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

View file

7
apps/timeshift/apps.py Normal file
View file

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

252
apps/timeshift/helpers.py Normal file
View file

@ -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<date>\d{4}-\d{2}-\d{2})"
r"(?P<dtsep>[:_]| )"
r"(?P<hour>\d{2})"
r"(?P<hmsep>[-:])"
r"(?P<minute>\d{2})"
r"(?:"
r":"
r"(?P<second>\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")

View file

View file

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

File diff suppressed because it is too large Load diff

1355
apps/timeshift/views.py Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()}"

View file

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

View file

@ -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/<str:username>/<str:password>/<str:stream_id>/<str:timestamp>/<str:duration>",
timeshift_proxy,
name="timeshift_proxy",
),
# XC VOD endpoints
path(
"movie/<str:username>/<str:password>/<str:stream_id>.<str:extension>",

View file

@ -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 = (
<Stack gap={4}>
<Text size="sm">{message}</Text>
<Text size="xs" c="dimmed">
{autoSyncSummary}
</Text>
{streamSummary && (
<Text size="xs" c="dimmed">
{streamSummary}
</Text>
)}
{autoSyncSummary && (
<Text size="xs" c="dimmed">
{autoSyncSummary}
</Text>
)}
{failed > 0 && failedDetails.length > 0 && (
<Button
size="xs"

View file

@ -851,4 +851,37 @@ describe('M3URefreshNotification', () => {
expect(call[0].autoClose).toBe(12000);
});
});
describe('Stream count rendering on parsing complete', () => {
it('inlines stream summary including marked stale count', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'success',
streams_created: 2,
streams_updated: 5,
streams_stale: 18,
streams_deleted: 3,
streams_processed: 1200,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
const call = showNotification.mock.calls.find(
(c) => typeof c[0]?.message === 'object'
);
expect(call).toBeDefined();
const { container } = render(<>{call[0].message}</>);
expect(container.textContent).toContain('Stream parsing complete!');
expect(container.textContent).toContain('18 marked stale');
expect(container.textContent).toContain('3 removed');
expect(container.textContent).toContain('Total processed: 1200');
});
});
});

View file

@ -22,6 +22,7 @@ import {
Gauge,
HardDriveDownload,
HardDriveUpload,
Rewind,
SquareX,
Timer,
Users,
@ -486,7 +487,10 @@ const StreamConnectionCard = ({
}, [channel.name, channel.stream_id]);
const channelName =
channel.name || previewedStream?.name || 'Unnamed Channel';
channel.name ||
channel.channel_name ||
previewedStream?.name ||
'Unnamed Channel';
const uptime = channel.uptime || 0;
const bitrates = channel.bitrates || [];
const totalBytes = channel.total_bytes || 0;
@ -661,6 +665,18 @@ const StreamConnectionCard = ({
{/* Add stream information badges */}
<Group gap="xs" mt="5">
{channel.is_timeshift && (
<Tooltip label="Catch-up (timeshift)">
<Badge
size="sm"
variant="light"
color="violet"
leftSection={<Rewind size={12} />}
>
TIMESHIFT
</Badge>
</Tooltip>
)}
{channel.resolution && (
<Tooltip label="Video resolution">
<Badge size="sm" variant="light" color="red">

View file

@ -16,6 +16,7 @@ import {
} from '@mantine/core';
import {
convertToSec,
formatDuration,
fromNow,
toFriendlyDuration,
useDateTimeFormat,
@ -31,8 +32,6 @@ import {
calculateConnectionDuration,
calculateConnectionStartTime,
calculateProgress,
formatDuration,
formatTime,
getEpisodeDisplayTitle,
getEpisodeSubtitle,
getMovieDisplayTitle,
@ -154,7 +153,7 @@ const ConnectionProgress = ({ connection, durationSecs }) => {
Progress
</Text>
<Text size="xs" c="dimmed">
{formatTime(currentTime)} / {formatTime(totalTime)}
{formatDuration(currentTime)} / {formatDuration(totalTime)}
</Text>
</Group>
<Progress
@ -370,11 +369,13 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
</Badge>
</Tooltip>
)}
{metadata.duration_secs && (
<Tooltip label="Content Duration">
<Badge size="sm" variant="light" color="blue">
{formatDuration(metadata.duration_secs)}
{formatDuration(metadata.duration_secs, {
zeroValue: 'Unknown',
precision: 'human',
})}
</Badge>
</Tooltip>
)}

View file

@ -19,12 +19,16 @@ vi.mock('../../../store/useVideoStore', () => ({
vi.mock('../../../store/users.jsx', () => ({
default: vi.fn(),
}));
vi.mock('../../../store/outputProfiles.jsx', () => ({
default: vi.fn(),
}));
// dateTimeUtils
vi.mock('../../../utils/dateTimeUtils.js', () => ({
toFriendlyDuration: vi.fn(() => '1h 23m'),
formatExactDuration: vi.fn((s) => `${s.toFixed(1)} seconds`),
useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })),
formatDuration: vi.fn(() => '5m 30s'),
}));
// networkUtils

View file

@ -7,6 +7,7 @@ vi.mock('../../../utils/dateTimeUtils.js', () => ({
fromNow: vi.fn(() => '5 minutes ago'),
toFriendlyDuration: vi.fn((secs) => (secs ? `${secs}s` : null)),
useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })),
formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)),
}));
// VodConnectionCardUtils
@ -18,8 +19,6 @@ vi.mock('../../../utils/cards/VodConnectionCardUtils.js', () => ({
currentTime: 0,
percentage: 0,
})),
formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)),
formatTime: vi.fn((secs) => `${secs}s`),
getEpisodeDisplayTitle: vi.fn(() => 'S01E02 — Pilot'),
getEpisodeSubtitle: vi.fn(() => ['Test Series', 'Season 1']),
getMovieDisplayTitle: vi.fn(() => 'Test Movie (2022)'),
@ -148,7 +147,10 @@ vi.mock('lucide-react', () => ({
}));
// Imports after mocks
import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js';
import {
formatDuration,
useDateTimeFormat,
} from '../../../utils/dateTimeUtils.js';
import {
calculateProgress,
getEpisodeDisplayTitle,
@ -398,6 +400,75 @@ describe('VodConnectionCard', () => {
});
});
// Content duration badge
describe('content duration badge', () => {
it('requests human-readable formatting for the content duration badge', () => {
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(formatDuration).toHaveBeenCalledWith(2700, {
zeroValue: 'Unknown',
precision: 'human',
});
});
it('renders the human-readable duration returned by formatDuration', () => {
vi.mocked(formatDuration).mockImplementation((seconds, options = {}) => {
if (options?.precision === 'human') {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
return `${seconds}s`;
});
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('45m')).toBeInTheDocument();
});
it('shows hours and minutes for long-form content', () => {
vi.mocked(formatDuration).mockImplementation((seconds, options = {}) => {
if (options?.precision === 'human') {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
return `${seconds}s`;
});
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('2h 0m')).toBeInTheDocument();
});
it('does not render the duration badge when duration_secs is missing', () => {
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
expect(formatDuration).not.toHaveBeenCalled();
});
});
// Episode rendering
describe('episode content', () => {

View file

@ -0,0 +1,81 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import {
Alert,
Button,
Flex,
NumberInput,
Stack,
Text,
} from '@mantine/core';
import { EPG_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getChangedSettings,
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../utils/forms/settings/EpgSettingsFormUtils.js';
const EpgSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const form = useForm({
mode: 'controlled',
initialValues: getEpgSettingsFormInitialValues(),
});
useEffect(() => {
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
const parsed = parseSettings(settings);
form.setFieldValue(
'xmltv_prev_days_override',
parsed.xmltv_prev_days_override ?? 0,
);
}
}, [settings]);
const onSubmit = async () => {
setSaved(false);
const changedSettings = getChangedSettings(form.getValues(), settings);
try {
await saveChangedSettings(settings, changedSettings);
setSaved(true);
} catch (error) {
console.error('Error saving EPG settings:', error);
}
};
const prevDaysConfig = EPG_SETTINGS_OPTIONS.xmltv_prev_days_override;
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
{saved && (
<Alert variant="light" color="green" title="Saved Successfully" />
)}
<NumberInput
label={prevDaysConfig.label}
description={prevDaysConfig.description}
min={0}
max={30}
{...form.getInputProps('xmltv_prev_days_override')}
/>
<Text size="xs" c="dimmed">
Per-user defaults and URL parameters still override this global value.
EPG channel matching options are configured from the Channels page.
</Text>
<Flex justify="flex-end">
<Button type="submit">Save</Button>
</Flex>
</Stack>
</form>
);
});
export default EpgSettingsForm;

View file

@ -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 (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={getNumericFieldMax(key)}
/>
);
}
if (isFloatField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
}
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
};
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 (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0}
max={getNumericFieldMax(key)}
/>
);
} else if (isFloatField(key)) {
return (
<NumberInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
min={0.0}
max={10.0}
step={0.01}
precision={1}
/>
);
} else {
return (
<TextInput
key={key}
label={config.label}
{...proxySettingsForm.getInputProps(key)}
description={config.description || null}
/>
);
}
})}
{mainEntries.map(([key, config]) =>
renderProxySettingField(key, config, proxySettingsForm)
)}
{advancedEntries.length > 0 && (
<>
<Button
variant="subtle"
size="xs"
leftSection={
advancedOpen ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
}
onClick={() => setAdvancedOpen((open) => !open)}
c="dimmed"
styles={{ root: { alignSelf: 'flex-start' } }}
>
{advancedOpen ? 'Hide' : 'Show'} Advanced Settings
</Button>
<Collapse in={advancedOpen}>
<Stack gap="sm">
{advancedEntries.map(([key, config]) =>
renderProxySettingField(key, config, proxySettingsForm)
)}
</Stack>
</Collapse>
</>
)}
</>
);
});

View file

@ -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', () => ({
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Collapse: ({ in: isOpen, children }) =>
isOpen ? <div data-testid="collapse-open">{children}</div> : null,
TextInput: ({ label, description, ...rest }) => (
<div>
<label>{label}</label>
@ -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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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(<ProxySettingsForm active={true} />);
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();
});
});
});

View file

@ -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 (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
<Text component="div" size="xs">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text>
</Flex>
</Box>
);
})}
{row.getVisibleCells().map((cell) => (
<Box
className="td"
key={cell.id}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
}}
>
<Flex align="center" style={{ height: '100%' }}>
<Text component="div" size="xs">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text>
</Flex>
</Box>
))}
</Box>
);
},
@ -260,7 +146,7 @@ const StatsCategory = ({ categoryName, stats }) => {
{Object.entries(stats).map(([key, value]) => (
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
<Badge size="xs" variant="light" color="gray">
{key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '}
{formatStatKey(key)}:{' '}
{formatStatValue(key, value)}
</Badge>
</Tooltip>
@ -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)
);

View file

@ -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(
</ActionIcon>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<MenuDropdown>
<MenuItem leftSection={<Copy size="14" />}>
<UnstyledButton
size="xs"
onClick={() => copyToClipboard(getChannelURL(row.original))}
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={onRecord}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
leftSection={
@ -239,8 +239,8 @@ const ChannelRowActions = React.memo(
}
>
<Text size="xs">Record</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Center>
</Box>
@ -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
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
@ -1312,8 +1194,8 @@ const ChannelsTable = ({ onReady }) => {
>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1329,7 +1211,7 @@ const ChannelsTable = ({ onReady }) => {
clients.
</Text>
<TextInput
value={buildHDHRUrl()}
value={buildHDHRUrl(hdhrOutputProfileId, hdhrUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1359,7 +1241,7 @@ const ChannelsTable = ({ onReady }) => {
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
<Popover
withArrow
@ -1368,7 +1250,7 @@ const ChannelsTable = ({ onReady }) => {
position="bottom-start"
withinPortal
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
@ -1381,8 +1263,8 @@ const ChannelsTable = ({ onReady }) => {
>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1398,7 +1280,7 @@ const ChannelsTable = ({ onReady }) => {
channel list.
</Text>
<TextInput
value={buildM3UUrl()}
value={buildM3UUrl(m3uParams, m3uUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1492,7 +1374,7 @@ const ChannelsTable = ({ onReady }) => {
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
<Popover
withArrow
@ -1501,7 +1383,7 @@ const ChannelsTable = ({ onReady }) => {
position="bottom-start"
withinPortal
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
@ -1515,8 +1397,8 @@ const ChannelsTable = ({ onReady }) => {
>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1534,7 +1416,7 @@ const ChannelsTable = ({ onReady }) => {
clients.
</Text>
<TextInput
value={buildEPGUrl()}
value={buildEPGUrl(epgParams, epgUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1608,7 +1490,7 @@ const ChannelsTable = ({ onReady }) => {
}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
</Group>
</Flex>
@ -1626,7 +1508,7 @@ const ChannelsTable = ({ onReady }) => {
<ChannelTableHeader
rows={rows}
editChannel={editChannel}
deleteChannels={deleteChannels}
deleteChannels={handleDeleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
showDisabled={showDisabled}

View file

@ -6,8 +6,14 @@ import {
Flex,
Group,
Menu,
NumberInput,
MenuDivider,
MenuDropdown,
MenuItem,
MenuLabel,
MenuTarget,
Popover,
PopoverDropdown,
PopoverTarget,
Select,
Text,
TextInput,
@ -19,22 +25,20 @@ import {
Binary,
CircleCheck,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
Settings,
Eye,
EyeOff,
Filter,
Square,
SquareCheck,
Pin,
PinOff,
Lock,
LockOpen,
Pin,
PinOff,
Settings,
Square,
SquareCheck,
SquareMinus,
SquarePen,
SquarePlus,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useChannelsTableStore from '../../../store/channelsTable';
import useAuthStore from '../../../store/auth';
@ -45,6 +49,10 @@ import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
import EPGMatchModal from '../../modals/EPGMatchModal';
import {
addChannelProfile,
deleteChannelProfile,
} from '../../../utils/tables/ChannelsTableUtils.js';
const CreateProfilePopover = React.memo(() => {
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"
>
<Popover.Target>
<PopoverTarget>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
@ -81,9 +89,9 @@ const CreateProfilePopover = React.memo(() => {
>
<SquarePlus />
</ActionIcon>
</Popover.Target>
</PopoverTarget>
<Popover.Dropdown>
<PopoverDropdown>
<Group>
<TextInput
placeholder="Profile Name"
@ -101,7 +109,7 @@ const CreateProfilePopover = React.memo(() => {
<CircleCheck />
</ActionIcon>
</Group>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
);
});
@ -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 = ({
>
<Flex gap={6}>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Button size="xs" variant="default" onClick={() => {}}>
<Filter size={18} />
</Button>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
onClick={toggleShowDisabled}
leftSection={
showDisabled ? <Eye size={18} /> : <EyeOff size={18} />
@ -319,9 +301,9 @@ const ChannelTableHeader = ({
<Text size="xs">
{showDisabled ? 'Hide Disabled' : 'Show Disabled'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyStreamlessChannels}
leftSection={
showOnlyStreamlessChannels ? (
@ -332,9 +314,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Only Empty Channels</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyStaleChannels}
leftSection={
showOnlyStaleChannels ? (
@ -345,9 +327,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Has Stale Streams</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyOverriddenChannels}
leftSection={
showOnlyOverriddenChannels ? (
@ -358,19 +340,19 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Has Overrides</Text>
</Menu.Item>
</MenuItem>
<Menu.Divider />
<Menu.Label>
<MenuDivider />
<MenuLabel>
<Text size="xs">Visibility</Text>
</Menu.Label>
</MenuLabel>
{[
{ value: 'active', label: 'Active Only' },
{ value: 'hidden', label: 'Hidden Only' },
{ value: 'all', label: 'Show All' },
].map(({ value, label }) => (
<Menu.Item
<MenuItem
key={value}
onClick={() =>
setVisibilityFilter && setVisibilityFilter(value)
@ -384,9 +366,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">{label}</Text>
</Menu.Item>
</MenuItem>
))}
</Menu.Dropdown>
</MenuDropdown>
</Menu>
<Button
@ -435,14 +417,14 @@ const ChannelTableHeader = ({
</Button>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
leftSection={
headerPinned ? <Pin size={18} /> : <PinOff size={18} />
}
@ -451,9 +433,9 @@ const ChannelTableHeader = ({
<Text size="xs">
{headerPinned ? 'Unpin Headers' : 'Pin Headers'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={
isUnlocked ? <LockOpen size={18} /> : <Lock size={18} />
}
@ -463,11 +445,11 @@ const ChannelTableHeader = ({
<Text size="xs">
{isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Divider />
<MenuDivider />
<Menu.Item
<MenuItem
leftSection={<ArrowDown01 size={18} />}
disabled={
selectedTableIds.length == 0 ||
@ -476,9 +458,9 @@ const ChannelTableHeader = ({
onClick={() => setAssignNumbersModalOpen(true)}
>
<Text size="xs">Assign #s</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={<Binary size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={() => setEpgMatchModalOpen(true)}
@ -488,16 +470,16 @@ const ChannelTableHeader = ({
? `Auto-Match (${selectedTableIds.length} selected)`
: 'Auto-Match EPG'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={<Settings size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={() => setGroupManagerOpen(true)}
>
<Text size="xs">Edit Groups</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
</Box>

View file

@ -1,28 +1,14 @@
import React, {
useState,
useCallback,
useEffect,
useRef,
useMemo,
memo,
} from 'react';
import {
Box,
TextInput,
Select,
NumberInput,
Tooltip,
Center,
Skeleton,
} from '@mantine/core';
import API from '../../../api';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import { Box, NumberInput, Select, Skeleton, TextInput, Tooltip, } from '@mantine/core';
import useChannelsTableStore from '../../../store/channelsTable';
import useLogosStore from '../../../store/logos';
import {
OVERRIDABLE_FIELDS,
normalizeFieldValue,
} from '../../../utils/forms/ChannelUtils.js';
import { requeryChannels, updateChannel, } from '../../../utils/forms/ChannelUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
buildInlinePatch,
getEpgOptions,
getLogoOptions,
} from '../../../utils/tables/ChannelsTableUtils.js';
// Surfaces server-side validation failures so the user knows the
// inline edit was rejected (otherwise the cell silently reverts).
@ -43,30 +29,6 @@ export const notifyInlineSaveError = (columnId, error) => {
});
};
// 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.
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,
};
};
// Lightweight wrapper that only renders full editable cell when unlocked
// This prevents 250+ heavy component instances when table is locked
const EditableCellWrapper = memo(
@ -151,7 +113,7 @@ const EditableTextCellInner = ({ row, column, getValue, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(row.original, column.id, newValue)
);
previousValue.current = newValue;
@ -298,7 +260,7 @@ const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(row.original, column.id, newValue)
);
previousValue.current = newValue;
@ -309,7 +271,7 @@ const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
// If channel_number was changed, refetch to reorder the table
if (column.id === 'channel_number') {
await API.requeryChannels();
await requeryChannels();
onBlur();
}
}
@ -416,7 +378,7 @@ const EditableGroupCellInner = ({
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'channel_group_id',
@ -591,7 +553,7 @@ const EditableEPGCellInner = ({
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'epg_data_id',
@ -620,51 +582,7 @@ const EditableEPGCellInner = ({
// Build EPG options
const epgOptions = useMemo(() => {
const options = [{ value: 'null', label: 'Not Assigned' }];
// Convert tvgsById to an array and sort by EPG source name, then by tvg_id
const tvgsArray = Object.values(tvgsById);
tvgsArray.sort((a, b) => {
const aEpgName =
a.epg_source && epgs[a.epg_source]
? epgs[a.epg_source].name
: a.epg_source || '';
const bEpgName =
b.epg_source && epgs[b.epg_source]
? epgs[b.epg_source].name
: b.epg_source || '';
const epgCompare = aEpgName.localeCompare(bEpgName);
if (epgCompare !== 0) return epgCompare;
// Secondary sort by tvg_id
return (a.tvg_id || '').localeCompare(b.tvg_id || '');
});
tvgsArray.forEach((tvg) => {
const epgSourceName =
tvg.epg_source && epgs[tvg.epg_source]
? epgs[tvg.epg_source].name
: tvg.epg_source;
const tvgName = tvg.name;
// Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
let label;
if (epgSourceName && tvg.tvg_id) {
label = `${epgSourceName} | ${tvg.tvg_id}`;
if (tvgName && tvgName !== tvg.tvg_id) {
label += ` | ${tvgName}`;
}
} else if (tvgName) {
label = tvgName;
} else {
label = `ID: ${tvg.id}`;
}
options.push({
value: String(tvg.id),
label: label,
});
});
return options;
return getEpgOptions(tvgsById, epgs);
}, [tvgsById, epgs]);
return (
@ -761,7 +679,7 @@ const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'logo_id',
@ -790,27 +708,7 @@ const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
// Build logo options with logo data
const logoOptions = useMemo(() => {
const options = [
{
value: 'null',
label: 'Default',
logo: null,
},
];
// Convert channelLogos object to array and sort by name
const logosArray = Object.values(channelLogos);
logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
logosArray.forEach((logo) => {
options.push({
value: String(logo.id),
label: logo.name || `Logo ${logo.id}`,
logo: logo,
});
});
return options;
return getLogoOptions(channelLogos);
}, [channelLogos]);
// Get display text for the current logo

View file

@ -0,0 +1,693 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() }));
vi.mock('../../../../store/auth', () => ({ default: vi.fn() }));
vi.mock('../../../../store/warnings', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../../utils/tables/ChannelsTableUtils.js', () => ({
addChannelProfile: vi.fn(),
deleteChannelProfile: vi.fn(),
}));
// Child component mocks
vi.mock('../../../forms/AssignChannelNumbers', () => ({
default: ({ isOpen, onClose }) =>
isOpen ? (
<div data-testid="assign-numbers-modal">
<button onClick={onClose}>Close Assign</button>
</div>
) : null,
}));
vi.mock('../../../forms/GroupManager', () => ({
default: ({ isOpen, onClose }) =>
isOpen ? (
<div data-testid="group-manager-modal">
<button onClick={onClose}>Close Group Manager</button>
</div>
) : null,
}));
vi.mock('../../../modals/ProfileModal', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="profile-modal">
<button onClick={onClose}>Close Profile Modal</button>
</div>
) : null,
renderProfileOption: vi.fn(() => () => null),
}));
vi.mock('../../../modals/EPGMatchModal', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="epg-match-modal">
<button onClick={onClose}>Close EPG Match</button>
</div>
) : null,
}));
vi.mock('../../../ConfirmationDialog', () => ({
default: ({ opened, onClose, onConfirm, title, loading }) =>
opened ? (
<div data-testid="confirmation-dialog">
<span data-testid="dialog-title">{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm} disabled={loading}>
Confirm
</button>
<button data-testid="cancel-btn" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color, variant }) => (
<button
data-testid="action-icon"
onClick={onClick}
disabled={disabled}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, variant, color }) => (
<button
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-color={color}
>
{children}
</button>
),
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Menu: Object.assign(
({ children }) => <div data-testid="menu">{children}</div>,
{
Target: ({ children }) => <div>{children}</div>,
Dropdown: ({ children }) => (
<div data-testid="menu-dropdown">{children}</div>
),
Item: ({ children, onClick, disabled }) => (
<button data-testid="menu-item" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Divider: () => <hr data-testid="menu-divider" />,
Label: ({ children }) => <div data-testid="menu-label">{children}</div>,
}
),
MenuDivider: () => <hr data-testid="menu-divider" />,
MenuDropdown: ({ children }) => (
<div data-testid="menu-dropdown">{children}</div>
),
MenuItem: ({ children, onClick, disabled }) => (
<button data-testid="menu-item" onClick={onClick} disabled={disabled}>
{children}
</button>
),
MenuLabel: ({ children }) => <div data-testid="menu-label">{children}</div>,
MenuTarget: ({ children }) => <div>{children}</div>,
Popover: ({ children, opened }) => (
<div data-testid="popover" data-opened={opened}>
{children}
</div>
),
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
PopoverTarget: ({ children }) => <div>{children}</div>,
Select: ({ value, onChange, data }) => (
<select
data-testid="profile-select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
Text: ({ children, c }) => (
<span data-testid="text" data-color={c}>
{children}
</span>
),
TextInput: ({ value, onChange, placeholder }) => (
<input
data-testid="text-input"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: () => ({
tailwind: {
green: { 5: 'green.5' },
yellow: { 5: 'yellow.5' },
},
palette: { custom: {} },
}),
}));
// lucide-react
vi.mock('lucide-react', () => ({
ArrowDown01: () => <svg data-testid="icon-arrow-down-01" />,
Binary: () => <svg data-testid="icon-binary" />,
CircleCheck: () => <svg data-testid="icon-circle-check" />,
EllipsisVertical: () => <svg data-testid="icon-ellipsis" />,
Eye: () => <svg data-testid="icon-eye" />,
EyeOff: () => <svg data-testid="icon-eye-off" />,
Filter: () => <svg data-testid="icon-filter" />,
Lock: () => <svg data-testid="icon-lock" />,
LockOpen: () => <svg data-testid="icon-lock-open" />,
Pin: () => <svg data-testid="icon-pin" />,
PinOff: () => <svg data-testid="icon-pin-off" />,
Settings: () => <svg data-testid="icon-settings" />,
Square: () => <svg data-testid="icon-square" />,
SquareCheck: () => <svg data-testid="icon-square-check" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
}));
// 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(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByTestId('profile-select')).toBeInTheDocument();
});
it('renders profile options in the select', () => {
setupMocks();
render(<ChannelTableHeader {...makeDefaultProps()} />);
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(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
});
it('renders the Delete button', () => {
setupMocks();
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('renders the Add button', () => {
setupMocks();
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Add')).toBeInTheDocument();
});
it('does not show Editing Mode text when not unlocked', () => {
setupMocks({ isUnlocked: false });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.queryByText('Editing Mode')).not.toBeInTheDocument();
});
it('shows Editing Mode text when unlocked', () => {
setupMocks({ isUnlocked: true });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Editing Mode')).toBeInTheDocument();
});
});
// Profile select
describe('profile select', () => {
it('calls setSelectedProfileId when profile is changed', () => {
const { mockSetSelectedProfileId } = setupMocks();
render(<ChannelTableHeader {...makeDefaultProps()} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
expect(screen.getByText('Show Disabled')).toBeInTheDocument();
});
it('calls setShowOnlyStreamlessChannels when Only Empty Channels is clicked', () => {
const props = makeDefaultProps({ showOnlyStreamlessChannels: false });
setupMocks();
render(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...props} />);
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(<ChannelTableHeader {...makeDefaultProps({ selectedTableIds: [] })} />);
expect(screen.getByText('Edit')).toBeDisabled();
});
it('Edit button is enabled when rows are selected and user is admin', () => {
setupMocks({ userLevel: ADMIN });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'] })}
/>
);
expect(screen.getByText('Edit')).not.toBeDisabled();
});
it('Edit button is disabled for non-admin users even with selection', () => {
setupMocks({ userLevel: STANDARD });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'] })}
/>
);
expect(screen.getByText('Edit')).toBeDisabled();
});
it('calls editChannel when Edit is clicked', () => {
const editChannel = vi.fn();
setupMocks({ userLevel: ADMIN });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'], editChannel })}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(editChannel).toHaveBeenCalled();
});
it('Delete button is disabled when no rows are selected', () => {
setupMocks();
render(<ChannelTableHeader {...makeDefaultProps({ selectedTableIds: [] })} />);
expect(screen.getByText('Delete')).toBeDisabled();
});
it('calls deleteChannels when Delete is clicked', () => {
const deleteChannels = vi.fn();
setupMocks({ userLevel: ADMIN });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'], deleteChannels })}
/>
);
fireEvent.click(screen.getByText('Delete'));
expect(deleteChannels).toHaveBeenCalled();
});
it('Add button is disabled for non-admin users', () => {
setupMocks({ userLevel: STANDARD });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Add')).toBeDisabled();
});
it('calls editChannel with forceAdd option when Add is clicked', () => {
const editChannel = vi.fn();
setupMocks({ userLevel: ADMIN });
render(<ChannelTableHeader {...makeDefaultProps({ editChannel })} />);
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(<ChannelTableHeader {...makeDefaultProps({ table })} />);
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(<ChannelTableHeader {...makeDefaultProps({ table })} />);
expect(screen.getByText('Unpin Headers')).toBeInTheDocument();
});
it('calls setIsUnlocked when Lock/Unlock Table is clicked', () => {
const { mockSetIsUnlocked } = setupMocks({ isUnlocked: false });
render(<ChannelTableHeader {...makeDefaultProps()} />);
fireEvent.click(screen.getByText('Unlock for Editing'));
expect(mockSetIsUnlocked).toHaveBeenCalledWith(true);
});
it('shows "Lock Table" when isUnlocked is true', () => {
setupMocks({ isUnlocked: true });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Lock Table')).toBeInTheDocument();
});
it('Assign #s menu item is disabled when no rows are selected', () => {
setupMocks();
render(<ChannelTableHeader {...makeDefaultProps({ selectedTableIds: [] })} />);
expect(screen.getByText('Assign #s').closest('button')).toBeDisabled();
});
it('opens AssignChannelNumbersForm when Assign #s is clicked', () => {
setupMocks({ userLevel: ADMIN });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'] })}
/>
);
fireEvent.click(screen.getByText('Assign #s'));
expect(screen.getByTestId('assign-numbers-modal')).toBeInTheDocument();
});
it('closes AssignChannelNumbersForm when onClose fires', () => {
setupMocks({ userLevel: ADMIN });
render(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1'] })}
/>
);
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(<ChannelTableHeader {...makeDefaultProps()} />);
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(
<ChannelTableHeader
{...makeDefaultProps({ selectedTableIds: ['ch-1', 'ch-2'] })}
/>
);
expect(screen.getByText('Auto-Match (2 selected)')).toBeInTheDocument();
});
it('opens GroupManager when Edit Groups is clicked', () => {
setupMocks({ userLevel: ADMIN });
render(<ChannelTableHeader {...makeDefaultProps()} />);
fireEvent.click(screen.getByText('Edit Groups'));
expect(screen.getByTestId('group-manager-modal')).toBeInTheDocument();
});
it('closes GroupManager when onClose fires', () => {
setupMocks({ userLevel: ADMIN });
render(<ChannelTableHeader {...makeDefaultProps()} />);
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(<ChannelTableHeader {...makeDefaultProps()} />);
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(<ChannelTableHeader {...makeDefaultProps()} />);
// 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(<ChannelTableHeader {...makeDefaultProps()} />);
// 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(<ChannelTableHeader {...makeDefaultProps()} />);
// 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(<ChannelTableHeader {...makeDefaultProps()} />);
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(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Unlock for Editing').closest('button')).toBeDisabled();
});
it('Edit Groups is disabled for non-admin', () => {
setupMocks({ userLevel: STANDARD });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Edit Groups').closest('button')).toBeDisabled();
});
it('Auto-Match EPG is disabled for non-admin', () => {
setupMocks({ userLevel: STANDARD });
render(<ChannelTableHeader {...makeDefaultProps()} />);
expect(screen.getByText('Auto-Match EPG').closest('button')).toBeDisabled();
});
});
});

View file

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

View file

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

View file

@ -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 <MultiSelectHeaderWrapper>{content}</MultiSelectHeaderWrapper>;
};
// 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 })),
}}
>
<Flex

View file

@ -0,0 +1,230 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import CustomTable from '../CustomTable';
// Child component mocks
vi.mock('../CustomTableHeader', () => ({
default: ({ headerPinned, enableDragDrop }) => (
<div
data-testid="custom-table-header"
data-header-pinned={headerPinned}
data-enable-drag-drop={enableDragDrop}
/>
),
}));
vi.mock('../CustomTableBody', () => ({
default: ({ enableDragDrop }) => (
<div
data-testid="custom-table-body"
data-enable-drag-drop={enableDragDrop}
/>
),
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Box: ({ children, className, style }) => (
<div data-testid="table-box" className={className} style={style}>
{children}
</div>
),
}));
// 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(<CustomTable table={makeTable()} />);
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(<CustomTable table={makeTable({ tableSize: 'default' })} />);
const box = screen.getByTestId('table-box');
expect(box.className).toContain('table-size-default');
});
it('applies compact table-size class when tableSize is compact', () => {
render(<CustomTable table={makeTable({ tableSize: 'compact' })} />);
const box = screen.getByTestId('table-box');
expect(box.className).toContain('table-size-compact');
});
it('applies large table-size class when tableSize is large', () => {
render(<CustomTable table={makeTable({ tableSize: 'large' })} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={table} />);
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(<CustomTable table={makeTable({ headerPinned: true })} />);
const header = screen.getByTestId('custom-table-header');
expect(header.dataset.headerPinned).toBe('true');
});
it('passes enableDragDrop to CustomTableHeader', () => {
render(<CustomTable table={makeTable({ enableDragDrop: true })} />);
const header = screen.getByTestId('custom-table-header');
expect(header.dataset.enableDragDrop).toBe('true');
});
it('passes enableDragDrop to CustomTableBody', () => {
render(<CustomTable table={makeTable({ enableDragDrop: true })} />);
const body = screen.getByTestId('custom-table-body');
expect(body.dataset.enableDragDrop).toBe('true');
});
it('passes false for enableDragDrop by default', () => {
render(<CustomTable table={makeTable()} />);
expect(
screen.getByTestId('custom-table-header').dataset.enableDragDrop
).toBe('false');
expect(
screen.getByTestId('custom-table-body').dataset.enableDragDrop
).toBe('false');
});
});
});

View file

@ -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 }) => (
<div
className={className}
style={style}
onClick={onClick}
onMouseDown={onMouseDown}
{...rest}
>
{children}
</div>
),
Flex: ({ children, align, style }) => (
<div data-align={align} style={style}>
{children}
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
GripVertical: ({ size, opacity }) => (
<svg data-testid="grip-vertical" data-size={size} data-opacity={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(() => <div data-testid="expanded-row" />),
renderBodyCell: vi.fn(({ cell }) => (
<span data-testid={`cell-${cell.id}`}>{cell.id}</span>
)),
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(<CustomTableBody {...defaultProps()} />);
expect(document.querySelector('.tbody')).toBeInTheDocument();
});
it('renders a row for each entry in getRowModel', () => {
render(<CustomTableBody {...defaultProps()} />);
expect(document.querySelectorAll('.tr')).toHaveLength(2);
});
it('renders no rows when getRowModel returns empty', () => {
const props = defaultProps({
getRowModel: vi.fn(() => ({ rows: [] })),
});
render(<CustomTableBody {...props} />);
expect(document.querySelectorAll('.tr')).toHaveLength(0);
});
it('applies tr-even class to even-indexed rows', () => {
render(<CustomTableBody {...defaultProps()} />);
const rows = document.querySelectorAll('.tr');
expect(rows[0].classList.contains('tr-even')).toBe(true);
});
it('applies tr-odd class to odd-indexed rows', () => {
render(<CustomTableBody {...defaultProps()} />);
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 }) => <span>{cell.id}</span>);
render(<CustomTableBody {...defaultProps({ renderBodyCell })} />);
// 2 rows × 2 cells each = 4 calls
expect(renderBodyCell).toHaveBeenCalledTimes(4);
});
it('renders td containers for each visible cell', () => {
render(<CustomTableBody {...defaultProps()} />);
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(<CustomTableBody {...defaultProps({ getRowStyles })} />);
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(<CustomTableBody {...props} />);
fireEvent.click(document.querySelector('.tr'));
expect(handleRowClick).toHaveBeenCalledWith(42, expect.any(Object));
});
it('does not throw when handleRowClickRef is null', () => {
render(
<CustomTableBody {...defaultProps({ handleRowClickRef: null })} />
);
expect(() =>
fireEvent.click(document.querySelector('.tr'))
).not.toThrow();
});
it('prevents default on mousedown with shift key', () => {
render(<CustomTableBody {...defaultProps()} />);
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(() => (
<div data-testid="expanded-content">Expanded!</div>
));
const props = defaultProps({
getRowModel: vi.fn(() => ({ rows: [row] })),
expandedRowIds: [1],
expandedRowRenderer,
});
render(<CustomTableBody {...props} />);
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(() => (
<div data-testid="expanded-content">Expanded!</div>
));
const props = defaultProps({
getRowModel: vi.fn(() => ({ rows: [row] })),
expandedRowIds: [],
expandedRowRenderer,
});
render(<CustomTableBody {...props} />);
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(() => <div />);
const props = defaultProps({
getRowModel: vi.fn(() => ({ rows: [row] })),
expandedRowIds: [1],
expandedRowRenderer,
});
render(<CustomTableBody {...props} />);
expect(expandedRowRenderer).toHaveBeenCalledWith({ row });
});
});
// Drag and drop
describe('drag and drop', () => {
it('does not render grip handle when enableDragDrop is false', () => {
render(<CustomTableBody {...defaultProps({ enableDragDrop: false })} />);
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(<CustomTableBody {...defaultProps({ enableDragDrop: true })} />);
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(<CustomTableBody {...props} />);
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(<CustomTableBody {...props} />);
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(<CustomTableBody {...props} />);
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 }) => <span>{cell.id}</span>);
const original = { id: 1 };
const row = { id: 'row-1', original, getVisibleCells: () => [] };
const props = defaultProps({
getRowModel: vi.fn(() => ({ rows: [row] })),
renderBodyCell,
});
const { rerender } = render(<CustomTableBody {...props} />);
const callCount = renderBodyCell.mock.calls.length;
// Rerender with same original reference
rerender(<CustomTableBody {...props} />);
expect(renderBodyCell.mock.calls.length).toBe(callCount);
});
});
});

View file

@ -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 }) => (
<div data-testid="multi-select-wrapper">{children}</div>
),
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Box: ({ children, className, style, 'data-header-pinned': pinned }) => (
<div
className={className}
style={style}
data-header-pinned={pinned}
data-testid={className?.includes('thead') ? 'thead' : undefined}
>
{children}
</div>
),
Center: ({ children, style }) => <div style={style}>{children}</div>,
Checkbox: ({ checked, indeterminate, onChange, size }) => (
<input
type="checkbox"
data-testid="select-all-checkbox"
checked={checked}
ref={(el) => {
if (el) el.indeterminate = !!indeterminate;
}}
onChange={onChange}
data-size={size}
/>
),
Flex: ({ children, align, style }) => (
<div style={style} data-align={align}>
{children}
</div>
),
}));
// 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(<CustomTableHeader {...defaultProps()} />);
expect(screen.getByTestId('thead')).toBeInTheDocument();
});
it('renders header cells for each header in the group', () => {
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () =>
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(<CustomTableHeader {...defaultProps()} />);
expect(
screen.getAllByTestId('multi-select-wrapper').length
).toBeGreaterThan(0);
});
});
// headerPinned
describe('headerPinned', () => {
it('sets data-header-pinned="true" when headerPinned is true', () => {
render(<CustomTableHeader {...defaultProps({ headerPinned: true })} />);
expect(screen.getByTestId('thead')).toHaveAttribute(
'data-header-pinned',
'true'
);
});
it('sets data-header-pinned="false" when headerPinned is false', () => {
render(<CustomTableHeader {...defaultProps({ headerPinned: false })} />);
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(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => selectHeaderGroups(),
})}
/>
);
expect(screen.getByTestId('select-all-checkbox')).toBeInTheDocument();
});
it('checkbox is unchecked when no rows are selected', () => {
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => selectHeaderGroups(),
allRowIds: ['1', '2'],
selectedTableIds: [],
})}
/>
);
expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked();
});
it('checkbox is unchecked when allRowIds is empty', () => {
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => selectHeaderGroups(),
allRowIds: [],
selectedTableIds: [],
})}
/>
);
expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked();
});
it('checkbox is checked when all rows are selected', () => {
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => 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(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => 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(() => (
<span data-testid="custom-header">Custom Name</span>
));
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => 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(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () => makeHeaderGroups([makeHeader('name')]),
headerCellRenderFns: {},
})}
/>
);
expect(screen.getByText('NAME')).toBeInTheDocument();
});
});
// resize handle
describe('resize handle', () => {
it('renders resize handle when column canResize is true', () => {
render(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () =>
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(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () =>
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(
<CustomTableHeader
{...defaultProps({
getHeaderGroups: () =>
makeHeaderGroups([
makeHeader('name', { canResize: true, isResizing: true }),
]),
})}
/>
);
expect(document.querySelector('.resizer.isResizing')).toBeTruthy();
});
});
});

View file

@ -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
}) => (
<div data-testid="multiselect" data-value={JSON.stringify(value)}>
{label && <label>{label}</label>}
<input
data-testid="multiselect-input"
onChange={(e) => onChange?.(e.target.value ? [e.target.value] : [])}
/>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</div>
);
MockMultiSelect.displayName = 'MultiSelect';
return {
MultiSelect: MockMultiSelect,
Box: ({ children, style, ...props }) => (
<div data-testid="box" style={style} {...props}>
{children}
</div>
),
Flex: ({ children, style }) => (
<div data-testid="flex" style={style}>
{children}
</div>
),
Pill: ({
children,
onRemove,
onClick,
withRemoveButton,
removeButtonProps,
}) => (
<span data-testid="pill" onClick={onClick}>
{children}
{withRemoveButton && (
<button
data-testid="pill-remove"
onClick={removeButtonProps?.onClick ?? onRemove}
>
×
</button>
)}
</span>
),
Tooltip: ({ children, label }) => (
<div
data-testid="tooltip"
data-label={typeof label === 'string' ? label : 'jsx'}
>
{label}
{children}
</div>
),
};
});
// 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(
<MultiSelectHeaderWrapper>
<div data-testid="plain-child">Hello</div>
</MultiSelectHeaderWrapper>
);
expect(screen.getByTestId('plain-child')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('renders a plain text node unchanged', () => {
render(
<MultiSelectHeaderWrapper>{'just text'}</MultiSelectHeaderWrapper>
);
expect(screen.getByText('just text')).toBeInTheDocument();
});
it('recursively passes through nested non-MultiSelect elements', () => {
render(
<MultiSelectHeaderWrapper>
<div>
<span data-testid="nested">Nested</span>
</div>
</MultiSelectHeaderWrapper>
);
expect(screen.getByTestId('nested')).toBeInTheDocument();
});
});
// MultiSelect with no selections
describe('MultiSelect with no selections', () => {
it('renders the MultiSelect directly when value is empty', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={[]} data={makeData()} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.getByTestId('multiselect')).toBeInTheDocument();
});
it('does not render pills when value is empty', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={[]} data={makeData()} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.queryByTestId('pill')).not.toBeInTheDocument();
});
it('does not render a tooltip when value is empty', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={[]} data={makeData()} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument();
});
it('handles undefined value as no selections', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect data={makeData()} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
const pill = screen.getByTestId('pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveTextContent('Label 0');
});
it('renders exactly one pill for a single selection', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.getAllByTestId('pill')).toHaveLength(1);
});
it('renders a tooltip wrapping the pill area', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
});
it('still renders the underlying MultiSelect', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.getByTestId('multiselect')).toBeInTheDocument();
});
it('falls back to the raw value when label is not found in data', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={['unknown-val']} data={[]} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={['val-0']} data={data} onChange={onChange} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.getAllByTestId('pill')).toHaveLength(2);
});
it('shows the first label in the first pill', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
const pills = screen.getAllByTestId('pill');
expect(pills[0]).toHaveTextContent('Label 0');
});
it('shows "+N" overflow count in the second pill', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
// 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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={onChange} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={onChange} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={value} data={data} onChange={vi.fn()} />
</MultiSelectHeaderWrapper>
);
expect(screen.queryByText(/more$/)).not.toBeInTheDocument();
});
});
// Recursive enhancement
describe('recursive MultiSelect enhancement', () => {
it('enhances a MultiSelect nested inside a wrapper div', () => {
render(
<MultiSelectHeaderWrapper>
<div>
<MultiSelect
value={['val-0']}
data={makeData()}
onChange={vi.fn()}
/>
</div>
</MultiSelectHeaderWrapper>
);
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(
<MultiSelectHeaderWrapper>
<MultiSelect value={['val-0']} data={makeData()} />
</MultiSelectHeaderWrapper>
);
expect(() =>
fireEvent.click(screen.getByTestId('pill-remove'))
).not.toThrow();
});
it('does not throw when clearAll is triggered without onChange', () => {
render(
<MultiSelectHeaderWrapper>
<MultiSelect value={['val-0', 'val-1']} data={makeData()} />
</MultiSelectHeaderWrapper>
);
const removeButtons = screen.getAllByTestId('pill-remove');
expect(() => fireEvent.click(removeButtons[1])).not.toThrow();
});
});
});

View file

@ -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(() => <span data-testid="flex-rendered" />),
}));
// Hook mocks
vi.mock('../../../../hooks/useTablePreferences', () => ({
default: vi.fn(),
}));
// Child component mocks
vi.mock('../CustomTable', () => ({
default: () => <div data-testid="custom-table" />,
}));
vi.mock('../CustomTableHeader', () => ({
default: () => <div data-testid="custom-table-header" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Center: ({ children, style, onClick }) => (
<div data-testid="center" style={style} onClick={onClick}>
{children}
</div>
),
Checkbox: ({ checked, onChange }) => (
<input
type="checkbox"
data-testid="checkbox"
checked={!!checked}
onChange={onChange || (() => {})}
/>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
ChevronDown: () => <svg data-testid="icon-chevron-down" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
}));
// 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(
() => <span data-testid="custom-cell" />
);
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(<TestWrapper />);
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(<TestWrapper />);
// 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(<TestWrapper />);
// 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(
<span data-testid="flex-rendered" />
);
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(
<span data-testid="flex-rendered" />
);
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();
});
});
});
});

View file

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

View file

@ -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 (
<Stack spacing={2}>
@ -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 (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
}
};
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
</Text>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Button
leftSection={<SquarePlus size={18} />}
rightSection={<ChevronDown size={16} />}
@ -648,13 +558,11 @@ const EPGsTable = () => {
>
Add EPG
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={createStandardEPG}>
Standard EPG Source
</Menu.Item>
<Menu.Item onClick={createDummyEPG}>Dummy EPG Source</Menu.Item>
</Menu.Dropdown>
</MenuTarget>
<MenuDropdown>
<MenuItem onClick={createStandardEPG}>Standard EPG Source</MenuItem>
<MenuItem onClick={createDummyEPG}>Dummy EPG Source</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
@ -668,11 +576,8 @@ const EPGsTable = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 0,
// gap: 1,
}}
></Box>
</Paper>

View file

@ -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 (
<Tooltip
@ -528,7 +480,7 @@ const LogosTable = () => {
theme={theme}
row={row}
editLogo={editLogo}
deleteLogo={deleteLogo}
handleDeleteLogo={handleDeleteLogo}
/>
),
},
@ -536,7 +488,7 @@ const LogosTable = () => {
[
theme,
editLogo,
deleteLogo,
handleDeleteLogo,
selectedRows,
handleSelectRow,
handleSelectAll,

View file

@ -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 }) => (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>{label}{value ? ':' : ''}</Text>
{value && <Text size="xs">{value}</Text>}
</Flex>
);
// 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 }) => (
<Box>
<Flex direction="column" gap={2}>{children}</Flex>
</Box>
);
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)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
@ -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}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
@ -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 (
<StatusBox><StatusRow label="Initializing refresh..." /></StatusBox>
);
case 'downloading':
return buildDownloadingStats(data);
case 'processing_groups':
return buildGroupProcessingStats(data);
return (
<StatusBox>
<StatusRow label="Downloading" value={`${content.progress}%`} />
<StatusRow label="Speed" value={content.speed} />
<StatusRow label="Time left" value={content.timeRemaining} />
</StatusBox>
);
case 'groups':
return (
<StatusBox>
<StatusRow label="Processing groups" value={`${content.progress}%`} />
{content.elapsedTime && <StatusRow label="Elapsed" value={content.elapsedTime} />}
{content.groupsProcessed && <StatusRow label="Groups" value={content.groupsProcessed} />}
</StatusBox>
);
case 'parsing':
return buildParsingStats(data);
return (
<StatusBox>
<StatusRow label="Parsing" value={`${content.progress}%`} />
{content.elapsedTime && <StatusRow label="Elapsed" value={content.elapsedTime} />}
{content.timeRemaining && <StatusRow label="Remaining" value={content.timeRemaining} />}
{content.streamsProcessed && <StatusRow label="Streams" value={content.streamsProcessed} />}
</StatusBox>
);
case 'error':
return (
<StatusBox>
<Text size="xs" fw={500} color="red">Error:</Text>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
{content.error || 'Unknown error occurred'}
</Text>
</StatusBox>
);
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 (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Downloading:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Speed:
</Text>
<Text size="xs">{speed}</Text>
</Flex>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Time left:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
</Flex>
</Box>
);
};
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 (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Processing groups:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{elapsedTime && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.groups_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>
Groups:
</Text>
<Text size="xs">{data.groups_processed}</Text>
</Flex>
)}
</Flex>
</Box>
);
};
const buildErrorStats = (data) => {
return (
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500} color="red">
Error:
</Text>
</Flex>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
{data.error || 'Unknown error occurred'}
</Text>
</Flex>
</Box>
);
};
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 (
<Box>
<Flex direction="column" gap={2}>
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Parsing:
</Text>
<Text size="xs">{parseInt(data.progress)}%</Text>
</Flex>
{data.elapsed_time && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Elapsed:
</Text>
<Text size="xs">{elapsedTime}</Text>
</Flex>
)}
{data.time_remaining && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '60px' }}>
Remaining:
</Text>
<Text size="xs">{timeRemaining}</Text>
</Flex>
)}
{data.streams_processed && (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500} style={{ width: '80px' }}>
Streams:
</Text>
<Text size="xs">{data.streams_processed}</Text>
</Flex>
)}
</Flex>
</Box>
);
};
const buildInitializingStats = () => {
return (
<Box>
<Flex direction="column" gap={2}>
<Flex align="center">
<Text size="xs" fw={500}>
Initializing refresh...
</Text>
</Flex>
</Flex>
</Box>
);
};
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 (
<Tooltip
@ -764,9 +572,9 @@ const M3UTable = () => {
},
],
[
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 (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
}
};
const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange);
const renderBodyCell = useCallback(({ cell, row }) => {
switch (cell.column.id) {
@ -899,9 +633,9 @@ const M3UTable = () => {
<RowActions
tableSize={tableSize}
editPlaylist={editPlaylist}
deletePlaylist={deletePlaylist}
handleDeletePlaylist={handleDeletePlaylist}
row={row}
refreshPlaylist={refreshPlaylist}
handleRefreshPlaylist={handleRefreshPlaylist}
/>
);
}
@ -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 = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 0,
// gap: 1,
}}
></Box>
</Paper>
@ -1115,7 +845,7 @@ This action cannot be undone.`}
}}
>
<Text size="sm" fw={600}>
{`${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.`}
</Text>
</div>
) : null}

View file

@ -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 (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
{header.column.columnDef.sortable && (
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange(header.id),
size: 14,
})}
</Center>
)}
</Group>
);
};
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);
}
};

View file

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

View file

@ -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 (
<>
<ActionIcon
@ -47,7 +45,7 @@ const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => {
size="sm"
color="red.9"
disabled={row.original.locked}
onClick={() => deleteStreamProfile(row.original.id)}
onClick={() => handleDeleteStreamProfile(row.original.id)}
>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
@ -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 = () => {
<RowActions
row={row}
editStreamProfile={editStreamProfile}
deleteStreamProfile={deleteStreamProfile}
handleDeleteStreamProfile={handleDeleteStreamProfile}
/>
);
}
@ -255,11 +235,8 @@ const StreamProfiles = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>

View file

@ -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)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<MenuDropdown>
<MenuItem leftSection={<Copy size="14" />}>
<UnstyledButton
variant="unstyled"
size="xs"
@ -166,17 +176,17 @@ const StreamRowActions = ({
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item onClick={onEdit} disabled={!row.original.is_custom}>
</MenuItem>
<MenuItem onClick={onEdit} disabled={!row.original.is_custom}>
<Text size="xs">Edit</Text>
</Menu.Item>
<Menu.Item onClick={onDelete} disabled={!row.original.is_custom}>
</MenuItem>
<MenuItem onClick={onDelete} disabled={!row.original.is_custom}>
<Text size="xs">Delete Stream</Text>
</Menu.Item>
<Menu.Item onClick={onPreview}>
</MenuItem>
<MenuItem onClick={onPreview}>
<Text size="xs">Preview Stream</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</>
);
@ -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 }) => {
</Text>
);
// 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 (
<Tooltip
@ -593,15 +560,7 @@ const StreamsTable = ({ onReady }) => {
// 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 }) => {
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Tooltip label="Filters" openDelay={500}>
<Button size="xs" variant="default">
<Filter size={18} />
</Button>
</Tooltip>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
onClick={toggleUnassignedOnly}
leftSection={
filters.unassigned === true ? (
@ -1564,8 +1446,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Only Unassociated</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={toggleHideStale}
leftSection={
filters.hide_stale === true ? (
@ -1576,8 +1458,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Hide Stale</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
<Tooltip label="Create a new custom stream" openDelay={500}>
@ -1603,7 +1485,7 @@ const StreamsTable = ({ onReady }) => {
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteStreams}
onClick={handleDeleteStreams}
disabled={selectedStreamIds.length == 0}
>
Delete
@ -1611,17 +1493,17 @@ const StreamsTable = ({ onReady }) => {
</Tooltip>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Tooltip label="Table Settings" openDelay={500}>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Tooltip>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Label>Toggle Columns</Menu.Label>
<Menu.Item
<MenuDropdown>
<MenuLabel>Toggle Columns</MenuLabel>
<MenuItem
onClick={() => toggleColumnVisibility('name')}
leftSection={
columnVisibility.name !== false ? (
@ -1632,8 +1514,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Name</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('group')}
leftSection={
columnVisibility.group !== false ? (
@ -1644,8 +1526,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Group</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('m3u')}
leftSection={
columnVisibility.m3u !== false ? (
@ -1656,8 +1538,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">M3U</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('tvg_id')}
leftSection={
columnVisibility.tvg_id !== false ? (
@ -1668,8 +1550,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">TVG-ID</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('stats')}
leftSection={
columnVisibility.stats !== false ? (
@ -1680,15 +1562,15 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Stats</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Item
</MenuItem>
<MenuDivider />
<MenuItem
onClick={resetColumnVisibility}
leftSection={<RotateCcw size={18} />}
>
<Text size="xs">Reset to Default</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
</Flex>

View file

@ -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 (
<>
<ActionIcon
@ -38,7 +47,7 @@ const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
onClick={() => handleDeleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
@ -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 = () => {
<RowActions
row={row}
editUserAgent={editUserAgent}
deleteUserAgent={deleteUserAgent}
handleDeleteUserAgent={handleDeleteUserAgent}
/>
);
}

View file

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

View file

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

View file

@ -0,0 +1,796 @@
import React from 'react';
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ChannelStreams from '../ChannelTableStreams';
// Store mocks
vi.mock('../../../store/channelsTable', () => ({ 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 }) => (
<div data-testid="dnd-context" data-ondragend={typeof onDragEnd}>
{children}
</div>
)),
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 }) => (
<div data-testid="sortable-context">{children}</div>
),
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 }) => (
<button data-testid="action-icon" onClick={onClick} {...rest}>
{children}
</button>
),
Badge: ({ children, color, onClick, style }) => (
<span
data-testid="badge"
data-color={color}
onClick={onClick}
style={style}
>
{children}
</span>
),
Box: ({ children, style, className, ...rest }) => (
<div style={style} className={className} {...rest}>
{children}
</div>
),
Button: ({ children, onClick, leftSection }) => (
<button data-testid="button" onClick={onClick}>
{leftSection}
{children}
</button>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Collapse: ({ children, in: open }) =>
open ? <div data-testid="collapse-open">{children}</div> : null,
Flex: ({ children, style }) => (
<div style={style}>{children}</div>
),
Group: ({ children }) => <div>{children}</div>,
Text: ({ children, size }) => (
<span data-testid="text" data-size={size}>
{children}
</span>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: vi.fn(() => ({
tailwind: { red: { 6: '#fa5252' } },
})),
}));
// lucide-react
vi.mock('lucide-react', () => ({
ChevronDown: () => <svg data-testid="icon-chevron-down" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
Eye: () => <svg data-testid="icon-eye" />,
GripHorizontal: () => <svg data-testid="icon-grip" />,
SquareMinus: ({ onClick, disabled, color }) => (
<svg
data-testid="icon-square-minus"
onClick={onClick}
data-disabled={disabled}
data-color={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(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByText('Stream One')).toBeInTheDocument();
});
it('renders the M3U account name badge', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByText('My M3U')).toBeInTheDocument();
});
it('renders "Unknown" account badge when m3u_account has no matching playlist', () => {
setupMocks({ playlists: [] });
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('renders the quality badge when stream has quality', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByText('1080p')).toBeInTheDocument();
});
it('does not render quality badge when stream has no quality', () => {
const streams = [makeStream({ quality: null })];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.queryByText('1080p')).not.toBeInTheDocument();
});
it('renders the URL badge when stream has a url', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByText('URL')).toBeInTheDocument();
});
it('does not render URL badge when stream has no url', () => {
const streams = [makeStream({ url: null })];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.queryByText('URL')).not.toBeInTheDocument();
});
it('renders "No Data" when there are no streams', () => {
setupMocks({ streams: [] });
render(<ChannelStreams channel={makeChannel([])} />);
expect(screen.getByText('No Data')).toBeInTheDocument();
});
it('renders the preview Eye action icon when stream has a url', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
});
it('renders the drag handle grip icon', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
expect(screen.getByTestId('icon-grip')).toBeInTheDocument();
});
it('renders the remove stream icon', () => {
setupMocks();
render(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel()} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.getByText('Video:')).toBeInTheDocument();
});
it('renders resolution badge', () => {
const streams = [
makeStream({ stream_stats: { resolution: '1920x1080' } }),
];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.getByText('1920x1080')).toBeInTheDocument();
});
it('renders video_bitrate badge with kbps suffix', () => {
const streams = [makeStream({ stream_stats: { video_bitrate: 5000 } })];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.getByText('5000 kbps')).toBeInTheDocument();
});
it('renders fps badge', () => {
const streams = [makeStream({ stream_stats: { source_fps: 29.97 } })];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.getByText('29.97 FPS')).toBeInTheDocument();
});
it('renders codec badge uppercased', () => {
const streams = [makeStream({ stream_stats: { video_codec: 'h264' } })];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
// 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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
expect(screen.queryByText('Show Advanced Stats')).not.toBeInTheDocument();
});
it('toggles to "Hide Advanced Stats" after clicking Show', () => {
const streams = [makeStreamWithAdvancedStats()];
setupMocks({ streams });
render(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel(streams)} />);
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 <div data-testid="dnd-context">{children}</div>;
});
render(<ChannelStreams channel={makeChannel(streams)} />);
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 <div data-testid="dnd-context">{children}</div>;
});
render(<ChannelStreams channel={makeChannel(streams)} />);
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 <div data-testid="dnd-context">{children}</div>;
});
render(<ChannelStreams channel={makeChannel(streams)} />);
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(<ChannelStreams channel={makeChannel()} />)
).not.toThrow();
});
it('handles playlists with missing ids gracefully', () => {
setupMocks({ playlists: [{ name: 'No ID Playlist' }] });
expect(() =>
render(<ChannelStreams channel={makeChannel()} />)
).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(
<ChannelStreams channel={makeChannel(streams)} />
);
expect(container.querySelector('.stale-stream-row')).toBeInTheDocument();
});
it('does not apply stale-stream-row class when is_stale is false', () => {
setupMocks();
const { container } = render(<ChannelStreams channel={makeChannel()} />);
expect(
container.querySelector('.stale-stream-row')
).not.toBeInTheDocument();
});
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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 }) => <div data-testid="center">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Text: ({ children, size, name }) => (
<span data-testid="text" data-size={size} data-name={name}>
{children}
</span>
),
}));
vi.mock('lucide-react', () => ({
ArrowUpDown: (props) => (
<svg data-testid="icon-arrow-up-down" onClick={props.onClick} />
),
ArrowUpNarrowWide: (props) => (
<svg data-testid="icon-arrow-up-narrow-wide" onClick={props.onClick} />
),
ArrowDownWideNarrow: (props) => (
<svg data-testid="icon-arrow-down-wide-narrow" onClick={props.onClick} />
),
}));
// 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);
});
});
});

View file

@ -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 ? (
<div data-testid="output-profile-form">
<span data-testid="form-profile-name">{profile?.name ?? 'new'}</span>
<button data-testid="form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, leftSection, disabled, loading }) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading}>
{leftSection}
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, style }) => <div style={style}>{children}</div>,
Switch: ({ checked, onChange, disabled }) => (
<input
data-testid="active-switch"
type="checkbox"
checked={checked ?? false}
onChange={onChange}
disabled={disabled}
/>
),
Text: ({ children, size, style }) => (
<span data-testid="text" data-size={size} style={style}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
useMantineTheme: vi.fn(() => ({
palette: { background: { paper: '#1a1a1a' } },
})),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Eye: () => <svg data-testid="icon-eye" />,
EyeOff: () => <svg data-testid="icon-eye-off" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
}));
// 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(<OutputProfiles />);
expect(screen.getByText('Add Output Profile')).toBeInTheDocument();
});
it('renders the hide/show inactive toggle button', () => {
setupMocks();
render(<OutputProfiles />);
expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
});
it('renders the custom table', () => {
setupMocks();
render(<OutputProfiles />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('does not render the form on initial load', () => {
setupMocks();
render(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
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(<OutputProfiles />);
expect(capturedTableOptions.data).toHaveLength(0);
});
});
});

View file

@ -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 ? (
<div data-testid="stream-profile-form">
<span data-testid="form-profile-name">{profile?.name ?? 'new'}</span>
<button data-testid="form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, leftSection, disabled, loading }) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading}>
{leftSection}
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, style }) => <div style={style}>{children}</div>,
Switch: ({ checked, onChange, disabled }) => (
<input
data-testid="active-switch"
type="checkbox"
checked={checked ?? false}
onChange={onChange}
disabled={disabled}
/>
),
Text: ({ children, name }) => (
<span data-testid="text" data-name={name}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
useMantineTheme: vi.fn(() => ({
palette: { background: { paper: '#1a1a1a' } },
})),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Eye: () => <svg data-testid="icon-eye" />,
EyeOff: () => <svg data-testid="icon-eye-off" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
}));
// 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(<StreamProfiles />);
expect(screen.getByText('Add Stream Profile')).toBeInTheDocument();
});
it('renders the hide/show inactive toggle button', () => {
setupMocks();
render(<StreamProfiles />);
expect(screen.getByTestId('icon-eye')).toBeInTheDocument();
});
it('renders the custom table', () => {
setupMocks();
render(<StreamProfiles />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('does not render the form on initial load', () => {
setupMocks();
render(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
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(<StreamProfiles />);
expect(capturedTableOptions.data).toHaveLength(3);
});
it('passes an empty array to the table when no profiles exist', () => {
setupMocks({ profiles: [] });
render(<StreamProfiles />);
expect(capturedTableOptions.data).toHaveLength(0);
});
});
});

View file

@ -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 ? (
<div data-testid="stream-form">
<span data-testid="form-stream-name">{stream?.name ?? 'new'}</span>
<button data-testid="form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onClose, onConfirm, title, message, confirmLabel, cancelLabel, loading }) =>
opened ? (
<div data-testid="confirm-dialog">
<span data-testid="confirm-title">{title}</span>
<span data-testid="confirm-message">{typeof message === 'string' ? message : 'message'}</span>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={loading}>
{confirmLabel}
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
{cancelLabel}
</button>
</div>
) : null,
}));
vi.mock('../../modals/CreateChannelModal', () => ({
default: ({ opened, onClose, onConfirm }) =>
opened ? (
<div data-testid="create-channel-modal">
<button data-testid="create-channel-confirm" onClick={onConfirm}>
Confirm
</button>
<button data-testid="create-channel-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, leftSection, disabled, loading }) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading}>
{leftSection}
{children}
</button>
),
Card: ({ children, style }) => (
<div data-testid="card" style={style}>{children}</div>
),
Center: ({ children, style }) => <div style={style}>{children}</div>,
Divider: ({ label }) => <hr data-label={label} />,
Flex: ({ children, style }) => (
<div style={style}>{children}</div>
),
Group: ({ children, style }) => <div style={style}>{children}</div>,
LoadingOverlay: ({ visible }) => visible ? <div data-testid="loading-overlay" /> : null,
Menu: Object.assign(
({ children }) => <div data-testid="menu">{children}</div>,
{
Target: ({ children }) => <div>{children}</div>,
Dropdown: ({ children }) => <div>{children}</div>,
Label: ({ children }) => <div data-testid="menu-label">{children}</div>,
Item: ({ children, onClick, leftSection }) => (
<button data-testid="menu-item" onClick={onClick}>
{leftSection}
{children}
</button>
),
Divider: () => <hr />,
}
),
MenuDivider: () => <hr />,
MenuDropdown: ({ children }) => <div>{children}</div>,
MenuItem: ({ children, onClick, leftSection }) => (
<button data-testid="menu-item" onClick={onClick}>
{leftSection}
{children}
</button>
),
MenuLabel: ({ children }) => <div data-testid="menu-label">{children}</div>,
MenuTarget: ({ children }) => <div>{children}</div>,
MultiSelect: ({ onChange, value, data }) => (
<select data-testid="multi-select" onChange={(e) => onChange && onChange([e.target.value])} value={value}>
{(data || []).map((d) => (
<option key={d.value ?? d} value={d.value ?? d}>{d.label ?? d}</option>
))}
</select>
),
NativeSelect: ({ onChange, value, data }) => (
<select data-testid="native-select" onChange={onChange} value={value}>
{(data || []).map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
),
Pagination: ({ total, value, onChange }) => (
<div data-testid="pagination">
<button data-testid="page-prev" onClick={() => onChange && onChange(value - 1)} disabled={value <= 1}>
Prev
</button>
<span data-testid="page-current">{value}</span>
<button data-testid="page-next" onClick={() => onChange && onChange(value + 1)} disabled={value >= total}>
Next
</button>
</div>
),
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, style }) => <div style={style}>{children}</div>,
Text: ({ children, style }) => (
<span data-testid="text" style={style}>{children}</span>
),
TextInput: ({ onChange, value, placeholder }) => (
<input
data-testid="text-input"
onChange={onChange}
value={value ?? ''}
placeholder={placeholder}
/>
),
Title: ({ children, style }) => <h3 style={style}>{children}</h3>,
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
UnstyledButton: ({ children, onClick }) => (
<button data-testid="unstyled-button" onClick={onClick}>{children}</button>
),
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: () => <svg data-testid="icon-arrow-down" />,
ArrowUpDown: () => <svg data-testid="icon-arrow-up-down" />,
ArrowUpNarrowWide: () => <svg data-testid="icon-arrow-up" />,
Copy: () => <svg data-testid="icon-copy" />,
EllipsisVertical: () => <svg data-testid="icon-ellipsis" />,
Eye: () => <svg data-testid="icon-eye" />,
EyeOff: () => <svg data-testid="icon-eye-off" />,
Filter: () => <svg data-testid="icon-filter" />,
ListPlus: () => <svg data-testid="icon-list-plus" />,
RotateCcw: () => <svg data-testid="icon-rotate-ccw" />,
Search: () => <svg data-testid="icon-search" />,
Square: () => <svg data-testid="icon-square" />,
SquareCheck: () => <svg data-testid="icon-square-check" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
}));
// 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(<StreamsTable />);
expect(screen.getByText('Streams')).toBeInTheDocument();
});
it('renders the "Create Stream" button', () => {
setupMocks();
render(<StreamsTable />);
expect(screen.getByText('Create Stream')).toBeInTheDocument();
});
it('renders the "Delete" button', () => {
setupMocks();
render(<StreamsTable />);
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('renders the "Add to Channel" button', () => {
setupMocks();
render(<StreamsTable />);
expect(screen.getByText('Add to Channel')).toBeInTheDocument();
});
it('renders "Create Channel (0)" button when no streams are selected', () => {
setupMocks({ selectedStreamIds: [] });
render(<StreamsTable />);
expect(screen.getByText('Create Channel (0)')).toBeInTheDocument();
});
it('renders "Create Channels (N)" button when multiple streams are selected', () => {
setupMocks({ selectedStreamIds: [1, 2] });
render(<StreamsTable />);
expect(screen.getByText('Create Channels (2)')).toBeInTheDocument();
});
it('does not render the stream form on initial load', () => {
setupMocks();
render(<StreamsTable />);
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(<StreamsTable />);
await waitFor(() => {
expect(screen.getByText('Getting started')).toBeInTheDocument();
});
});
it('renders the custom table when totalCount > 0', async () => {
setupMocks({ totalCount: 5, streams: [makeStream()] });
render(<StreamsTable />);
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(<StreamsTable />);
// 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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
const deleteBtn = screen.getByText('Delete').closest('button');
expect(deleteBtn).toBeDisabled();
});
it('is enabled when streams are selected', () => {
setupMocks({ selectedStreamIds: [1] });
render(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
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(<StreamsTable />);
await waitFor(() => {
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
});
it('renders current page number', async () => {
setupMocks({ totalCount: 5, streams: [makeStream()], pagination: { pageIndex: 0, pageSize: 50 } });
render(<StreamsTable />);
await waitFor(() => {
expect(screen.getByTestId('page-current')).toHaveTextContent('1');
});
});
it('renders native select for page size', async () => {
setupMocks({ totalCount: 5, streams: [makeStream()] });
render(<StreamsTable />);
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(<StreamsTable />);
// 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(<StreamsTable />);
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(<StreamsTable />);
await waitFor(() => {
expect(capturedTableOptions.data).toHaveLength(2);
});
});
it('passes manualPagination: true to useTable', async () => {
setupMocks({ totalCount: 5, streams: [makeStream()] });
render(<StreamsTable />);
await waitFor(() => {
expect(capturedTableOptions.manualPagination).toBe(true);
});
});
it('passes manualSorting: true to useTable', async () => {
setupMocks({ totalCount: 5, streams: [makeStream()] });
render(<StreamsTable />);
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(<StreamsTable />);
await waitFor(() => {
expect(capturedTableOptions.state.pagination).toEqual(pagination);
});
});
});
// Initial data fetch
describe('initial data fetch', () => {
it('calls queryStreamsTable on mount', async () => {
setupMocks();
render(<StreamsTable />);
await waitFor(() => {
expect(StreamsTableUtils.queryStreamsTable).toHaveBeenCalled();
});
});
it('calls getAllStreamIds on mount', async () => {
setupMocks();
render(<StreamsTable />);
await waitFor(() => {
expect(StreamsTableUtils.getAllStreamIds).toHaveBeenCalled();
});
});
it('calls getStreamFilterOptions on mount', async () => {
setupMocks();
render(<StreamsTable />);
await waitFor(() => {
expect(StreamsTableUtils.getStreamFilterOptions).toHaveBeenCalled();
});
});
it('calls onReady callback once data is loaded', async () => {
setupMocks();
const onReady = vi.fn();
render(<StreamsTable onReady={onReady} />);
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(<StreamsTable />);
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(<StreamsTable />);
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();
});
});
});

View file

@ -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 ? (
<div data-testid="user-agent-form">
<span data-testid="form-ua-name">{userAgent?.name ?? 'new'}</span>
<button data-testid="form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, leftSection, disabled, loading }) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading}>
{leftSection}
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, style }) => <div style={style}>{children}</div>,
Text: ({ children, name }) => (
<span data-testid="text" data-name={name}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Check: ({ color }) => <svg data-testid="icon-check" data-color={color} />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
X: ({ color }) => <svg data-testid="icon-x" data-color={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(<UserAgentsTable />);
expect(screen.getByText('Add User-Agent')).toBeInTheDocument();
});
it('renders the custom table', () => {
setupMocks();
render(<UserAgentsTable />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('does not render the form on initial load', () => {
setupMocks();
render(<UserAgentsTable />);
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(<UserAgentsTable />);
expect(capturedTableOptions.data).toHaveLength(2);
});
it('passes an empty array when no userAgents exist', () => {
setupMocks({ userAgents: [] });
render(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
const { getByTestId } = render(renderIsActiveCell(true));
expect(getByTestId('icon-check')).toBeInTheDocument();
});
it('renders X icon when is_active is false', () => {
setupMocks();
render(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
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(<UserAgentsTable />);
expect(capturedTableOptions.allRowIds).toEqual([10, 20, 30]);
});
});
});

View file

@ -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 ? (
<div data-testid="user-form">
<span data-testid="form-user-name">{user?.username ?? 'new'}</span>
<button data-testid="form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) =>
opened ? (
<div data-testid="confirm-dialog">
<span data-testid="confirm-title">{title}</span>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={loading}>
{confirmLabel}
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
{cancelLabel}
</button>
</div>
) : null,
}));
vi.mock('../CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, leftSection, disabled, loading }) => (
<button data-testid="button" onClick={onClick} disabled={disabled || loading}>
{leftSection}
{children}
</button>
),
Flex: ({ children, style }) => <div style={style}>{children}</div>,
Group: ({ children, style }) => (
<div style={style}>{children}</div>
),
LoadingOverlay: ({ visible }) => visible ? <div data-testid="loading-overlay" /> : null,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, style }) => <div style={style}>{children}</div>,
Text: ({ children, style, name }) => (
<span data-testid="text" data-name={name} style={style}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
useMantineTheme: vi.fn(() => ({
tailwind: {
yellow: { 3: '#fde047' },
red: { 6: '#dc2626' },
green: { 5: '#22c55e' },
},
})),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Eye: () => <svg data-testid="icon-eye" />,
EyeOff: () => <svg data-testid="icon-eye-off" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
}));
// 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(<UsersTable />);
expect(screen.getByText('Users')).toBeInTheDocument();
});
it('renders the "Add User" button', () => {
setupMocks();
render(<UsersTable />);
expect(screen.getByText('Add User')).toBeInTheDocument();
});
it('renders the custom table', () => {
setupMocks();
render(<UsersTable />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('does not render the user form on initial load', () => {
setupMocks();
render(<UsersTable />);
expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
});
it('does not render the confirmation dialog on initial load', () => {
setupMocks();
render(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<XCCell getValue={() => customProperties} />);
};
it('hides password by default (shows bullets)', () => {
setupMocks();
render(<UsersTable />);
const { getByText } = renderXCCell({ xc_password: 'mypassword' });
expect(getByText('••••••••')).toBeInTheDocument();
});
it('shows the password after clicking the eye toggle', () => {
setupMocks();
render(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
const { getByText } = renderXCCell({});
expect(getByText('N/A')).toBeInTheDocument();
});
it('does not render the eye toggle when password is N/A', () => {
setupMocks();
render(<UsersTable />);
const { queryByTestId } = renderXCCell({});
expect(queryByTestId('icon-eye')).not.toBeInTheDocument();
});
it('shows "N/A" when custom_properties is null', () => {
setupMocks();
render(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
const col = getCol('name');
const { getByText } = render(col.cell({ getValue: () => '' }));
expect(getByText('-')).toBeInTheDocument();
});
it('cell renders the full name when set', () => {
setupMocks();
render(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
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(<UsersTable />);
expect(capturedTableOptions.enablePagination).toBe(false);
});
it('passes enableRowSelection: false', () => {
setupMocks();
render(<UsersTable />);
expect(capturedTableOptions.enableRowSelection).toBe(false);
});
it('passes manualSorting: false', () => {
setupMocks();
render(<UsersTable />);
expect(capturedTableOptions.manualSorting).toBe(false);
});
});
});

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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 = () => {
</AccordionPanel>
</AccordionItem>
<AccordionItem value="epg-settings">
<AccordionControl>EPG</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<EpgSettingsForm
active={accordianValue === 'epg-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="system-settings">
<AccordionControl>System Settings</AccordionControl>
<AccordionPanel>

View file

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

View file

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

View file

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

View file

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

View file

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

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