From 6b9d621cf778490fcb70f3b7b1d58d610609c68b Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 04:52:07 +0000 Subject: [PATCH 001/152] feat(epg): add Schedules Direct API integration - Add EPGSource username field and SDScheduleMD5 model for MD5 delta sync - Implement full Schedules Direct fetch with SHA1 auth, token caching, 20-day schedule window, and MD5-based delta sync to minimize API usage - Add lineup manager UI allowing users to search and manage SD lineups - Add stations-only fetch on source creation for immediate EPG matching - Enforce 2-hour minimum refresh interval to respect SD rate limits, with first-fetch bypass when no prior full refresh exists - Add real-time progress updates via synchronous Redis WebSocket delivery, bypassing gevent.spawn for reliable delivery from Celery prefork workers - Refactor EPGsTable status cell into standalone EPGStatusCell component with direct Zustand subscription for live progress bar updates - Guard XMLTV parse trigger for channels linked to SD EPG sources - Add 15 backend tests covering auth, credentials, signals, and serializers Migrations: 0023 (username field), 0024 (api_key help_text), 0025 (SD fields + SDScheduleMD5) --- apps/channels/signals.py | 9 + .../epg/migrations/0023_epgsource_username.py | 23 + .../0024_alter_epgsource_api_key.py | 21 + .../0025_sd_fields_and_schedule_md5.py | 65 ++ apps/epg/models.py | 56 +- apps/epg/serializers.py | 1 + apps/epg/signals.py | 13 +- apps/epg/tasks.py | 967 ++++++++++++++++-- apps/epg/tests/test_schedules_direct.py | 263 +++++ frontend/src/WebSocket.jsx | 20 +- frontend/src/api.js | 45 + frontend/src/components/forms/EPG.jsx | 469 ++++++++- frontend/src/components/tables/EPGsTable.jsx | 295 +++--- 13 files changed, 1991 insertions(+), 256 deletions(-) create mode 100644 apps/epg/migrations/0023_epgsource_username.py create mode 100644 apps/epg/migrations/0024_alter_epgsource_api_key.py create mode 100644 apps/epg/migrations/0025_sd_fields_and_schedule_md5.py create mode 100644 apps/epg/tests/test_schedules_direct.py diff --git a/apps/channels/signals.py b/apps/channels/signals.py index a1917b25..54dd30fc 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -201,10 +201,19 @@ def refresh_epg_programs(sender, instance, created, **kwargs): if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']: logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data") if instance.epg_data: + # Skip XMLTV program parser for SD sources — program data is handled + # by fetch_schedules_direct() directly. + if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct': + logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}") + return logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}") parse_programs_for_tvg_id.delay(instance.epg_data.id) # For new channels with EPG data, also refresh elif created and instance.epg_data: + # Skip XMLTV program parser for SD sources + if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct': + logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}") + return logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data") parse_programs_for_tvg_id.delay(instance.epg_data.id) diff --git a/apps/epg/migrations/0023_epgsource_username.py b/apps/epg/migrations/0023_epgsource_username.py new file mode 100644 index 00000000..1a4c5ed3 --- /dev/null +++ b/apps/epg/migrations/0023_epgsource_username.py @@ -0,0 +1,23 @@ +# Generated migration — adds username field to EPGSource for Schedules Direct auth + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0022_alter_epgdata_name'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='username', + field=models.CharField( + max_length=255, + blank=True, + null=True, + help_text='Username for Schedules Direct authentication', + ), + ), + ] diff --git a/apps/epg/migrations/0024_alter_epgsource_api_key.py b/apps/epg/migrations/0024_alter_epgsource_api_key.py new file mode 100644 index 00000000..e40a6baf --- /dev/null +++ b/apps/epg/migrations/0024_alter_epgsource_api_key.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0023_epgsource_username'), + ] + + operations = [ + migrations.AlterField( + model_name='epgsource', + name='api_key', + field=models.CharField( + max_length=255, + blank=True, + null=True, + help_text='Password for Schedules Direct authentication', + ), + ), + ] diff --git a/apps/epg/migrations/0025_sd_fields_and_schedule_md5.py b/apps/epg/migrations/0025_sd_fields_and_schedule_md5.py new file mode 100644 index 00000000..b02c94f2 --- /dev/null +++ b/apps/epg/migrations/0025_sd_fields_and_schedule_md5.py @@ -0,0 +1,65 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0024_alter_epgsource_api_key'), + ] + + operations = [ + # Add sd_changes_remaining to EPGSource + migrations.AddField( + model_name='epgsource', + name='sd_changes_remaining', + field=models.IntegerField( + blank=True, + null=True, + help_text='Number of Schedules Direct lineup additions remaining today (resets at midnight UTC)', + ), + ), + # Add sd_changes_reset_at to EPGSource + migrations.AddField( + model_name='epgsource', + name='sd_changes_reset_at', + field=models.DateTimeField( + blank=True, + null=True, + help_text='UTC datetime when the Schedules Direct daily lineup change counter resets', + ), + ), + # Add sd_program_md5 to ProgramData + migrations.AddField( + model_name='programdata', + name='sd_program_md5', + field=models.CharField( + blank=True, + max_length=22, + null=True, + help_text='MD5 hash from Schedules Direct for delta detection', + ), + ), + # Create SDScheduleMD5 model + migrations.CreateModel( + name='SDScheduleMD5', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('station_id', models.CharField(help_text='Schedules Direct stationID', max_length=20)), + ('date', models.DateField(help_text='Schedule date (UTC)')), + ('md5', models.CharField(help_text='MD5 hash of the schedule for this station/date from Schedules Direct', max_length=22)), + ('last_modified', models.DateTimeField(help_text='Last modified timestamp from Schedules Direct')), + ('epg_source', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='sd_schedule_md5s', + to='epg.epgsource', + )), + ], + options={ + 'indexes': [ + models.Index(fields=['epg_source', 'station_id'], name='epg_sdsche_epg_sou_idx'), + ], + 'unique_together': {('epg_source', 'station_id', 'date')}, + }, + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index d5758ce2..0d4058fb 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -30,7 +30,10 @@ class EPGSource(models.Model): name = models.CharField(max_length=255, unique=True) source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES) url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV - api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct + username = models.CharField(max_length=255, blank=True, null=True, + help_text='Username for Schedules Direct authentication') + api_key = models.CharField(max_length=255, blank=True, null=True, + help_text='Password for Schedules Direct authentication') # For Schedules Direct is_active = models.BooleanField(default=True) file_path = models.CharField(max_length=1024, blank=True, null=True) extracted_file_path = models.CharField(max_length=1024, blank=True, null=True, @@ -49,6 +52,16 @@ class EPGSource(models.Model): default=0, help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel." ) + sd_changes_remaining = models.IntegerField( + null=True, + blank=True, + help_text="Number of Schedules Direct lineup additions remaining today (resets at midnight UTC)" + ) + sd_changes_reset_at = models.DateTimeField( + null=True, + blank=True, + help_text="UTC datetime when the Schedules Direct daily lineup change counter resets" + ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, @@ -163,6 +176,47 @@ class ProgramData(models.Model): description = models.TextField(blank=True, null=True) tvg_id = models.CharField(max_length=255, null=True, blank=True) custom_properties = models.JSONField(default=dict, blank=True, null=True) + sd_program_md5 = models.CharField( + max_length=22, + null=True, + blank=True, + help_text="MD5 hash from Schedules Direct for delta detection" + ) def __str__(self): return f"{self.title} ({self.start_time} - {self.end_time})" + +class SDScheduleMD5(models.Model): + """ + Caches per-station per-date MD5 hashes from Schedules Direct. + Used to detect schedule changes and avoid unnecessary re-downloads, + minimizing API calls against SD's rate-limited endpoints. + """ + epg_source = models.ForeignKey( + EPGSource, + on_delete=models.CASCADE, + related_name="sd_schedule_md5s", + ) + station_id = models.CharField( + max_length=20, + help_text="Schedules Direct stationID" + ) + date = models.DateField( + help_text="Schedule date (UTC)" + ) + md5 = models.CharField( + max_length=22, + help_text="MD5 hash of the schedule for this station/date from Schedules Direct" + ) + last_modified = models.DateTimeField( + help_text="Last modified timestamp from Schedules Direct" + ) + + class Meta: + unique_together = ('epg_source', 'station_id', 'date') + indexes = [ + models.Index(fields=['epg_source', 'station_id']), + ] + + def __str__(self): + return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})" diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 4ab89a9b..049a9713 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -22,6 +22,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'name', 'source_type', 'url', + 'username', 'api_key', 'is_active', 'file_path', diff --git a/apps/epg/signals.py b/apps/epg/signals.py index 97992ef3..7eaa8c0e 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -1,7 +1,7 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from .models import EPGSource, EPGData -from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id +from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id, fetch_schedules_direct_stations from core.scheduling import create_or_update_periodic_task, delete_periodic_task from core.utils import is_protected_path, send_websocket_update import json @@ -12,9 +12,16 @@ logger = logging.getLogger(__name__) @receiver(post_save, sender=EPGSource) def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs): - # Trigger refresh only if the source is newly created, active, and not a dummy EPG + # Trigger refresh only if the source is newly created, active, and not a dummy EPG. + # For Schedules Direct sources, run a stations-only fetch on creation so the user + # can run Auto-match EPG before committing to a full schedule/program fetch. + # A short countdown gives the frontend time to receive the API response and + # populate the store before WebSocket updates arrive. if created and instance.is_active and instance.source_type != 'dummy': - refresh_epg_data.delay(instance.id) + if instance.source_type == 'schedules_direct': + fetch_schedules_direct_stations.apply_async((instance.id,), countdown=3) + else: + refresh_epg_data.delay(instance.id) @receiver(post_save, sender=EPGSource) def create_dummy_epg_data(sender, instance, created, **kwargs): diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 8efe3618..479df9e2 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -29,6 +29,10 @@ from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, se logger = logging.getLogger(__name__) +SD_BASE_URL = 'https://json.schedulesdirect.org/20141201' +SD_DAYS_TO_FETCH = 20 +SD_PROGRAM_BATCH_SIZE = 5000 + # DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named # entities so lxml/libxml2 can resolve references like é correctly # instead of silently dropping them in recovery mode. @@ -173,6 +177,64 @@ def send_epg_update(source_id, action, progress, **kwargs): gc.collect() +def _sd_send_ws_sync(source_id, action, progress, **kwargs): + """ + Sends a WebSocket progress update synchronously via Redis, bypassing gevent.spawn. + + In Celery prefork workers that inherit gevent monkey-patching from uWSGI, + gevent.spawn schedules coroutines that never execute because there is no + running gevent hub in the worker process. This function writes directly to + Redis using the channels_redis 4.x wire format, guaranteeing delivery + regardless of the execution context. + """ + try: + import msgpack + import redis as redis_lib + from django.conf import settings + + data = {"progress": progress, "type": "epg_refresh", "source": source_id, "action": action} + data.update(kwargs) + message = {"type": "update", "data": data} + + redis_url = getattr(settings, "CELERY_BROKER_URL", "redis://localhost:6379/0") + r = redis_lib.from_url(redis_url, decode_responses=False) + + prefix = "asgi" + group_name = "updates" + group_key = f"{prefix}:group:{group_name}" + group_expiry = 86400 + channel_expiry = 60 + rand_len = 12 + now = time.time() + + r.zremrangebyscore(group_key, 0, now - group_expiry) + raw = r.zrange(group_key, 0, -1) + if not raw: + return + + channels = [m.decode("utf-8") if isinstance(m, bytes) else m for m in raw] + nonlocal_map = {} + for ch in channels: + pos = ch.find("!") + nl = ch[:pos + 1] if pos >= 0 else ch + nonlocal_map.setdefault(nl, []).append(ch) + + pipe = r.pipeline(transaction=False) + for nl, chs in nonlocal_map.items(): + channel_key = prefix + nl + msg = dict(message) + msg["__asgi_channel__"] = chs + serialized = os.urandom(rand_len) + msgpack.packb(msg) + pipe.zadd(channel_key, {serialized: now}) + pipe.expire(channel_key, channel_expiry) + pipe.execute() + r.close() + except Exception as e: + logger.warning(f"SD WebSocket sync send failed: {e}") + # Fall back to standard path + send_epg_update(source_id, action, progress, **kwargs) + + def delete_epg_refresh_task_by_id(epg_id): """ Delete the periodic task associated with an EPG source ID. @@ -1255,6 +1317,17 @@ def parse_channels_only(source): @shared_task(time_limit=3600, soft_time_limit=3500) def parse_programs_for_tvg_id(epg_id): + # Skip XMLTV file parsing for Schedules Direct sources — program data is + # fetched and persisted directly by fetch_schedules_direct(). + try: + from apps.epg.models import EPGData + epg_obj = EPGData.objects.select_related('epg_source').filter(id=epg_id).first() + if epg_obj and epg_obj.epg_source and epg_obj.epg_source.source_type == 'schedules_direct': + logger.info(f"Skipping XMLTV parse for SD EPGData id={epg_id} (source: {epg_obj.epg_source.name})") + return "Skipped — Schedules Direct source" + except Exception as e: + logger.warning(f"Could not check EPG source type for id={epg_id}: {e}") + if not acquire_task_lock('parse_epg_programs', epg_id): logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") return "Task already running" @@ -1885,75 +1958,845 @@ def parse_programs_for_source(epg_source, tvg_id=None): logger.info(f"[parse_programs_for_source] Final memory usage: {final_memory:.2f} MB difference: {final_memory - initial_memory:.2f} MB") # Explicitly clear the process object to prevent potential memory leaks process = None -def fetch_schedules_direct(source): - logger.info(f"Fetching Schedules Direct data from source: {source.name}") +@shared_task(bind=True) +def fetch_schedules_direct_stations(self, source_id): + """ + Lightweight Celery task that runs a stations-only Schedules Direct fetch. + Called on initial source creation so EPGData entries exist for auto-matching + before the user commits to a full schedule/program fetch. + """ try: - # Get default user agent from settings - stream_settings = CoreSettings.get_stream_settings() - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default - default_user_agent_id = stream_settings.get('default_user_agent') + source = EPGSource.objects.get(id=source_id) + except EPGSource.DoesNotExist: + logger.error(f"EPGSource {source_id} not found for SD stations fetch") + return + fetch_schedules_direct(source, stations_only=True) - if default_user_agent_id: - try: - user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() - if user_agent_obj and user_agent_obj.user_agent: - user_agent = user_agent_obj.user_agent - logger.debug(f"Using default user agent: {user_agent}") - except (ValueError, Exception) as e: - logger.warning(f"Error retrieving default user agent, using fallback: {e}") - api_url = '' - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {source.api_key}', - 'User-Agent': user_agent - } - logger.debug(f"Requesting subscriptions from Schedules Direct using URL: {api_url}") - response = requests.get(api_url, headers=headers, timeout=30) - response.raise_for_status() - subscriptions = response.json() - logger.debug(f"Fetched subscriptions: {subscriptions}") +def fetch_schedules_direct(source, stations_only=False): + """ + Fetch EPG data from the Schedules Direct JSON API and persist it to the + EPGData / ProgramData models. - for sub in subscriptions: - tvg_id = sub.get('stationID') - logger.debug(f"Processing subscription for tvg_id: {tvg_id}") - schedules_url = f"/schedules/{tvg_id}" - logger.debug(f"Requesting schedules from URL: {schedules_url}") - sched_response = requests.get(schedules_url, headers=headers, timeout=30) - sched_response.raise_for_status() - schedules = sched_response.json() - logger.debug(f"Fetched schedules: {schedules}") + Authentication flow (as required by the SD API specification): + 1. POST credentials to the token endpoint — password must be SHA1-hashed + as required by the Schedules Direct API specification. + 2. Use the returned token for all subsequent requests via the 'token' header. + 3. Tokens are valid for 24 hours; SD returns the current valid token if one + already exists for the account. - epg_data, created = EPGData.objects.get_or_create( - tvg_id=tvg_id, - defaults={'name': tvg_id} - ) - if created: - logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.") - else: - logger.debug(f"Found existing EPGData for tvg_id '{tvg_id}'.") + Data flow: + 1. Fetch subscribed lineups for the account. + 2. Fetch station metadata for each lineup. + 3. Persist station metadata to EPGData. + 4. If stations_only=True, stop here — used on initial source creation so + the user can run Auto-match EPG before the full program fetch. + 5. Fetch schedule grids in 14-day date-batched requests per station. + 6. Fetch program metadata in batched requests (up to 5000 programIDs per request). + 7. Persist channels to EPGData and programs to ProgramData. - for sched in schedules.get('schedules', []): - title = sched.get('title', 'No Title') - desc = sched.get('description', '') - start_time = parse_schedules_direct_time(sched.get('startTime')) - end_time = parse_schedules_direct_time(sched.get('endTime')) - obj, created = ProgramData.objects.update_or_create( - epg=epg_data, - start_time=start_time, - title=title, - defaults={ - 'end_time': end_time, - 'description': desc, - 'sub_title': '' - } + Args: + source: EPGSource instance + stations_only: If True, only fetch and persist station metadata (no schedules/programs). + Used on initial source creation to populate EPGData for auto-matching + before channels are assigned. + """ + import hashlib + from datetime import date + + + logger.info(f"Fetching Schedules Direct data for source: {source.name}") + + # ------------------------------------------------------------------------- + # Validate credentials + # ------------------------------------------------------------------------- + username = (source.username or '').strip() + password = (source.api_key or '').strip() + + if not username or not password: + msg = "Schedules Direct source requires both a username and password." + logger.error(msg) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + # ------------------------------------------------------------------------- + # Enforce 2-hour minimum interval between full fetches (not stations-only). + # Schedules Direct enforces rate limits of ~200 requests per 2-hour window. + # This prevents automated abuse regardless of how the refresh was triggered. + # + # Exception: if no SDScheduleMD5 records exist yet, this is the first full + # refresh after initial source creation (stations-only runs first and updates + # updated_at, which would otherwise incorrectly trigger this guard). Always + # allow the first full refresh through so guide data is immediately available. + # ------------------------------------------------------------------------- + if not stations_only and source.updated_at: + from apps.epg.models import SDScheduleMD5 as _SDScheduleMD5 + has_prior_full_refresh = _SDScheduleMD5.objects.filter(epg_source=source).exists() + if has_prior_full_refresh: + elapsed = (timezone.now() - source.updated_at).total_seconds() + min_interval_seconds = 2 * 3600 # 2 hours + if elapsed < min_interval_seconds: + remaining_minutes = int((min_interval_seconds - elapsed) / 60) + msg = ( + f"Schedules Direct refresh skipped — minimum 2-hour interval not reached. " + f"Last refreshed {int(elapsed / 60)} minutes ago. " + f"Please wait {remaining_minutes} more minute(s)." ) - if created: - logger.info(f"Created ProgramData '{title}' for tvg_id '{tvg_id}'.") - else: - logger.info(f"Updated ProgramData '{title}' for tvg_id '{tvg_id}'.") - except Exception as e: - logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True) + logger.warning(f"SD source {source.id}: {msg}") + source.status = EPGSource.STATUS_IDLE + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="idle", message=msg) + return + else: + logger.info(f"SD source {source.id}: No prior full refresh detected — skipping 2-hour guard for first full fetch.") + + # ------------------------------------------------------------------------- + # Resolve user agent + # ------------------------------------------------------------------------- + stream_settings = CoreSettings.get_stream_settings() + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" + default_user_agent_id = stream_settings.get('default_user_agent') + if default_user_agent_id: + try: + ua_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() + if ua_obj and ua_obj.user_agent: + user_agent = ua_obj.user_agent + except (ValueError, Exception) as e: + logger.warning(f"Could not resolve default user agent, using fallback: {e}") + + def _sd_headers(token=None): + h = { + 'Content-Type': 'application/json', + 'User-Agent': user_agent, + } + if token: + h['token'] = token + return h + + # ------------------------------------------------------------------------- + # Step 1 — Authenticate and obtain session token + # The SD API requires the password to be SHA1-hashed before transmission. + # This is a requirement of the Schedules Direct API specification, not an + # architectural choice. + # ------------------------------------------------------------------------- + source.status = EPGSource.STATUS_FETCHING + source.last_message = "Authenticating with Schedules Direct..." + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "parsing_programs", 2, message="Authenticating with Schedules Direct...") + + try: + sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest() + token_response = requests.post( + f"{SD_BASE_URL}/token", + json={'username': username, 'password': sha1_password}, + headers=_sd_headers(), + timeout=30, + ) + token_response.raise_for_status() + token_data = token_response.json() + + auth_code = token_data.get('code', 0) + if auth_code != 0: + if auth_code == 4007: + msg = "Schedules Direct: this application is not authorized. Please contact the Dispatcharr maintainers." + elif auth_code == 4004: + msg = "Schedules Direct: account locked due to too many failed login attempts. Try again in 15 minutes." + elif auth_code == 4009: + msg = "Schedules Direct: too many login attempts in 24 hours. Token is valid for 24 hours — check for misconfiguration." + elif auth_code == 4001: + msg = "Schedules Direct: account has expired. Please renew your subscription at schedulesdirect.org." + elif auth_code == 4008: + msg = "Schedules Direct: account is inactive. Please log in to schedulesdirect.org to reactivate." + else: + msg = f"Schedules Direct authentication failed (code {auth_code}): {token_data.get('message', 'Unknown error')}" + logger.error(msg) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + token = token_data.get('token') + if not token: + msg = "Schedules Direct returned no token." + logger.error(msg) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + logger.info("Schedules Direct authentication successful.") + + except requests.exceptions.RequestException as e: + msg = f"Network error authenticating with Schedules Direct: {e}" + logger.error(msg, exc_info=True) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + # ------------------------------------------------------------------------- + # Step 2 — Check account status (respect OFFLINE system status) + # ------------------------------------------------------------------------- + try: + status_response = requests.get( + f"{SD_BASE_URL}/status", + headers=_sd_headers(token), + timeout=30, + ) + status_response.raise_for_status() + status_data = status_response.json() + system_status = status_data.get('systemStatus', [{}])[0].get('status', 'Online') + if system_status == 'Offline': + # Per SD API spec: if system is offline, disconnect and do not + # retry for 1 hour. We set idle status rather than error since + # this is a temporary SD-side condition. + msg = "Schedules Direct system is currently offline. Per SD guidelines, retrying in 1 hour." + logger.warning(msg) + source.status = EPGSource.STATUS_IDLE + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="idle", message=msg) + return + logger.debug(f"Schedules Direct system status: {system_status}") + except requests.exceptions.RequestException as e: + logger.warning(f"Could not fetch SD system status, proceeding anyway: {e}") + + # ------------------------------------------------------------------------- + # Step 3 — Fetch subscribed lineups and build station map + # ------------------------------------------------------------------------- + _sd_send_ws_sync(source.id, "parsing_programs", 10, message="Fetching subscribed lineups...") + try: + lineups_response = requests.get( + f"{SD_BASE_URL}/lineups", + headers=_sd_headers(token), + timeout=30, + ) + # SD returns 400 with code 4102 when no lineups are configured. + # This is a valid account state — the user needs to add lineups via + # the Manage Lineups UI. Treat as idle rather than error. + if lineups_response.status_code == 400: + sd_data = lineups_response.json() + if sd_data.get('code') == 4102: + msg = "No lineups configured. Use the Manage Lineups option in the EPG source settings to add a lineup." + logger.warning(f"SD source {source.id}: no lineups configured on account (4102).") + source.status = EPGSource.STATUS_IDLE + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="idle", message=msg) + return + lineups_response.raise_for_status() + lineups_data = lineups_response.json() + lineups = [l for l in lineups_data.get('lineups', []) if not l.get('isDeleted', False)] + if not lineups: + msg = "No lineups configured. Use the Manage Lineups option in the EPG source settings to add a lineup." + logger.warning(f"SD source {source.id}: no active lineups found.") + source.status = EPGSource.STATUS_IDLE + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="idle", message=msg) + return + logger.info(f"Found {len(lineups)} lineup(s) in SD account.") + except requests.exceptions.RequestException as e: + msg = f"Failed to fetch Schedules Direct lineups: {e}" + logger.error(msg, exc_info=True) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + # Build station metadata map: stationID -> {name, callsign, logo_url} + station_map = {} + _sd_send_ws_sync(source.id, "parsing_programs", 18, message=f"Fetching station metadata for {len(lineups)} lineup(s)...") + for lineup in lineups: + lineup_id = lineup.get('lineupID') or lineup.get('lineup') + if not lineup_id: + continue + try: + detail_response = requests.get( + f"{SD_BASE_URL}/lineups/{lineup_id}", + headers=_sd_headers(token), + timeout=30, + ) + detail_response.raise_for_status() + detail_data = detail_response.json() + for station in detail_data.get('stations', []): + sid = station.get('stationID') + if not sid: + continue + logo_url = None + logos = station.get('stationLogos') or station.get('logo') or [] + if isinstance(logos, list) and logos: + logo_url = logos[0].get('URL') or logos[0].get('url') + elif isinstance(logos, dict): + logo_url = logos.get('URL') or logos.get('url') + station_map[sid] = { + 'name': station.get('name', sid), + 'callsign': station.get('callsign', ''), + 'logo_url': logo_url, + } + logger.debug(f"Fetched {len(detail_data.get('stations', []))} stations from lineup {lineup_id}") + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch lineup details for {lineup_id}: {e}") + + if not station_map: + msg = "No stations found across all Schedules Direct lineups." + logger.warning(msg) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "refresh", 100, status="error", error=msg) + return + + logger.info(f"Built station map with {len(station_map)} stations.") + + # ------------------------------------------------------------------------- + # Step 4 — Persist station metadata to EPGData + # ------------------------------------------------------------------------- + source.status = EPGSource.STATUS_PARSING + source.last_message = f"Syncing {len(station_map)} stations..." + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "parsing_programs", 28, message=f"Syncing {len(station_map)} stations to database...") + + existing_epg_map = { + epg.tvg_id: epg + for epg in EPGData.objects.filter(epg_source=source) + } + + epgs_to_create = [] + epgs_to_update = [] + icon_max_length = EPGData._meta.get_field('icon_url').max_length + name_max_length = EPGData._meta.get_field('name').max_length + + for sid, info in station_map.items(): + display_name = (info['name'] or sid)[:name_max_length] + logo = info['logo_url'] + if logo and len(logo) > icon_max_length: + logo = None + + if sid in existing_epg_map: + epg_obj = existing_epg_map[sid] + needs_update = False + if epg_obj.name != display_name: + epg_obj.name = display_name + needs_update = True + if epg_obj.icon_url != logo: + epg_obj.icon_url = logo + needs_update = True + if needs_update: + epgs_to_update.append(epg_obj) + else: + epgs_to_create.append(EPGData( + tvg_id=sid, + name=display_name, + icon_url=logo, + epg_source=source, + )) + + if epgs_to_create: + EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) + logger.info(f"Created {len(epgs_to_create)} new EPGData entries.") + if epgs_to_update: + EPGData.objects.bulk_update(epgs_to_update, ['name', 'icon_url']) + logger.info(f"Updated {len(epgs_to_update)} existing EPGData entries.") + + gc.collect() + + # Rebuild map with fresh DB ids for all stations + epg_id_map = { + epg.tvg_id: epg.id + for epg in EPGData.objects.filter(epg_source=source, tvg_id__in=list(station_map.keys())) + } + + # Station sync complete — send progress continuing into programs phase + # We deliberately do NOT send parsing_channels at 100 with status=success here + # because that would cause the frontend to mark the source as complete and + # stop rendering progress updates for the subsequent program fetch phases. + _sd_send_ws_sync(source.id, "parsing_programs", 30, + message=f"Stations synced ({len(station_map)} stations). Preparing schedule fetch...") + + # ------------------------------------------------------------------------- + # Stations-only mode — used on initial source creation. + # Stop here so the user can run Auto-match EPG before the full program fetch. + # ------------------------------------------------------------------------- + if stations_only: + success_msg = ( + f"{len(station_map)} stations loaded from Schedules Direct. " + f"Run Auto-match EPG to map your channels, then use the Refresh " + f"button to populate guide data." + ) + source.status = EPGSource.STATUS_SUCCESS + source.last_message = success_msg + source.updated_at = timezone.now() + source.save(update_fields=['status', 'last_message', 'updated_at']) + _sd_send_ws_sync(source.id, "parsing_channels", 100, status="success", + message=success_msg, channels_count=len(station_map)) + logger.info(f"Stations-only fetch complete for source: {source.name} ({len(station_map)} stations)") + return + + # ------------------------------------------------------------------------- + # Step 5 — MD5-delta schedule fetch + # First fetch MD5 hashes for all stations/dates. Compare against our + # locally cached hashes to determine which schedules have changed. + # Only download schedules that have actually changed — this minimises + # API calls against SD's rate-limited endpoints. + # ------------------------------------------------------------------------- + from apps.epg.models import SDScheduleMD5 + from django.utils.dateparse import parse_datetime + + _sd_send_ws_sync(source.id, "parsing_programs", 33, message=f"Checking schedule MD5s for {len(station_map)} stations over {SD_DAYS_TO_FETCH} days...") + station_ids = list(station_map.keys()) + today = date.today() + date_list = [(today + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(SD_DAYS_TO_FETCH)] + + # Fetch MD5 hashes for all stations in batches of 5000 + STATION_BATCH_SIZE = 5000 + server_md5s = {} # (station_id, date) -> {md5, last_modified} + + logger.info(f"Fetching schedule MD5s for {len(station_ids)} stations over {SD_DAYS_TO_FETCH} days.") + + station_batches = [station_ids[i:i + STATION_BATCH_SIZE] for i in range(0, len(station_ids), STATION_BATCH_SIZE)] + for batch in station_batches: + try: + md5_response = requests.post( + f"{SD_BASE_URL}/schedules/md5", + json=[{'stationID': sid, 'date': date_list} for sid in batch], + headers=_sd_headers(token), + timeout=120, + ) + md5_response.raise_for_status() + md5_data = md5_response.json() + for sid, dates in md5_data.items(): + for date_str, info in dates.items(): + if info.get('code', 0) == 0: + server_md5s[(sid, date_str)] = { + 'md5': info.get('md5', ''), + 'last_modified': info.get('lastModified', ''), + } + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch schedule MD5s: {e}") + + # Load our cached MD5s from DB + cached_md5s = { + (r.station_id, r.date.strftime('%Y-%m-%d')): r.md5 + for r in SDScheduleMD5.objects.filter(epg_source=source, station_id__in=station_ids) + } + + # Determine which station/date combinations need downloading + changed_by_station = {} # station_id -> [date_str, ...] + for (sid, date_str), server_info in server_md5s.items(): + if date_str not in date_list: + continue + cached = cached_md5s.get((sid, date_str)) + if cached != server_info['md5']: + changed_by_station.setdefault(sid, []).append(date_str) + + total_changed = sum(len(v) for v in changed_by_station.items()) + total_possible = len(station_ids) * len(date_list) + logger.info(f"Schedule MD5 check: {len(server_md5s)} hashes checked, {total_changed} station/date combinations changed (of {total_possible} possible).") + _sd_send_ws_sync(source.id, "parsing_programs", 38, + message=f"MD5 check complete: {len(changed_by_station)} stations have schedule updates.") + + # schedules_by_station: stationID -> list of {programID, airDateTime, duration, ...} + schedules_by_station = {sid: [] for sid in station_ids} + program_ids_needed = set() + + # Existing cached program MD5s for delta detection in step 6 + existing_program_md5s = { + p.tvg_id + '|' + p.title: p.sd_program_md5 + for p in ProgramData.objects.filter( + epg__epg_source=source, + sd_program_md5__isnull=False, + ).only('tvg_id', 'title', 'sd_program_md5') + } + + if not changed_by_station: + logger.info("No schedule changes detected — skipping schedule and program downloads.") + _sd_send_ws_sync(source.id, "parsing_programs", 100, status="success", + message="No schedule changes detected since last refresh. Guide data is up to date.") + source.status = EPGSource.STATUS_SUCCESS + source.last_message = "No schedule changes detected. Guide data is up to date." + source.updated_at = timezone.now() + source.save(update_fields=['status', 'last_message', 'updated_at']) + return + + # Download only changed schedules, batched by 7-day windows per station + SCHEDULE_BATCH_DAYS = 7 + changed_station_ids = list(changed_by_station.keys()) + date_batches = [date_list[i:i + SCHEDULE_BATCH_DAYS] for i in range(0, len(date_list), SCHEDULE_BATCH_DAYS)] + new_md5_records = [] + updated_md5_records = [] + existing_md5_map = { + (r.station_id, r.date.strftime('%Y-%m-%d')): r + for r in SDScheduleMD5.objects.filter(epg_source=source, station_id__in=changed_station_ids) + } + + for batch_idx, date_batch in enumerate(date_batches): + # Notify frontend at the start of each batch so progress updates immediately + pre_progress = 38 + int((batch_idx / len(date_batches)) * 22) + logger.info(f"Fetching schedule batch {batch_idx + 1} of {len(date_batches)}...") + _sd_send_ws_sync(source.id, "parsing_programs", min(59, pre_progress), + message=f"Fetching schedules: batch {batch_idx + 1} of {len(date_batches)}...") + # Yield to gevent hub so the WebSocket update is delivered before the blocking request + try: + import gevent; gevent.sleep(0) + except ImportError: + pass + # Only include stations that have changes in this date batch + request_body = [ + {'stationID': sid, 'date': [d for d in date_batch if d in changed_by_station.get(sid, [])]} + for sid in changed_station_ids + if any(d in changed_by_station.get(sid, []) for d in date_batch) + ] + if not request_body: + continue + try: + sched_response = requests.post( + f"{SD_BASE_URL}/schedules", + json=request_body, + headers=_sd_headers(token), + timeout=120, + ) + sched_response.raise_for_status() + sched_data = sched_response.json() + + for station_sched in sched_data: + sid = station_sched.get('stationID') + if not sid: + continue + programs = station_sched.get('programs', []) + schedules_by_station.setdefault(sid, []).extend(programs) + for prog in programs: + pid = prog.get('programID') + if pid: + program_ids_needed.add(pid) + + # Update MD5 cache for this station/date + meta = station_sched.get('metadata', {}) + start_date = meta.get('startDate') + md5_val = meta.get('md5', '') + last_mod_str = meta.get('modified', '') + if start_date and md5_val: + key = (sid, start_date) + last_mod = parse_datetime(last_mod_str) if last_mod_str else timezone.now() + if key in existing_md5_map: + rec = existing_md5_map[key] + rec.md5 = md5_val + rec.last_modified = last_mod + updated_md5_records.append(rec) + else: + import datetime as dt_module + try: + date_obj = dt_module.date.fromisoformat(start_date) + new_md5_records.append(SDScheduleMD5( + epg_source=source, + station_id=sid, + date=date_obj, + md5=md5_val, + last_modified=last_mod, + )) + except ValueError: + pass + + progress = 38 + int(((batch_idx + 1) / len(date_batches)) * 22) + _sd_send_ws_sync(source.id, "parsing_programs", min(60, progress), + message=f"Fetching changed schedules: batch {batch_idx + 1}/{len(date_batches)} ({len(program_ids_needed):,} programs found)") + + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch schedule batch {batch_idx + 1}: {e}") + + # Persist updated MD5 cache + if new_md5_records: + SDScheduleMD5.objects.bulk_create(new_md5_records, ignore_conflicts=True) + logger.info(f"Cached {len(new_md5_records)} new schedule MD5s.") + if updated_md5_records: + SDScheduleMD5.objects.bulk_update(updated_md5_records, ['md5', 'last_modified']) + logger.info(f"Updated {len(updated_md5_records)} existing schedule MD5s.") + + if not program_ids_needed: + msg = "No schedule data returned from Schedules Direct." + logger.warning(msg) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "parsing_programs", 100, status="error", error=msg) + return + + # ------------------------------------------------------------------------- + # Step 6 — MD5-delta program metadata fetch + # The schedule response includes an MD5 hash per program airing. + # Compare against our cached program MD5s to only download programs + # whose metadata has changed since our last fetch. + # ------------------------------------------------------------------------- + + # Build map of programID -> md5 from schedule data + schedule_program_md5s = {} # programID -> md5 from schedule + for sid, airings in schedules_by_station.items(): + for airing in airings: + pid = airing.get('programID') + md5 = airing.get('md5') + if pid and md5: + schedule_program_md5s[pid] = md5 + + # Load cached program MD5s from DB keyed by programID via tvg_id + cached_prog_md5s = { + p.tvg_id: p.sd_program_md5 + for p in ProgramData.objects.filter( + epg__epg_source=source, + sd_program_md5__isnull=False, + ).only('tvg_id', 'sd_program_md5').distinct('tvg_id') + } + + # Only fetch programs where MD5 differs from our cache + programs_to_fetch = [ + pid for pid in program_ids_needed + if schedule_program_md5s.get(pid) != cached_prog_md5s.get(pid) + ] + + logger.info( + f"Program MD5 delta: {len(program_ids_needed)} programs in schedules, " + f"{len(programs_to_fetch)} need downloading ({len(program_ids_needed) - len(programs_to_fetch)} unchanged).") + + program_metadata = {} + program_id_list = programs_to_fetch + total_batches = max(1, (len(program_id_list) + SD_PROGRAM_BATCH_SIZE - 1) // SD_PROGRAM_BATCH_SIZE) + + if program_id_list: + logger.info(f"Fetching metadata for {len(program_id_list)} programs in {total_batches} batch(es).") + for batch_idx in range(total_batches): + # Notify frontend at the start of each batch so progress updates immediately + pre_progress = 60 + int((batch_idx / total_batches) * 20) + logger.info(f"Fetching program metadata batch {batch_idx + 1} of {total_batches} ({batch_idx * SD_PROGRAM_BATCH_SIZE:,} of {len(program_id_list):,} programs)...") + _sd_send_ws_sync(source.id, "parsing_programs", min(79, pre_progress), + message=f"Fetching program data: batch {batch_idx + 1} of {total_batches} ({batch_idx * SD_PROGRAM_BATCH_SIZE:,} of {len(program_id_list):,} programs)") + # Yield to gevent hub so the WebSocket update is delivered before the blocking request + try: + import gevent; gevent.sleep(0) + except ImportError: + pass + batch = program_id_list[batch_idx * SD_PROGRAM_BATCH_SIZE:(batch_idx + 1) * SD_PROGRAM_BATCH_SIZE] + try: + prog_response = requests.post( + f"{SD_BASE_URL}/programs", + json=batch, + headers=_sd_headers(token), + timeout=120, + ) + prog_response.raise_for_status() + prog_data = prog_response.json() + for prog in prog_data: + pid = prog.get('programID') + if pid: + program_metadata[pid] = prog + + progress = 60 + int(((batch_idx + 1) / total_batches) * 20) + _sd_send_ws_sync(source.id, "parsing_programs", min(80, progress), + message=f"Fetching program details: batch {batch_idx + 1}/{total_batches} ({len(program_metadata):,} programs loaded)") + logger.debug(f"Fetched program metadata batch {batch_idx + 1}/{total_batches}") + + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch program metadata batch {batch_idx + 1}: {e}") + else: + logger.info("All program metadata unchanged - skipping program download.") + _sd_send_ws_sync(source.id, "parsing_programs", 80, message="Program metadata unchanged - using cached data.") + + gc.collect() + + # ------------------------------------------------------------------------- + # Step 7 — Build ProgramData records and persist atomically + # ------------------------------------------------------------------------- + logger.info("Building program records...") + _sd_send_ws_sync(source.id, "parsing_programs", 80) + + # Only process stations that are mapped to channels to match the existing + # XMLTV flow (parse_programs_for_source only processes mapped channels). + from apps.channels.models import Channel as ChannelModel + mapped_epg_ids = set( + ChannelModel.objects.filter( + epg_data__epg_source=source, + epg_data__isnull=False, + ).values_list('epg_data_id', flat=True) + ) + mapped_tvg_ids = set( + EPGData.objects.filter( + id__in=mapped_epg_ids, + epg_source=source, + ).values_list('tvg_id', flat=True) + ) + + all_programs_to_create = [] + total_programs = 0 + skipped_unmapped = 0 + + for sid, airings in schedules_by_station.items(): + if sid not in mapped_tvg_ids: + skipped_unmapped += len(airings) + continue + + epg_db_id = epg_id_map.get(sid) + if not epg_db_id: + continue + + for airing in airings: + pid = airing.get('programID') + air_time = airing.get('airDateTime') + duration_secs = airing.get('duration', 0) + + if not pid or not air_time or not duration_secs: + continue + + try: + start_dt = parse_schedules_direct_time(air_time) + end_dt = start_dt + timedelta(seconds=int(duration_secs)) + except Exception as e: + logger.debug(f"Could not parse air time '{air_time}': {e}") + continue + + meta = program_metadata.get(pid, {}) + titles = meta.get('titles', [{}]) + title = titles[0].get('title120', '') if titles else '' + if not title: + title = meta.get('episodeTitle150', '') or 'No Title' + title = title[:255] + + descriptions = meta.get('descriptions', {}) + desc = '' + for key in ('description1000', 'description255', 'description100'): + candidates = descriptions.get(key, []) + if candidates: + desc = candidates[0].get('description', '') + if desc: + break + + episode_title = meta.get('episodeTitle150', '') + + # Build custom_properties following the same pattern as the XMLTV parser + custom_props = {} + metadata_block = meta.get('metadata', [{}]) + if metadata_block: + m = metadata_block[0].get('Gracenote', {}) + season = m.get('season') + episode = m.get('episode') + if season: + custom_props['season'] = int(season) + if episode: + custom_props['episode'] = int(episode) + + content_rating = meta.get('contentRating', []) + if content_rating: + custom_props['rating'] = content_rating[0].get('code', '') + custom_props['rating_system'] = content_rating[0].get('body', '') + + genres = meta.get('genres', []) + if genres: + custom_props['categories'] = genres + + cast = meta.get('cast', []) + crew = meta.get('crew', []) + credits = {} + if cast: + credits['actor'] = [ + {'name': p.get('name', ''), 'role': p.get('role', '')} + for p in cast if p.get('name') + ] + if crew: + for member in crew: + role = member.get('role', '').lower() + name = member.get('name', '') + if not name: + continue + if 'director' in role: + credits.setdefault('director', []).append(name) + elif 'writer' in role or 'screenwriter' in role: + credits.setdefault('writer', []).append(name) + elif 'producer' in role: + credits.setdefault('producer', []).append(name) + if credits: + custom_props['credits'] = credits + + if airing.get('isLive'): + custom_props['live'] = True + if airing.get('isNew'): + custom_props['new'] = True + if airing.get('isPremiere'): + custom_props['premiere'] = True + + year = meta.get('movie', {}).get('year') or meta.get('originalAirDate', '')[:4] + if year: + custom_props['date'] = str(year) + + all_programs_to_create.append(ProgramData( + epg_id=epg_db_id, + start_time=start_dt, + end_time=end_dt, + title=title, + sub_title=episode_title or None, + description=desc or None, + tvg_id=sid, + custom_properties=custom_props or None, + sd_program_md5=schedule_program_md5s.get(pid), + )) + total_programs += 1 + + logger.info(f"Built {total_programs} program records " + f"({skipped_unmapped} skipped for unmapped stations).") + + _sd_send_ws_sync(source.id, "parsing_programs", 88) + + # Atomic delete + bulk insert — same pattern as parse_programs_for_source + BATCH_SIZE = 1000 + try: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET LOCAL statement_timeout = '10min'") + deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0] + logger.debug(f"Deleted {deleted_count} existing SD programs.") + for i in range(0, len(all_programs_to_create), BATCH_SIZE): + ProgramData.objects.bulk_create(all_programs_to_create[i:i + BATCH_SIZE]) + progress = 88 + int(((i + BATCH_SIZE) / max(len(all_programs_to_create), 1)) * 10) + _sd_send_ws_sync(source.id, "parsing_programs", min(98, progress)) + + logger.info(f"Committed {total_programs} Schedules Direct programs to database.") + except Exception as db_error: + msg = f"Database error persisting Schedules Direct programs: {db_error}" + logger.error(msg, exc_info=True) + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + _sd_send_ws_sync(source.id, "parsing_programs", 100, status="error", error=msg) + return + finally: + all_programs_to_create = None + gc.collect() + + # ------------------------------------------------------------------------- + # Done + # ------------------------------------------------------------------------- + success_msg = ( + f"Successfully fetched {total_programs:,} programs for " + f"{len(mapped_tvg_ids)} mapped stations from Schedules Direct " + f"({skipped_unmapped:,} programs skipped for unmapped stations)." + ) + source.status = EPGSource.STATUS_SUCCESS + source.last_message = success_msg + source.updated_at = timezone.now() + source.save(update_fields=['status', 'last_message', 'updated_at']) + _sd_send_ws_sync(source.id, "parsing_programs", 100, status="success", message=success_msg) + log_system_event( + event_type='epg_refresh', + source_name=source.name, + programs=total_programs, + channels=len(mapped_tvg_ids), + skipped_programs=skipped_unmapped, + ) + logger.info(f"Schedules Direct fetch complete for source: {source.name}") # ------------------------------- diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py new file mode 100644 index 00000000..782750af --- /dev/null +++ b/apps/epg/tests/test_schedules_direct.py @@ -0,0 +1,263 @@ +""" +Tests for the Schedules Direct EPG integration. + +Covers: +- EPGSource model: username field presence and help text +- EPGSource serializer: username field included in output +- fetch_schedules_direct: credential validation +- fetch_schedules_direct: SHA1 password hashing and token exchange +- fetch_schedules_direct: graceful error handling on auth failure +- parse_schedules_direct_time: correct UTC parsing +- EPG signals: SD sources skip the XMLTV program parser +""" + +import hashlib +from datetime import datetime +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone + +from apps.epg.models import EPGSource +from apps.epg.serializers import EPGSourceSerializer + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + +class EPGSourceUsernameFieldTests(TestCase): + """EPGSource.username must exist, be nullable, and carry help text.""" + + def test_username_field_exists(self): + source = EPGSource.objects.create( + name='SD Test', + source_type='schedules_direct', + username='testuser', + api_key='testpass', + ) + source.refresh_from_db() + self.assertEqual(source.username, 'testuser') + + def test_username_nullable(self): + source = EPGSource.objects.create( + name='SD Nullable', + source_type='schedules_direct', + ) + source.refresh_from_db() + self.assertIsNone(source.username) + + def test_username_help_text(self): + field = EPGSource._meta.get_field('username') + self.assertIn('Schedules Direct', field.help_text) + + +# --------------------------------------------------------------------------- +# Serializer tests +# --------------------------------------------------------------------------- + +class EPGSourceSerializerSDTests(TestCase): + """EPGSourceSerializer must include the username field.""" + + def test_username_in_serializer_fields(self): + source = EPGSource.objects.create( + name='SD Serializer Test', + source_type='schedules_direct', + username='sduser', + api_key='sdpass', + ) + data = EPGSourceSerializer(source).data + self.assertIn('username', data) + self.assertEqual(data['username'], 'sduser') + + def test_api_key_in_serializer_fields(self): + source = EPGSource.objects.create( + name='SD API Key Test', + source_type='schedules_direct', + api_key='secret', + ) + data = EPGSourceSerializer(source).data + self.assertIn('api_key', data) + + +# --------------------------------------------------------------------------- +# fetch_schedules_direct tests +# --------------------------------------------------------------------------- + +class FetchSchedulesDirectCredentialTests(TestCase): + """fetch_schedules_direct must reject sources missing credentials.""" + + def _make_source(self, username=None, api_key=None): + return EPGSource.objects.create( + name='SD Cred Test', + source_type='schedules_direct', + username=username, + api_key=api_key, + ) + + def test_missing_username_sets_error_status(self): + from apps.epg.tasks import fetch_schedules_direct + source = self._make_source(username=None, api_key='pass') + fetch_schedules_direct(source) + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + + def test_missing_password_sets_error_status(self): + from apps.epg.tasks import fetch_schedules_direct + source = self._make_source(username='user', api_key=None) + fetch_schedules_direct(source) + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + + def test_empty_username_sets_error_status(self): + from apps.epg.tasks import fetch_schedules_direct + source = self._make_source(username=' ', api_key='pass') + fetch_schedules_direct(source) + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + + +class FetchSchedulesDirectAuthTests(TestCase): + """fetch_schedules_direct must SHA1-hash the password before sending.""" + + @patch('apps.epg.tasks.requests.post') + @patch('apps.epg.tasks.requests.get') + def test_password_sha1_hashed_in_token_request(self, mock_get, mock_post): + """The token POST body must contain the SHA1 hash of the plaintext password.""" + plaintext = 'mysecretpassword' + expected_hash = hashlib.sha1(plaintext.encode('utf-8')).hexdigest() + + # Auth succeeds, status check returns empty data, lineups returns empty + mock_post.return_value = MagicMock( + status_code=200, + json=MagicMock(return_value={'code': 0, 'token': 'tok123'}), + ) + mock_get.return_value = MagicMock( + status_code=200, + json=MagicMock(return_value={}), + ) + + from apps.epg.tasks import fetch_schedules_direct + source = EPGSource.objects.create( + name='SD Hash Test', + source_type='schedules_direct', + username='sduser', + api_key=plaintext, + ) + + with patch('apps.epg.tasks.send_epg_update'): + fetch_schedules_direct(source) + + # Verify the POST was called and the body contained the hash + self.assertTrue(mock_post.called) + call_kwargs = mock_post.call_args + posted_json = call_kwargs[1].get('json') or call_kwargs[0][1] + self.assertEqual(posted_json.get('password'), expected_hash) + self.assertEqual(posted_json.get('username'), 'sduser') + + @patch('apps.epg.tasks.requests.post') + def test_auth_failure_sets_error_status(self, mock_post): + """A non-zero SD response code must set STATUS_ERROR on the source.""" + mock_post.return_value = MagicMock( + status_code=200, + json=MagicMock(return_value={ + 'code': 3000, + 'message': 'Invalid credentials', + }), + ) + + from apps.epg.tasks import fetch_schedules_direct + source = EPGSource.objects.create( + name='SD Auth Fail', + source_type='schedules_direct', + username='baduser', + api_key='badpass', + ) + + with patch('apps.epg.tasks.send_epg_update'): + fetch_schedules_direct(source) + + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + + @patch('apps.epg.tasks.requests.post') + def test_network_error_sets_error_status(self, mock_post): + """A network-level exception must set STATUS_ERROR and not crash.""" + import requests as req_lib + mock_post.side_effect = req_lib.exceptions.ConnectionError('timeout') + + from apps.epg.tasks import fetch_schedules_direct + source = EPGSource.objects.create( + name='SD Network Error', + source_type='schedules_direct', + username='user', + api_key='pass', + ) + + with patch('apps.epg.tasks.send_epg_update'): + fetch_schedules_direct(source) # Must not raise + + source.refresh_from_db() + self.assertEqual(source.status, EPGSource.STATUS_ERROR) + + +# --------------------------------------------------------------------------- +# parse_schedules_direct_time tests +# --------------------------------------------------------------------------- + +class ParseSchedulesDirectTimeTests(TestCase): + """parse_schedules_direct_time must parse SD ISO timestamps to UTC-aware datetimes.""" + + def test_parses_valid_timestamp(self): + from apps.epg.tasks import parse_schedules_direct_time + result = parse_schedules_direct_time('2026-05-16T20:00:00Z') + self.assertEqual(result.year, 2026) + self.assertEqual(result.month, 5) + self.assertEqual(result.day, 16) + self.assertEqual(result.hour, 20) + self.assertIsNotNone(result.tzinfo) + + def test_result_is_utc_aware(self): + from apps.epg.tasks import parse_schedules_direct_time + result = parse_schedules_direct_time('2026-01-01T00:00:00Z') + # Should be timezone-aware + self.assertIsNotNone(result.tzinfo) + + def test_raises_on_invalid_format(self): + from apps.epg.tasks import parse_schedules_direct_time + with self.assertRaises(Exception): + parse_schedules_direct_time('not-a-timestamp') + + +# --------------------------------------------------------------------------- +# Signal tests +# --------------------------------------------------------------------------- + +class SDSourceSignalTests(TestCase): + """SD EPG sources must skip the XMLTV program parser signal.""" + + @patch('apps.channels.signals.parse_programs_for_tvg_id') + def test_sd_source_skips_xmltv_parse_on_channel_create(self, mock_parse): + """Creating a channel linked to an SD EPG source must not trigger + the XMLTV program parser — SD data is handled by fetch_schedules_direct.""" + from apps.epg.models import EPGData + from apps.channels.models import Channel + + sd_source = EPGSource.objects.create( + name='SD Signal Test', + source_type='schedules_direct', + username='u', + api_key='p', + ) + epg_data = EPGData.objects.create( + tvg_id='sd-test-station', + name='SD Test Station', + epg_source=sd_source, + ) + + Channel.objects.create( + name='SD Channel', + epg_data=epg_data, + ) + + mock_parse.delay.assert_not_called() diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 31ad1fc5..47eb91ca 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -51,6 +51,7 @@ export const WebsocketProvider = ({ children }) => { const epgs = useEPGsStore((s) => s.epgs); const updateEPG = useEPGsStore((s) => s.updateEPG); const updateEPGProgress = useEPGsStore((s) => s.updateEPGProgress); + const fetchEPGsForProgress = useEPGsStore((s) => s.fetchEPGs); const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist); @@ -642,19 +643,18 @@ export const WebsocketProvider = ({ children }) => { parsedEvent.data.source || parsedEvent.data.account; const epg = epgs[sourceId]; - // Only update progress if the EPG still exists in the store - // This prevents crashes when receiving updates for deleted EPGs - if (epg) { - // Update the store with progress information - updateEPGProgress(parsedEvent.data); - } else { - // EPG was deleted, ignore this update - console.debug( - `Ignoring EPG refresh update for deleted EPG ${sourceId}` - ); + // If EPG not in store yet (e.g. newly created and refresh started + // before the store was updated), fetch the EPG list and update progress. + if (!epg) { + fetchEPGsForProgress().then(() => { + updateEPGProgress(parsedEvent.data); + }); break; } + // Update the store with progress information + updateEPGProgress(parsedEvent.data); + if (epg) { // Check for any indication of an error (either via status or error field) const hasError = diff --git a/frontend/src/api.js b/frontend/src/api.js index 2a357a81..42f010c6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3817,4 +3817,49 @@ export default class API { errorNotification('Failed to fetch connect logs', e); } } + + static async getSDLineups(sourceId) { + try { + const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve Schedules Direct lineups', e); + } + } + + static async addSDLineup(sourceId, lineup) { + try { + const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`, { + method: 'POST', + body: { lineup }, + }); + return response; + } catch (e) { + errorNotification(`Failed to add lineup ${lineup}`, e); + } + } + + static async deleteSDLineup(sourceId, lineup) { + try { + const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`, { + method: 'DELETE', + body: { lineup }, + }); + return response; + } catch (e) { + errorNotification(`Failed to remove lineup ${lineup}`, e); + } + } + + static async searchSDLineups(sourceId, country, postalcode) { + try { + const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`, { + method: 'POST', + body: { country, postalcode }, + }); + return response; + } catch (e) { + errorNotification('Failed to search Schedules Direct lineups', e); + } + } } diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 4641af6c..82ddffe9 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -1,5 +1,5 @@ // Modal.js -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { TextInput, Button, @@ -12,15 +12,333 @@ import { Divider, Box, Text, + Alert, + Select, + Loader, + Badge, + ScrollArea, + Table, } from '@mantine/core'; +import { TriangleAlert, Trash2, Plus, Search } from 'lucide-react'; import { isNotEmpty, useForm } from '@mantine/form'; import ScheduleInput from './ScheduleInput'; import { addEPG, updateEPG } from '../../utils/forms/DummyEpgUtils.js'; import { showNotification } from '../../utils/notificationUtils.js'; +import API from '../../api.js'; + +// Countries are fetched dynamically from the SD API on component mount. +// Fallback list used if the API call fails. +const SD_COUNTRIES_FALLBACK = [ + { value: 'USA', label: 'United States' }, + { value: 'CAN', label: 'Canada' }, + { value: 'GBR', label: 'United Kingdom' }, + { value: 'AUS', label: 'Australia' }, + { value: 'AUT', label: 'Austria' }, + { value: 'BEL', label: 'Belgium' }, + { value: 'DNK', label: 'Denmark' }, + { value: 'FIN', label: 'Finland' }, + { value: 'FRA', label: 'France' }, + { value: 'DEU', label: 'Germany' }, + { value: 'IRL', label: 'Ireland' }, + { value: 'ITA', label: 'Italy' }, + { value: 'NLD', label: 'Netherlands' }, + { value: 'NZL', label: 'New Zealand' }, +]; + +const SDLineupManager = ({ sourceId }) => { + const [countries, setCountries] = useState(SD_COUNTRIES_FALLBACK); + const [activeLineups, setActiveLineups] = useState([]); + const [lineupNotice, setLineupNotice] = useState(null); + const [changesRemaining, setChangesRemaining] = useState(null); + const [changesResetAt, setChangesResetAt] = useState(null); + const [searchResults, setSearchResults] = useState([]); + const [country, setCountry] = useState('USA'); + const [postalCode, setPostalCode] = useState(''); + const [loadingLineups, setLoadingLineups] = useState(false); + const [searching, setSearching] = useState(false); + const [addingLineup, setAddingLineup] = useState(null); + const [removingLineup, setRemovingLineup] = useState(null); + const maxLineups = 4; + const SD_DOCS_URL = 'https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform'; + + const fetchActiveLineups = useCallback(async () => { + setLoadingLineups(true); + try { + const data = await API.getSDLineups(sourceId); + if (data) { + setActiveLineups(data.lineups || []); + setLineupNotice(data.notice || null); + // Always update changesRemaining from server — includes null (unknown) and 0 (locked) + if (data.changes_remaining !== undefined) { + setChangesRemaining(data.changes_remaining); + } + if (data.changes_reset_at !== undefined) { + setChangesResetAt(data.changes_reset_at); + } + } + } finally { + setLoadingLineups(false); + } + }, [sourceId]); + + useEffect(() => { + if (sourceId) { + fetchActiveLineups(); + // Fetch country list from SD API per their recommendation to not hardcode + fetch('https://json.schedulesdirect.org/20141201/available/countries') + .then(r => r.json()) + .then(data => { + const all = Object.values(data).flat(); + const mapped = all + .filter(c => c.shortName && c.fullName) + .map(c => ({ value: c.shortName, label: c.fullName })) + .sort((a, b) => a.label.localeCompare(b.label)); + if (mapped.length > 0) setCountries(mapped); + }) + .catch(() => {}); // fallback list remains if fetch fails + } + }, [sourceId, fetchActiveLineups]); + + const handleSearch = async () => { + if (!postalCode.trim()) return; + setSearching(true); + setSearchResults([]); + try { + const data = await API.searchSDLineups(sourceId, country, postalCode.trim()); + if (data) { + setSearchResults(data.lineups || []); + } + } finally { + setSearching(false); + } + }; + + const handleAdd = async (lineup) => { + setAddingLineup(lineup.lineup); + try { + const result = await API.addSDLineup(sourceId, lineup.lineup); + if (!result) return; + + // Update changesRemaining from response + if (result.changes_remaining !== undefined && result.changes_remaining !== null) { + setChangesRemaining(result.changes_remaining); + } + + if (result.error === 'daily_limit_reached') { + setChangesRemaining(0); + await fetchActiveLineups(); // reload to get reset_at from server + return; + } + + if (result.error === 'duplicate_lineup') { + showNotification({ title: 'Already added', message: result.message, color: 'yellow' }); + return; + } + + if (result.error === 'max_lineups_reached') { + showNotification({ title: 'Maximum lineups reached', message: result.message, color: 'orange' }); + return; + } + + if (result.error) { + showNotification({ title: 'Unable to add lineup', message: result.message, color: 'red' }); + return; + } + + if (result.code === 0) { + showNotification({ title: 'Lineup added', message: lineup.name, color: 'green' }); + await fetchActiveLineups(); + } + } finally { + setAddingLineup(null); + } + }; + + const handleRemove = async (lineup) => { + setRemovingLineup(lineup.lineup); + try { + const result = await API.deleteSDLineup(sourceId, lineup.lineup); + if (result && result.code === 0) { + showNotification({ title: 'Lineup removed', message: lineup.name, color: 'blue' }); + if (result.changes_remaining !== undefined && result.changes_remaining !== null) { + setChangesRemaining(result.changes_remaining); + } + await fetchActiveLineups(); + setSearchResults([]); + } + } finally { + setRemovingLineup(null); + } + }; + + const activeLineupIds = new Set(activeLineups.map((l) => l.lineup)); + const atMax = activeLineups.length >= maxLineups; + + return ( + + + + + Manage Lineups + + + {loadingLineups ? '...' : `${activeLineups.length} / ${maxLineups}`} + + + + {/* Active lineups */} + {loadingLineups ? ( + + + + ) : activeLineups.length === 0 ? ( + + {lineupNotice || 'No lineups configured. Search below to add one.'} + + ) : ( + + {activeLineups.map((lineup) => ( + + + {lineup.name} + + {lineup.transport} · {lineup.location} · {lineup.lineup} + + + + + ))} + + )} + + {/* Search section */} + + Add a lineup + + + {atMax && ( + }> + Maximum of {maxLineups} lineups reached. Remove one to add another. + + )} + + {changesRemaining === 0 && ( + }> + You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period.{' '} + {changesResetAt && ( + Limit resets at {new Date(changesResetAt).toUTCString()}. + )} + {!changesResetAt && Limit resets 24 hours after the first add of the day. } + Learn more + + )} + + {changesRemaining === 1 && ( + }> + You have 1 lineup addition remaining today. Use it carefully — Schedules Direct limits adds to 6 per 24-hour period.{' '} + Learn more + + )} + + {changesRemaining === 2 && ( + }> + You have 2 lineup additions remaining today. Schedules Direct limits adds to 6 per 24-hour period.{' '} + Learn more + + )} + + + onChange?.(e.target.value)} + > + + {(data ?? []).map((opt) => { + const val = typeof opt === 'string' ? opt : opt.value; + const lbl = typeof opt === 'string' ? opt : opt.label; + return ; + })} + + ), + Loader: ({ size }) =>
, + Badge: ({ children, color }) => {children}, + ScrollArea: ({ children }) =>
{children}
, + Table: ({ children }) => {children}
, + Tooltip: ({ children }) =>
{children}
, })); // ── Imports after mocks ──────────────────────────────────────────────────────── From cc41ab613f40927bbdfb75e400cd985c02e44a57 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 05:52:17 +0000 Subject: [PATCH 003/152] fix(tests): update EPG test for SD username/password fields --- frontend/src/components/forms/__tests__/EPG.test.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index ce004431..e1d926b5 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -399,10 +399,11 @@ describe('EPG', () => { expect(screen.queryByTestId('input-api-key')).not.toBeInTheDocument(); }); - it('shows API key input when source type requires it', () => { + it('shows username and password inputs when source type is schedules_direct', () => { const epg = makeEPG({ source_type: 'schedules_direct' }); render(); - expect(screen.getByTestId('input-api-key')).toBeInTheDocument(); + expect(screen.getByTestId('input-username')).toBeInTheDocument(); + expect(screen.getByTestId('input-password')).toBeInTheDocument(); }); }); From b236c965ebe330fd4d719481877a2b7bd4cfcf42 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 06:02:04 +0000 Subject: [PATCH 004/152] fix(tests): mock API module in EPG test to prevent real HTTP requests --- frontend/src/components/forms/__tests__/EPG.test.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index e1d926b5..e7e85372 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -2,6 +2,15 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../api.js', () => ({ + default: { + getSDLineups: vi.fn().mockResolvedValue([]), + addSDLineup: vi.fn().mockResolvedValue({ success: true }), + deleteSDLineup: vi.fn().mockResolvedValue({ success: true }), + searchSDLineups: vi.fn().mockResolvedValue([]), + }, +})); + vi.mock('../../../utils/notificationUtils.js', () => ({ showNotification: vi.fn(), })); From aa27fcc3623fbafcb75ac034fdd7657b79a3fa8e Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 06:08:47 +0000 Subject: [PATCH 005/152] fix: only fetch SD countries and lineups when source type is schedules_direct --- frontend/src/components/forms/EPG.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 82ddffe9..ffb91c24 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -82,7 +82,7 @@ const SDLineupManager = ({ sourceId }) => { }, [sourceId]); useEffect(() => { - if (sourceId) { + if (sourceId && sourceType === 'schedules_direct') { fetchActiveLineups(); // Fetch country list from SD API per their recommendation to not hardcode fetch('https://json.schedulesdirect.org/20141201/available/countries') From 60a61e78e3b4bd57e4ddb4df96e0cf323f09cac2 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 06:17:19 +0000 Subject: [PATCH 006/152] fix(tests): mock global fetch and restore SDLineupManager useEffect guard --- frontend/src/components/forms/EPG.jsx | 2 +- frontend/src/components/forms/__tests__/EPG.test.jsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index ffb91c24..82ddffe9 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -82,7 +82,7 @@ const SDLineupManager = ({ sourceId }) => { }, [sourceId]); useEffect(() => { - if (sourceId && sourceType === 'schedules_direct') { + if (sourceId) { fetchActiveLineups(); // Fetch country list from SD API per their recommendation to not hardcode fetch('https://json.schedulesdirect.org/20141201/available/countries') diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index e7e85372..01671a45 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -2,6 +2,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Utility mocks ────────────────────────────────────────────────────────────── +global.fetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({}), +}); + vi.mock('../../api.js', () => ({ default: { getSDLineups: vi.fn().mockResolvedValue([]), From 96f501b0fc9de3f04288c67a44ecaa0786f2f17d Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 06:24:46 +0000 Subject: [PATCH 007/152] fix(tests): correct API mock path in EPG test --- frontend/src/components/forms/__tests__/EPG.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index 01671a45..bd8ad1cd 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -6,7 +6,7 @@ global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({}), }); -vi.mock('../../api.js', () => ({ +vi.mock('../../../api.js', () => ({ default: { getSDLineups: vi.fn().mockResolvedValue([]), addSDLineup: vi.fn().mockResolvedValue({ success: true }), From ba5623c248e05dac2c8080d68e41e7ab935d2149 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 21:10:47 +0000 Subject: [PATCH 008/152] refactor(migrations): consolidate SD migrations into single 0023_schedules_direct --- .../epg/migrations/0023_epgsource_username.py | 23 ------------------ ...hedule_md5.py => 0023_schedules_direct.py} | 24 ++++++++++++++++++- .../0024_alter_epgsource_api_key.py | 21 ---------------- 3 files changed, 23 insertions(+), 45 deletions(-) delete mode 100644 apps/epg/migrations/0023_epgsource_username.py rename apps/epg/migrations/{0025_sd_fields_and_schedule_md5.py => 0023_schedules_direct.py} (76%) delete mode 100644 apps/epg/migrations/0024_alter_epgsource_api_key.py diff --git a/apps/epg/migrations/0023_epgsource_username.py b/apps/epg/migrations/0023_epgsource_username.py deleted file mode 100644 index 1a4c5ed3..00000000 --- a/apps/epg/migrations/0023_epgsource_username.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated migration — adds username field to EPGSource for Schedules Direct auth - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('epg', '0022_alter_epgdata_name'), - ] - - operations = [ - migrations.AddField( - model_name='epgsource', - name='username', - field=models.CharField( - max_length=255, - blank=True, - null=True, - help_text='Username for Schedules Direct authentication', - ), - ), - ] diff --git a/apps/epg/migrations/0025_sd_fields_and_schedule_md5.py b/apps/epg/migrations/0023_schedules_direct.py similarity index 76% rename from apps/epg/migrations/0025_sd_fields_and_schedule_md5.py rename to apps/epg/migrations/0023_schedules_direct.py index b02c94f2..6c4efe1c 100644 --- a/apps/epg/migrations/0025_sd_fields_and_schedule_md5.py +++ b/apps/epg/migrations/0023_schedules_direct.py @@ -5,10 +5,32 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('epg', '0024_alter_epgsource_api_key'), + ('epg', '0022_alter_epgdata_name'), ] operations = [ + # Add username field to EPGSource + migrations.AddField( + model_name='epgsource', + name='username', + field=models.CharField( + max_length=255, + blank=True, + null=True, + help_text='Username for Schedules Direct authentication', + ), + ), + # Update api_key help_text to reflect SD password usage + migrations.AlterField( + model_name='epgsource', + name='api_key', + field=models.CharField( + max_length=255, + blank=True, + null=True, + help_text='Password for Schedules Direct authentication', + ), + ), # Add sd_changes_remaining to EPGSource migrations.AddField( model_name='epgsource', diff --git a/apps/epg/migrations/0024_alter_epgsource_api_key.py b/apps/epg/migrations/0024_alter_epgsource_api_key.py deleted file mode 100644 index e40a6baf..00000000 --- a/apps/epg/migrations/0024_alter_epgsource_api_key.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('epg', '0023_epgsource_username'), - ] - - operations = [ - migrations.AlterField( - model_name='epgsource', - name='api_key', - field=models.CharField( - max_length=255, - blank=True, - null=True, - help_text='Password for Schedules Direct authentication', - ), - ), - ] From d066c3748aec5c5b06de67ac2a460782072a9467 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 21:31:23 +0000 Subject: [PATCH 009/152] fix: set Dispatcharr/version User-Agent for SD API requests per SergeantPanda approval --- apps/epg/tasks.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 479df9e2..c4b58fe4 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -2055,23 +2055,17 @@ def fetch_schedules_direct(source, stations_only=False): logger.info(f"SD source {source.id}: No prior full refresh detected — skipping 2-hour guard for first full fetch.") # ------------------------------------------------------------------------- - # Resolve user agent + # Build SD-specific headers + # SD API spec requires the User-Agent to identify the application and version. + # SergeantPanda confirmed Dispatcharr should identify itself properly. # ------------------------------------------------------------------------- - stream_settings = CoreSettings.get_stream_settings() - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" - default_user_agent_id = stream_settings.get('default_user_agent') - if default_user_agent_id: - try: - ua_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() - if ua_obj and ua_obj.user_agent: - user_agent = ua_obj.user_agent - except (ValueError, Exception) as e: - logger.warning(f"Could not resolve default user agent, using fallback: {e}") + from version import __version__ as dispatcharr_version + sd_user_agent = f"Dispatcharr/{dispatcharr_version}" def _sd_headers(token=None): h = { 'Content-Type': 'application/json', - 'User-Agent': user_agent, + 'User-Agent': sd_user_agent, } if token: h['token'] = token From 6f316977ad423b2c9204e13924a3f526235fca26 Mon Sep 17 00:00:00 2001 From: mwhit Date: Mon, 18 May 2026 22:46:56 +0000 Subject: [PATCH 010/152] fix(epg): address reviewer feedback on SD integration - Rename api_key to password (RemoveField + AddField) for clarity and reusability - Move sd_changes_remaining/sd_changes_reset_at to custom_properties JSONField instead of nullable columns on all EPGSource rows - Replace sd_program_md5 column on ProgramData with SDProgramMD5 relation table keyed by epg_source + program_id, fixing the key mismatch bug in program MD5 delta detection - Fix total_changed counter: .items() -> .values() so len() returns date count not tuple length - Add missing SD lineup management endpoints to api_views.py (sd_lineups GET/POST/DELETE, sd_lineups_search POST) - Regenerate migration 0023_schedules_direct using Django to ensure correct index names and schema --- apps/epg/api_views.py | 320 +++++++++++++++++++ apps/epg/migrations/0023_schedules_direct.py | 84 ++--- apps/epg/models.py | 78 +++-- apps/epg/serializers.py | 2 +- apps/epg/tasks.py | 52 +-- apps/epg/tests/test_schedules_direct.py | 28 +- frontend/src/components/forms/EPG.jsx | 12 +- frontend/src/components/tables/EPGsTable.jsx | 6 +- 8 files changed, 441 insertions(+), 141 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 296f91b8..f0d80c5f 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -108,6 +108,326 @@ class EPGSourceViewSet(viewsets.ModelViewSet): return super().partial_update(request, *args, **kwargs) + def _sd_authenticate(self, source): + """ + Authenticate with Schedules Direct using stored credentials. + Returns (token, None) on success or (None, Response) on failure. + """ + import hashlib + import requests as http_requests + from apps.epg.tasks import SD_BASE_URL + from version import __version__ as dispatcharr_version + + username = (source.username or '').strip() + password = (source.password or '').strip() + if not username or not password: + return None, Response( + {"error": "Username and password are required."}, + status=status.HTTP_400_BAD_REQUEST + ) + + sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest() + try: + auth_response = http_requests.post( + f"{SD_BASE_URL}/token", + json={'username': username, 'password': sha1_password}, + headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'}, + timeout=15, + ) + auth_response.raise_for_status() + token = auth_response.json().get('token') + if not token: + return None, Response( + {"error": "Authentication failed. Check your credentials."}, + status=status.HTTP_401_UNAUTHORIZED + ) + return token, None + except http_requests.exceptions.RequestException as e: + return None, Response( + {"error": f"Authentication failed: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + + def _get_sd_reset_at(self, source): + """Retrieve stored reset timestamp from EPGSource model field.""" + reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at') + return reset_at_str + + def _get_sd_changes_remaining(self, source): + """ + Retrieve stored changesRemaining from EPGSource model field. + If a reset timestamp exists and has passed (midnight UTC), clears the + lockout automatically so the user can make adds again. + """ + from django.utils import timezone + + cp = source.custom_properties or {} + changes_remaining = cp.get('sd_changes_remaining') + reset_at_str = cp.get('sd_changes_reset_at') + from django.utils.dateparse import parse_datetime + reset_at = parse_datetime(reset_at_str) if reset_at_str else None + + # If we have a reset timestamp and it has passed, clear the lockout + if changes_remaining == 0 and reset_at: + if timezone.now() >= reset_at: + cp = source.custom_properties or {} + cp.pop('sd_changes_remaining', None) + cp.pop('sd_changes_reset_at', None) + source.custom_properties = cp + source.save(update_fields=['custom_properties']) + return None + + return changes_remaining + + def _save_sd_changes_remaining(self, source, changes_remaining): + """Persist changesRemaining to EPGSource model field.""" + cp = source.custom_properties or {} + cp['sd_changes_remaining'] = changes_remaining + source.custom_properties = cp + source.save(update_fields=['custom_properties']) + + def _save_sd_lockout(self, source): + """ + Persist a hard lockout to EPGSource model fields when SD returns + 4100 MAX_LINEUP_CHANGES_REACHED. Lockout clears automatically at next + midnight UTC per SD's documented reset schedule. + """ + from django.utils import timezone + from datetime import datetime, timedelta, timezone as dt_timezone + + now = timezone.now() + # SD resets on a 24-hour rolling window from when the limit was hit. + # We use 24 hours from now rather than next midnight UTC to be safe. + reset_at = now + timedelta(hours=24) + + cp = source.custom_properties or {} + cp['sd_changes_remaining'] = 0 + cp['sd_changes_reset_at'] = reset_at.isoformat() + source.custom_properties = cp + source.save(update_fields=['custom_properties']) + logger.warning( + f"SD source {source.id}: daily add limit reached (4100). " + f"Lockout set until {reset_at.isoformat()}." + ) + + @action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups") + def sd_lineups(self, request, pk=None): + """ + GET — list lineups currently on the SD account + POST — add a lineup (body: {"lineup": "USA-NJ29486-X"}) + DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"}) + """ + import requests as http_requests + from apps.epg.tasks import SD_BASE_URL + from version import __version__ as dispatcharr_version + + source = self.get_object() + if source.source_type != 'schedules_direct': + return Response( + {"error": "This action is only available for Schedules Direct sources."}, + status=status.HTTP_400_BAD_REQUEST + ) + + token, error = self._sd_authenticate(source) + if error: + return error + + headers = { + 'Content-Type': 'application/json', + 'User-Agent': f'Dispatcharr/{dispatcharr_version}', + 'token': token, + } + + if request.method == "GET": + try: + resp = http_requests.get( + f"{SD_BASE_URL}/lineups", + headers=headers, + timeout=15, + ) + if resp.status_code == 400: + sd_data = resp.json() + sd_code = sd_data.get('code') + if sd_code == 4102: + return Response({ + "lineups": [], + "max_lineups": 4, + "changes_remaining": self._get_sd_changes_remaining(source), + "changes_reset_at": self._get_sd_reset_at(source), + "notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.", + }) + resp.raise_for_status() + data = resp.json() + lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)] + return Response({ + "lineups": lineups, + "max_lineups": 4, + "changes_remaining": self._get_sd_changes_remaining(source), + "changes_reset_at": self._get_sd_reset_at(source), + }) + except http_requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to fetch lineups: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + + elif request.method == "POST": + lineup_id = request.data.get('lineup') + if not lineup_id: + return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.put( + f"{SD_BASE_URL}/lineups/{lineup_id}", + headers=headers, + timeout=15, + ) + sd_data = resp.json() + sd_code = sd_data.get('code') + + if resp.status_code == 400 or resp.status_code == 403: + if sd_code == 4100: + self._save_sd_lockout(source) + return Response({ + "error": "daily_limit_reached", + "message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.", + "changes_remaining": 0, + "docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform", + }, status=status.HTTP_200_OK) + if sd_code == 4101: + return Response({ + "error": "max_lineups_reached", + "message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.", + "changes_remaining": self._get_sd_changes_remaining(source), + }, status=status.HTTP_200_OK) + if sd_code == 2100: + return Response({ + "error": "duplicate_lineup", + "message": "This lineup is already on your Schedules Direct account.", + "changes_remaining": self._get_sd_changes_remaining(source), + }, status=status.HTTP_200_OK) + return Response({ + "error": sd_data.get('message', 'Failed to add lineup.'), + "changes_remaining": self._get_sd_changes_remaining(source), + }, status=status.HTTP_200_OK) + + resp.raise_for_status() + + # Persist changesRemaining to custom_properties + changes_remaining = sd_data.get('changesRemaining') + if changes_remaining is not None: + self._save_sd_changes_remaining(source, changes_remaining) + + logger.info( + f"SD lineup added for source {source.id}: {lineup_id}. " + f"changesRemaining: {changes_remaining}" + ) + + # Re-fetch stations so the new lineup's stations are available for matching + from apps.epg.tasks import fetch_schedules_direct_stations + fetch_schedules_direct_stations.delay(source.id) + + return Response({ + **sd_data, + "changes_remaining": changes_remaining, + }) + except http_requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to add lineup: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + + elif request.method == "DELETE": + lineup_id = request.data.get('lineup') + if not lineup_id: + return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.delete( + f"{SD_BASE_URL}/lineups/{lineup_id}", + headers=headers, + timeout=15, + ) + if resp.status_code == 400: + sd_data = resp.json() + sd_code = sd_data.get('code') + if sd_code == 2103: + return Response({ + "response": "OK", + "code": 0, + "message": "Lineup not found on account — already removed.", + "changes_remaining": self._get_sd_changes_remaining(source), + }) + resp.raise_for_status() + logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}") + return Response({ + **resp.json(), + "changes_remaining": self._get_sd_changes_remaining(source), + }) + except http_requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to remove lineup: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + + @action(detail=True, methods=["post"], url_path="sd-lineups/search") + def sd_lineups_search(self, request, pk=None): + """ + Search available headends/lineups by country and postal code. + Body: {"country": "USA", "postalcode": "07030"} + Returns a flat list of lineups across all matching headends. + """ + import requests as http_requests + from apps.epg.tasks import SD_BASE_URL + + source = self.get_object() + if source.source_type != 'schedules_direct': + return Response( + {"error": "This action is only available for Schedules Direct sources."}, + status=status.HTTP_400_BAD_REQUEST + ) + + country = request.data.get('country', '').strip() + postalcode = request.data.get('postalcode', '').strip() + if not country or not postalcode: + return Response( + {"error": "country and postalcode are required."}, + status=status.HTTP_400_BAD_REQUEST + ) + + token, error = self._sd_authenticate(source) + if error: + return error + + headers = { + 'Content-Type': 'application/json', + 'User-Agent': f'Dispatcharr/{dispatcharr_version}', + 'token': token, + } + + try: + resp = http_requests.get( + f"{SD_BASE_URL}/headends", + params={'country': country, 'postalcode': postalcode}, + headers=headers, + timeout=15, + ) + resp.raise_for_status() + headends = resp.json() + lineups = [] + for headend in headends: + for lineup in headend.get('lineups', []): + lineups.append({ + 'lineup': lineup.get('lineup'), + 'name': lineup.get('name'), + 'transport': headend.get('transport'), + 'location': headend.get('location'), + 'headend': headend.get('headend'), + }) + return Response({"lineups": lineups}) + except http_requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to search headends: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) # ───────────────────────────── # 2) Program API (CRUD) # ───────────────────────────── diff --git a/apps/epg/migrations/0023_schedules_direct.py b/apps/epg/migrations/0023_schedules_direct.py index 6c4efe1c..458f11f9 100644 --- a/apps/epg/migrations/0023_schedules_direct.py +++ b/apps/epg/migrations/0023_schedules_direct.py @@ -1,5 +1,7 @@ -from django.db import migrations, models +# Generated by Django 6.0.4 on 2026-05-18 22:38 + import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): @@ -9,60 +11,38 @@ class Migration(migrations.Migration): ] operations = [ - # Add username field to EPGSource + migrations.RemoveField( + model_name='epgsource', + name='api_key', + ), + migrations.AddField( + model_name='epgsource', + name='password', + field=models.CharField(blank=True, help_text='Password for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True), + ), migrations.AddField( model_name='epgsource', name='username', - field=models.CharField( - max_length=255, - blank=True, - null=True, - help_text='Username for Schedules Direct authentication', - ), + field=models.CharField(blank=True, help_text='Username for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True), ), - # Update api_key help_text to reflect SD password usage migrations.AlterField( model_name='epgsource', - name='api_key', - field=models.CharField( - max_length=255, - blank=True, - null=True, - help_text='Password for Schedules Direct authentication', - ), + name='custom_properties', + field=models.JSONField(blank=True, default=dict, help_text='Custom properties for source-specific configuration', null=True), ), - # Add sd_changes_remaining to EPGSource - migrations.AddField( - model_name='epgsource', - name='sd_changes_remaining', - field=models.IntegerField( - blank=True, - null=True, - help_text='Number of Schedules Direct lineup additions remaining today (resets at midnight UTC)', - ), + migrations.CreateModel( + name='SDProgramMD5', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('program_id', models.CharField(help_text='Schedules Direct programID (e.g. EP123456789)', max_length=64)), + ('md5', models.CharField(help_text='MD5 hash of the program metadata from Schedules Direct', max_length=22)), + ('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_program_md5s', to='epg.epgsource')), + ], + options={ + 'indexes': [models.Index(fields=['epg_source', 'program_id'], name='epg_sdprogr_epg_sou_222ce1_idx')], + 'unique_together': {('epg_source', 'program_id')}, + }, ), - # Add sd_changes_reset_at to EPGSource - migrations.AddField( - model_name='epgsource', - name='sd_changes_reset_at', - field=models.DateTimeField( - blank=True, - null=True, - help_text='UTC datetime when the Schedules Direct daily lineup change counter resets', - ), - ), - # Add sd_program_md5 to ProgramData - migrations.AddField( - model_name='programdata', - name='sd_program_md5', - field=models.CharField( - blank=True, - max_length=22, - null=True, - help_text='MD5 hash from Schedules Direct for delta detection', - ), - ), - # Create SDScheduleMD5 model migrations.CreateModel( name='SDScheduleMD5', fields=[ @@ -71,16 +51,10 @@ class Migration(migrations.Migration): ('date', models.DateField(help_text='Schedule date (UTC)')), ('md5', models.CharField(help_text='MD5 hash of the schedule for this station/date from Schedules Direct', max_length=22)), ('last_modified', models.DateTimeField(help_text='Last modified timestamp from Schedules Direct')), - ('epg_source', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='sd_schedule_md5s', - to='epg.epgsource', - )), + ('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_schedule_md5s', to='epg.epgsource')), ], options={ - 'indexes': [ - models.Index(fields=['epg_source', 'station_id'], name='epg_sdsche_epg_sou_idx'), - ], + 'indexes': [models.Index(fields=['epg_source', 'station_id'], name='epg_sdsched_epg_sou_0d700e_idx')], 'unique_together': {('epg_source', 'station_id', 'date')}, }, ), diff --git a/apps/epg/models.py b/apps/epg/models.py index 0d4058fb..177623a3 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -31,9 +31,9 @@ class EPGSource(models.Model): source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES) url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV username = models.CharField(max_length=255, blank=True, null=True, - help_text='Username for Schedules Direct authentication') - api_key = models.CharField(max_length=255, blank=True, null=True, - help_text='Password for Schedules Direct authentication') # For Schedules Direct + help_text='Username for credential-based EPG sources (e.g. Schedules Direct)') + password = models.CharField(max_length=255, blank=True, null=True, + help_text='Password for credential-based EPG sources (e.g. Schedules Direct)') is_active = models.BooleanField(default=True) file_path = models.CharField(max_length=1024, blank=True, null=True) extracted_file_path = models.CharField(max_length=1024, blank=True, null=True, @@ -46,22 +46,12 @@ class EPGSource(models.Model): default=dict, blank=True, null=True, - help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)" + help_text="Custom properties for source-specific configuration" ) priority = models.PositiveIntegerField( default=0, help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel." ) - sd_changes_remaining = models.IntegerField( - null=True, - blank=True, - help_text="Number of Schedules Direct lineup additions remaining today (resets at midnight UTC)" - ) - sd_changes_reset_at = models.DateTimeField( - null=True, - blank=True, - help_text="UTC datetime when the Schedules Direct daily lineup change counter resets" - ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, @@ -87,18 +77,13 @@ class EPGSource(models.Model): def get_cache_file(self): import mimetypes - # Use a temporary extension for initial download - # The actual extension will be determined after content inspection file_ext = ".tmp" - # If file_path is already set and contains an extension, use that - # This handles cases where we've already detected the proper type if self.file_path and os.path.exists(self.file_path): _, existing_ext = os.path.splitext(self.file_path) if existing_ext: file_ext = existing_ext else: - # Try to detect the MIME type and map to extension mime_type, _ = mimetypes.guess_type(self.file_path) if mime_type: if mime_type == 'application/gzip' or mime_type == 'application/x-gzip': @@ -107,48 +92,33 @@ class EPGSource(models.Model): file_ext = '.zip' elif mime_type == 'application/xml' or mime_type == 'text/xml': file_ext = '.xml' - # For files without mime type detection, try peeking at content else: try: with open(self.file_path, 'rb') as f: header = f.read(4) - # Check for gzip magic number (1f 8b) if header[:2] == b'\x1f\x8b': file_ext = '.gz' - # Check for zip magic number (PK..) elif header[:2] == b'PK': file_ext = '.zip' - # Check for XML elif header[:5] == b'': file_ext = '.xml' - except Exception as e: - # If we can't read the file, just keep the default extension + except Exception: pass filename = f"{self.id}{file_ext}" - - # Build full path in MEDIA_ROOT/cached_epg cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg") - - # Create directory if it doesn't exist os.makedirs(cache_dir, exist_ok=True) - cache = os.path.join(cache_dir, filename) - return cache def save(self, *args, **kwargs): - # Prevent auto_now behavior by handling updated_at manually if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']: - # Don't modify updated_at for regular updates kwargs.setdefault('update_fields', []) if 'updated_at' in kwargs['update_fields']: kwargs['update_fields'].remove('updated_at') super().save(*args, **kwargs) class EPGData(models.Model): - # Removed the Channel foreign key. We now just store the original tvg_id - # and a name (which might simply be the tvg_id if no real channel exists). tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) name = models.CharField(max_length=512) icon_url = models.URLField(max_length=500, null=True, blank=True) @@ -167,7 +137,6 @@ class EPGData(models.Model): return f"EPG Data for {self.name}" class ProgramData(models.Model): - # Each programme is associated with an EPGData record. epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs") start_time = models.DateTimeField() end_time = models.DateTimeField() @@ -176,12 +145,6 @@ class ProgramData(models.Model): description = models.TextField(blank=True, null=True) tvg_id = models.CharField(max_length=255, null=True, blank=True) custom_properties = models.JSONField(default=dict, blank=True, null=True) - sd_program_md5 = models.CharField( - max_length=22, - null=True, - blank=True, - help_text="MD5 hash from Schedules Direct for delta detection" - ) def __str__(self): return f"{self.title} ({self.start_time} - {self.end_time})" @@ -220,3 +183,34 @@ class SDScheduleMD5(models.Model): def __str__(self): return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})" + + +class SDProgramMD5(models.Model): + """ + Caches per-program MD5 hashes from Schedules Direct. + Keyed by epg_source + program_id (SD's programID e.g. EP123456789). + Used for program-level delta detection to avoid re-downloading unchanged + program metadata, minimizing API calls against SD's rate-limited endpoints. + """ + epg_source = models.ForeignKey( + EPGSource, + on_delete=models.CASCADE, + related_name="sd_program_md5s", + ) + program_id = models.CharField( + max_length=64, + help_text="Schedules Direct programID (e.g. EP123456789)" + ) + md5 = models.CharField( + max_length=22, + help_text="MD5 hash of the program metadata from Schedules Direct" + ) + + class Meta: + unique_together = ('epg_source', 'program_id') + indexes = [ + models.Index(fields=['epg_source', 'program_id']), + ] + + def __str__(self): + return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})" diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 049a9713..9d4e80c0 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -23,7 +23,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'source_type', 'url', 'username', - 'api_key', + 'password', 'is_active', 'file_path', 'refresh_interval', diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index c4b58fe4..0ba20864 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -24,7 +24,7 @@ from core.models import UserAgent, CoreSettings from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -from .models import EPGSource, EPGData, ProgramData +from .models import EPGSource, EPGData, ProgramData, SDScheduleMD5, SDProgramMD5 from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event logger = logging.getLogger(__name__) @@ -2011,7 +2011,7 @@ def fetch_schedules_direct(source, stations_only=False): # Validate credentials # ------------------------------------------------------------------------- username = (source.username or '').strip() - password = (source.api_key or '').strip() + password = (source.password or '').strip() if not username or not password: msg = "Schedules Direct source requires both a username and password." @@ -2392,7 +2392,7 @@ def fetch_schedules_direct(source, stations_only=False): if cached != server_info['md5']: changed_by_station.setdefault(sid, []).append(date_str) - total_changed = sum(len(v) for v in changed_by_station.items()) + total_changed = sum(len(v) for v in changed_by_station.values()) total_possible = len(station_ids) * len(date_list) logger.info(f"Schedule MD5 check: {len(server_md5s)} hashes checked, {total_changed} station/date combinations changed (of {total_possible} possible).") _sd_send_ws_sync(source.id, "parsing_programs", 38, @@ -2402,15 +2402,6 @@ def fetch_schedules_direct(source, stations_only=False): schedules_by_station = {sid: [] for sid in station_ids} program_ids_needed = set() - # Existing cached program MD5s for delta detection in step 6 - existing_program_md5s = { - p.tvg_id + '|' + p.title: p.sd_program_md5 - for p in ProgramData.objects.filter( - epg__epg_source=source, - sd_program_md5__isnull=False, - ).only('tvg_id', 'title', 'sd_program_md5') - } - if not changed_by_station: logger.info("No schedule changes detected — skipping schedule and program downloads.") _sd_send_ws_sync(source.id, "parsing_programs", 100, status="success", @@ -2539,16 +2530,16 @@ def fetch_schedules_direct(source, stations_only=False): if pid and md5: schedule_program_md5s[pid] = md5 - # Load cached program MD5s from DB keyed by programID via tvg_id + # Load cached program MD5s from SDProgramMD5 table, keyed by programID cached_prog_md5s = { - p.tvg_id: p.sd_program_md5 - for p in ProgramData.objects.filter( - epg__epg_source=source, - sd_program_md5__isnull=False, - ).only('tvg_id', 'sd_program_md5').distinct('tvg_id') + r.program_id: r.md5 + for r in SDProgramMD5.objects.filter( + epg_source=source, + program_id__in=program_ids_needed, + ).only('program_id', 'md5') } - # Only fetch programs where MD5 differs from our cache + # Only fetch programs where MD5 differs from our cached value programs_to_fetch = [ pid for pid in program_ids_needed if schedule_program_md5s.get(pid) != cached_prog_md5s.get(pid) @@ -2735,7 +2726,6 @@ def fetch_schedules_direct(source, stations_only=False): description=desc or None, tvg_id=sid, custom_properties=custom_props or None, - sd_program_md5=schedule_program_md5s.get(pid), )) total_programs += 1 @@ -2758,6 +2748,28 @@ def fetch_schedules_direct(source, stations_only=False): _sd_send_ws_sync(source.id, "parsing_programs", min(98, progress)) logger.info(f"Committed {total_programs} Schedules Direct programs to database.") + + # Upsert SDProgramMD5 records for programs we just downloaded + # This updates the cache so future fetches can skip unchanged programs + if schedule_program_md5s: + md5_records = [ + SDProgramMD5( + epg_source=source, + program_id=pid, + md5=md5, + ) + for pid, md5 in schedule_program_md5s.items() + if pid in programs_to_fetch # Only cache what we actually downloaded + ] + if md5_records: + SDProgramMD5.objects.bulk_create( + md5_records, + update_conflicts=True, + unique_fields=['epg_source', 'program_id'], + update_fields=['md5'], + ) + logger.info(f"Cached {len(md5_records)} program MD5s for future delta detection.") + except Exception as db_error: msg = f"Database error persisting Schedules Direct programs: {db_error}" logger.error(msg, exc_info=True) diff --git a/apps/epg/tests/test_schedules_direct.py b/apps/epg/tests/test_schedules_direct.py index 782750af..e73f5ad3 100644 --- a/apps/epg/tests/test_schedules_direct.py +++ b/apps/epg/tests/test_schedules_direct.py @@ -34,7 +34,7 @@ class EPGSourceUsernameFieldTests(TestCase): name='SD Test', source_type='schedules_direct', username='testuser', - api_key='testpass', + password='testpass', ) source.refresh_from_db() self.assertEqual(source.username, 'testuser') @@ -64,20 +64,20 @@ class EPGSourceSerializerSDTests(TestCase): name='SD Serializer Test', source_type='schedules_direct', username='sduser', - api_key='sdpass', + password='sdpass', ) data = EPGSourceSerializer(source).data self.assertIn('username', data) self.assertEqual(data['username'], 'sduser') - def test_api_key_in_serializer_fields(self): + def test_password_in_serializer_fields(self): source = EPGSource.objects.create( name='SD API Key Test', source_type='schedules_direct', - api_key='secret', + password='secret', ) data = EPGSourceSerializer(source).data - self.assertIn('api_key', data) + self.assertIn('password', data) # --------------------------------------------------------------------------- @@ -87,31 +87,31 @@ class EPGSourceSerializerSDTests(TestCase): class FetchSchedulesDirectCredentialTests(TestCase): """fetch_schedules_direct must reject sources missing credentials.""" - def _make_source(self, username=None, api_key=None): + def _make_source(self, username=None, password=None): return EPGSource.objects.create( name='SD Cred Test', source_type='schedules_direct', username=username, - api_key=api_key, + password=password, ) def test_missing_username_sets_error_status(self): from apps.epg.tasks import fetch_schedules_direct - source = self._make_source(username=None, api_key='pass') + source = self._make_source(username=None, password='pass') fetch_schedules_direct(source) source.refresh_from_db() self.assertEqual(source.status, EPGSource.STATUS_ERROR) def test_missing_password_sets_error_status(self): from apps.epg.tasks import fetch_schedules_direct - source = self._make_source(username='user', api_key=None) + source = self._make_source(username='user', password=None) fetch_schedules_direct(source) source.refresh_from_db() self.assertEqual(source.status, EPGSource.STATUS_ERROR) def test_empty_username_sets_error_status(self): from apps.epg.tasks import fetch_schedules_direct - source = self._make_source(username=' ', api_key='pass') + source = self._make_source(username=' ', password='pass') fetch_schedules_direct(source) source.refresh_from_db() self.assertEqual(source.status, EPGSource.STATUS_ERROR) @@ -142,7 +142,7 @@ class FetchSchedulesDirectAuthTests(TestCase): name='SD Hash Test', source_type='schedules_direct', username='sduser', - api_key=plaintext, + password=plaintext, ) with patch('apps.epg.tasks.send_epg_update'): @@ -171,7 +171,7 @@ class FetchSchedulesDirectAuthTests(TestCase): name='SD Auth Fail', source_type='schedules_direct', username='baduser', - api_key='badpass', + password='badpass', ) with patch('apps.epg.tasks.send_epg_update'): @@ -191,7 +191,7 @@ class FetchSchedulesDirectAuthTests(TestCase): name='SD Network Error', source_type='schedules_direct', username='user', - api_key='pass', + password='pass', ) with patch('apps.epg.tasks.send_epg_update'): @@ -247,7 +247,7 @@ class SDSourceSignalTests(TestCase): name='SD Signal Test', source_type='schedules_direct', username='u', - api_key='p', + password='p', ) epg_data = EPGData.objects.create( tvg_id='sd-test-station', diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 82ddffe9..369011f3 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -347,7 +347,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => { source_type: 'xmltv', url: '', username: '', - api_key: '', + password: '', is_active: true, refresh_interval: 24, cron_expression: '', @@ -402,7 +402,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => { source_type: epg.source_type, url: epg.url, username: epg.username || '', - api_key: epg.api_key, + password: epg.password || '', is_active: epg.is_active, refresh_interval: epg.refresh_interval, cron_expression: epg.cron_expression || '', @@ -565,13 +565,13 @@ const EPG = ({ epg = null, isOpen, onClose }) => { key={form.key('username')} /> )} diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 01eefdbb..a18dcc7b 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -299,7 +299,7 @@ const EPGsTable = () => { } else { value = cell.getValue() || - row.original.api_key || + row.original.password || row.original.file_path || ''; tooltip = value; @@ -702,8 +702,8 @@ Source Type: ${epgToDelete.source_type} ${ epgToDelete.url ? `URL: ${epgToDelete.url}` - : epgToDelete.api_key - ? `API Key: ${epgToDelete.api_key}` + : epgToDelete.password + ? `API Key: ${epgToDelete.password}` : epgToDelete.file_path ? `File Path: ${epgToDelete.file_path}` : '' From d8c0850fd8472e53dceeb2608686c9d15375d01b Mon Sep 17 00:00:00 2001 From: mwhit Date: Tue, 19 May 2026 23:48:11 +0000 Subject: [PATCH 011/152] fix(epg): address second round of reviewer feedback - Fix NameError in sd_lineups_search: add dispatcharr_version import - Fix DELETE handler: persist changesRemaining from SD response to custom_properties - Fix lockout reset time: calculate next midnight UTC (00:00Z) instead of rolling now+24h, matching SD's documented reset behavior - Remove redundant explicit index from SDProgramMD5: unique_together already creates this index in Postgres --- apps/epg/api_views.py | 25 +++++++++++++------ ...024_remove_sdprogrammd5_redundant_index.py | 17 +++++++++++++ apps/epg/models.py | 3 --- 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index f0d80c5f..2cfa8f47 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -188,17 +188,22 @@ class EPGSourceViewSet(viewsets.ModelViewSet): def _save_sd_lockout(self, source): """ - Persist a hard lockout to EPGSource model fields when SD returns - 4100 MAX_LINEUP_CHANGES_REACHED. Lockout clears automatically at next - midnight UTC per SD's documented reset schedule. + Persist a hard lockout to EPGSource custom_properties when SD returns + 4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at + 00:00Z (midnight UTC) per SD's documented behavior — error 4100 states + "lineup changes for today" and all SD rate counters reset at midnight UTC. + Lockout clears automatically when the next midnight UTC passes. """ from django.utils import timezone from datetime import datetime, timedelta, timezone as dt_timezone now = timezone.now() - # SD resets on a 24-hour rolling window from when the limit was hit. - # We use 24 hours from now rather than next midnight UTC to be safe. - reset_at = now + timedelta(hours=24) + # Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window + tomorrow = (now + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0, + tzinfo=dt_timezone.utc + ) + reset_at = tomorrow cp = source.custom_properties or {} cp['sd_changes_remaining'] = 0 @@ -357,9 +362,14 @@ class EPGSourceViewSet(viewsets.ModelViewSet): "changes_remaining": self._get_sd_changes_remaining(source), }) resp.raise_for_status() + sd_data = resp.json() + # SD returns changesRemaining on deletes — persist it + changes_remaining = sd_data.get('changesRemaining') + if changes_remaining is not None: + self._save_sd_changes_remaining(source, changes_remaining) logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}") return Response({ - **resp.json(), + **sd_data, "changes_remaining": self._get_sd_changes_remaining(source), }) except http_requests.exceptions.RequestException as e: @@ -377,6 +387,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): """ import requests as http_requests from apps.epg.tasks import SD_BASE_URL + from version import __version__ as dispatcharr_version source = self.get_object() if source.source_type != 'schedules_direct': diff --git a/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py b/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py new file mode 100644 index 00000000..f1499657 --- /dev/null +++ b/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.4 on 2026-05-19 23:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0023_schedules_direct'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='sdprogrammd5', + name='epg_sdprogr_epg_sou_222ce1_idx', + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 177623a3..c9870d0b 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -208,9 +208,6 @@ class SDProgramMD5(models.Model): class Meta: unique_together = ('epg_source', 'program_id') - indexes = [ - models.Index(fields=['epg_source', 'program_id']), - ] def __str__(self): return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})" From 4d5ffaf2231ace22b37a4431fd678deef36007b8 Mon Sep 17 00:00:00 2001 From: mwhit Date: Tue, 19 May 2026 23:56:56 +0000 Subject: [PATCH 012/152] fix(epg): address second round of reviewer feedback - Fix NameError in sd_lineups_search: add dispatcharr_version import - Fix DELETE handler: persist changesRemaining from SD response to custom_properties - Fix lockout reset time: calculate next midnight UTC (00:00Z) instead of rolling now+24h, matching SD's documented reset behavior - Remove redundant explicit index from SDProgramMD5: unique_together already creates this index in Postgres - Regenerate 0023_schedules_direct migration to incorporate all changes cleanly in a single migration --- apps/epg/migrations/0023_schedules_direct.py | 3 +-- .../0024_remove_sdprogrammd5_redundant_index.py | 17 ----------------- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py diff --git a/apps/epg/migrations/0023_schedules_direct.py b/apps/epg/migrations/0023_schedules_direct.py index 458f11f9..b0e835a9 100644 --- a/apps/epg/migrations/0023_schedules_direct.py +++ b/apps/epg/migrations/0023_schedules_direct.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.4 on 2026-05-18 22:38 +# Generated by Django 6.0.4 on 2026-05-19 23:55 import django.db.models.deletion from django.db import migrations, models @@ -39,7 +39,6 @@ class Migration(migrations.Migration): ('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_program_md5s', to='epg.epgsource')), ], options={ - 'indexes': [models.Index(fields=['epg_source', 'program_id'], name='epg_sdprogr_epg_sou_222ce1_idx')], 'unique_together': {('epg_source', 'program_id')}, }, ), diff --git a/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py b/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py deleted file mode 100644 index f1499657..00000000 --- a/apps/epg/migrations/0024_remove_sdprogrammd5_redundant_index.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 6.0.4 on 2026-05-19 23:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('epg', '0023_schedules_direct'), - ] - - operations = [ - migrations.RemoveIndex( - model_name='sdprogrammd5', - name='epg_sdprogr_epg_sou_222ce1_idx', - ), - ] From e2074c192116a6274249c047e84a7216c827d862 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 21:52:28 -0400 Subject: [PATCH 013/152] fix: reduce intermittent 503s on stats page and API requests --- apps/proxy/live_proxy/channel_status.py | 28 +++++++++++++------------ docker/nginx.conf | 2 ++ frontend/src/pages/Stats.jsx | 6 +----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..2bf93ae8 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -419,7 +419,8 @@ class ChannelStatus: info['stream_name'] = stream_name # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + # TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip + total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: total_bytes = int(total_bytes_bytes) info['total_bytes'] = total_bytes @@ -460,24 +461,25 @@ class ChannelStatus: client_key = RedisKeys.client_metadata(channel_id, client_id) + # Fetch only the fields we need in one round-trip (hmget returns a list + # in the same order as the requested keys; values are None if absent) + ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( + client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ) + client_info = { 'client_id': client_id, + 'user_agent': ua, } - user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') - client_info['user_agent'] = user_agent_bytes + if ip: + client_info['ip_address'] = ip - ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') - if ip_address_bytes: - client_info['ip_address'] = ip_address_bytes + if connected_at: + client_info['connected_at'] = float(connected_at) - connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') - if connected_at_bytes: - client_info['connected_at'] = float(connected_at_bytes) - - user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') - if user_id_bytes: - client_info['user_id'] = user_id_bytes + if user_id: + client_info['user_id'] = user_id output_format = proxy_server.redis_client.hget(client_key, 'output_format') client_info['output_format'] = output_format or 'mpegts' diff --git a/docker/nginx.conf b/docker/nginx.conf index c5cb429e..bacfb655 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,8 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_read_timeout 300s; + uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ec5f7042..ea4ef5f2 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -239,11 +239,7 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Fetch initial stats on component mount (for immediate data when navigating to page) - useEffect(() => { - fetchChannelStats(); - fetchVODStats(); - }, [fetchChannelStats, fetchVODStats]); + // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) useEffect(() => { console.log('Processing channel stats:', channelStats); From 6787e4912fc8ff9f5a93db62e4cfb930e783ac01 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:12:09 -0400 Subject: [PATCH 014/152] refactor: replace multiple hget calls with hmget where fields are known - channel_status.py: fold output_format and output_profile_id into the existing hmget, reducing per-client Redis calls from 3 to 1 - utils.py: collapse 2x and 3x hget per key in scan_iter loops (get_user_active_connections) to single hmget calls - views.py: replace 3x hget on channel metadata in the worker-join path of stream_ts with a single hmget --- apps/proxy/live_proxy/channel_status.py | 13 +++++++------ apps/proxy/live_proxy/views.py | 13 +++++-------- apps/proxy/utils.py | 9 ++++----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 2bf93ae8..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -463,13 +463,18 @@ class ChannelStatus: # Fetch only the fields we need in one round-trip (hmget returns a list # in the same order as the requested keys; values are None if absent) - ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( - client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ua, ip, connected_at, user_id, output_format, raw_profile_id = ( + proxy_server.redis_client.hmget( + client_key, + 'user_agent', 'ip_address', 'connected_at', 'user_id', + 'output_format', 'output_profile_id', + ) ) client_info = { 'client_id': client_id, 'user_agent': ua, + 'output_format': output_format or 'mpegts', } if ip: @@ -481,10 +486,6 @@ class ChannelStatus: if user_id: client_info['user_id'] = user_id - output_format = proxy_server.redis_client.hget(client_key, 'output_format') - client_info['output_format'] = output_format or 'mpegts' - - raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id') if raw_profile_id and raw_profile_id not in ('None', '0', ''): client_info['output_profile_id'] = int(raw_profile_id) else: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..bf39f6a9 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.URL - ) - ua_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.USER_AGENT - ) - profile_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.STREAM_PROFILE + url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget( + metadata_key, + ChannelMetadataField.URL, + ChannelMetadataField.USER_AGENT, + ChannelMetadataField.STREAM_PROFILE, ) if url_bytes: diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index db2339ed..69ee15e4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -98,8 +98,7 @@ def get_user_active_connections(user_id): channel_id = parts[2] client_id = parts[4] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'connected_at') + client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at') logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] channel_id = {channel_id}") @@ -124,9 +123,9 @@ def get_user_active_connections(user_id): if len(parts) >= 2: client_id = parts[1] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'created_at') - content_uuid = redis_client.hget(key, 'content_uuid') + client_user_id, connected_at, content_uuid = redis_client.hmget( + key, 'user_id', 'created_at', 'content_uuid' + ) logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") From a2c69be861d38711e40f0dad1976500b33423f12 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:22:48 -0400 Subject: [PATCH 015/152] fix: update Stats tests to expect single mount fetch after removing duplicate useEffect Tests were asserting fetchActiveChannelStats/getVODStats were called twice on mount, encoding React strict mode's double-invoke behavior rather than testing actual application logic. Update all affected call count assertions to reflect the correct single-fetch behavior. --- frontend/src/pages/__tests__/Stats.test.jsx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index d809a47a..21cfebf7 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -220,8 +220,8 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); }); }); @@ -283,16 +283,16 @@ describe('StatsPage', () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); // Advance timers by 5 seconds await act(async () => { vi.advanceTimersByTime(5000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); @@ -303,13 +303,13 @@ describe('StatsPage', () => { useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); vi.useRealTimers(); }); @@ -319,7 +319,7 @@ describe('StatsPage', () => { const { unmount } = render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); unmount(); @@ -328,7 +328,7 @@ describe('StatsPage', () => { }); // Should not fetch again after unmount - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -338,14 +338,14 @@ describe('StatsPage', () => { it('refreshes stats when Refresh Now button is clicked', async () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh Now'); fireEvent.click(refreshButton); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); @@ -406,14 +406,14 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(1); }); const stopButton = await screen.findByTestId('stop-vod-client-client-1'); fireEvent.click(stopButton); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); From 0d1d1f27221d883fbc675edc0f78ccbbf26115e0 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Sat, 23 May 2026 14:20:13 -0400 Subject: [PATCH 016/152] fix: revert nginx timeouts; fire stats fetch on mount regardless of interval --- docker/nginx.conf | 2 -- frontend/src/pages/Stats.jsx | 16 +++++++--------- frontend/src/pages/__tests__/Stats.test.jsx | 10 +++++++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index bacfb655..c5cb429e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,8 +21,6 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; - uwsgi_read_timeout 300s; - uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ea4ef5f2..86c7f1c3 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -212,19 +212,19 @@ const StatsPage = () => { } }, [setVodStats]); + // Always fetch once on mount, regardless of polling interval setting + useEffect(() => { + fetchChannelStats(); + fetchVODStats(); + }, [fetchChannelStats, fetchVODStats]); + // Set up polling for stats when on stats page useEffect(() => { - const location = window.location; - const isOnStatsPage = location.pathname === '/stats'; + const isOnStatsPage = window.location.pathname === '/stats'; if (isOnStatsPage && refreshInterval > 0) { setIsPollingActive(true); - // Initial fetch - fetchChannelStats(); - fetchVODStats(); - - // Set up interval const interval = setInterval(() => { fetchChannelStats(); fetchVODStats(); @@ -239,8 +239,6 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) - useEffect(() => { console.log('Processing channel stats:', channelStats); if ( diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index 21cfebf7..92cf8560 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -297,19 +297,23 @@ describe('StatsPage', () => { vi.useRealTimers(); }); - it('does not poll when interval is 0', async () => { + it('does not poll when interval is 0 but still fetches once on mount', async () => { vi.useFakeTimers(); useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should still fetch once on mount even with interval = 0 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should not have polled — count stays at 1 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); From 92d3068be0e964478ec2cb18f41183cf916a490a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:25:44 -0500 Subject: [PATCH 017/152] fix: ensure migrations run automatically after restoring backups to prevent missing schema issues --- CHANGELOG.md | 4 ++++ apps/backups/tasks.py | 3 +++ frontend/src/components/backups/BackupManager.jsx | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e998abd..3464dd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. + ## [0.25.1] - 2026-05-23 ### Fixed diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py index f531fef8..a169b7db 100644 --- a/apps/backups/tasks.py +++ b/apps/backups/tasks.py @@ -1,6 +1,7 @@ import logging import traceback from celery import shared_task +from django.core.management import call_command from . import services @@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str): backup_file = backup_dir / filename logger.info(f"[RESTORE] Backup file path: {backup_file}") services.restore_backup(backup_file) + logger.info(f"[RESTORE] Running migrations after restore...") + call_command('migrate', '--noinput', verbosity=1) logger.info(f"[RESTORE] Task {self.request.id} completed successfully") return { "status": "completed", diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 02b7d15d..c58755b2 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -439,12 +439,12 @@ export default function BackupManager() { try { await API.restoreBackup(selectedBackup.name); notifications.show({ - title: 'Success', + title: 'Restore Complete', message: - 'Backup restored successfully. You may need to refresh the page.', + 'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.', color: 'green', }); - setTimeout(() => window.location.reload(), 2000); + setTimeout(() => window.location.reload(), 4000); } catch (error) { notifications.show({ title: 'Error', From fb260356aad4ff8b44035fe88cc769742df361a7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:45:49 -0500 Subject: [PATCH 018/152] changelog: Update changelog for stats PR. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3464dd31..7cef64db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance + +- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012) + ### Fixed - **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. From 0db557384a7219d7d14426eba7ee2b053e5550a0 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 24 May 2026 10:59:53 -0400 Subject: [PATCH 019/152] configurable per-page, sticky pagination footer, fix update sort order and disabled button badge color --- .../components/theme/SizedInstallButton.jsx | 2 +- frontend/src/pages/PluginBrowse.jsx | 122 ++++++++++++------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx index 780aedb5..7bcb1819 100644 --- a/frontend/src/components/theme/SizedInstallButton.jsx +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -71,7 +71,7 @@ const SizedInstallButton = ({ : 'var(--mantine-primary-color-filled-hover)' : colorVar, filter: isDisabled - ? 'brightness(0.65) saturate(0.7)' + ? 'grayscale(1) brightness(0.55)' : hovered ? 'brightness(0.86)' : 'brightness(0.82)', diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index ccc81b28..dd4a3a6d 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -8,6 +8,7 @@ import { Group, Loader, Modal, + NativeSelect, NumberInput, Pagination, Select, @@ -68,6 +69,7 @@ export default function PluginBrowsePage() { const saveIntervalTimer = useRef(null); const recentlyInstalledSlugs = useRef(new Set()); + const recentlyUpdatedSlugs = useRef(new Set()); const recentlyUninstalledSlugs = useRef(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -75,7 +77,14 @@ export default function PluginBrowsePage() { const [filterRepo, setFilterRepo] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [page, setPage] = useState(1); - const perPage = 9; + const [perPage, setPerPage] = useState(() => { + const stored = localStorage.getItem('pluginBrowsePerPage'); + return stored && !isNaN(Number(stored)) ? Number(stored) : 9; + }); + const handlePerPageChange = (value) => { + setPerPage(Number(value)); + localStorage.setItem('pluginBrowsePerPage', value); + }; const hasFetched = useRef(false); @@ -294,6 +303,8 @@ export default function PluginBrowsePage() { // Pre-sort weights: deprecated → installed → incompatible sink to bottom (in that order) // Recently installed plugins are exempt so they don't jump away after install const weight = (p) => { + if (p.install_status === 'update_available') return -1; + if (recentlyUpdatedSlugs.current.has(p.slug)) return -1; if (recentlyInstalledSlugs.current.has(p.slug)) return 0; if (recentlyUninstalledSlugs.current.has(p.slug)) return 2; const meetsMin = @@ -335,10 +346,10 @@ export default function PluginBrowsePage() { appVersion, ]); - // Reset to page 1 when filters/search change + // Reset to page 1 when filters/search/page-size change React.useEffect(() => { setPage(1); - }, [searchQuery, filterRepo, filterStatus, sortBy]); + }, [searchQuery, filterRepo, filterStatus, sortBy, perPage]); const totalPages = Math.ceil(filteredPlugins.length / perPage); const paginatedPlugins = filteredPlugins.slice( @@ -347,7 +358,15 @@ export default function PluginBrowsePage() { ); return ( - + + @@ -467,38 +486,69 @@ export default function PluginBrowsePage() { )} {!loading && filteredPlugins.length > 0 && ( - <> - - {paginatedPlugins.map((p) => ( - 1} - onBeforeInstall={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - }} - onInstalled={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - fetchAvailablePlugins(); - }} - onUninstalled={(slug) => { - if (slug) recentlyUninstalledSlugs.current.add(slug); - }} - /> - ))} - - {totalPages > 1 && ( - - - - )} - + + {paginatedPlugins.map((p) => ( + 1} + onBeforeInstall={(slug) => { + if (slug) { + if (p.install_status === 'update_available') { + recentlyUpdatedSlugs.current.add(slug); + } else { + recentlyInstalledSlugs.current.add(slug); + } + } + }} + onInstalled={(slug) => { + if (slug) recentlyInstalledSlugs.current.add(slug); + fetchAvailablePlugins(); + }} + onUninstalled={(slug) => { + if (slug) recentlyUninstalledSlugs.current.add(slug); + }} + /> + ))} + + )} + + + + {!loading && filteredPlugins.length > 0 && ( + + + Page Size + handlePerPageChange(e.target.value)} + styles={{ input: { textAlignLast: 'center' } }} + /> + + + {`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`} + + + )} {/* Manage Repos Modal */} From ca6ab0cd023c2d315fc08d0963ed0497eb32e343 Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 24 May 2026 16:49:12 +0000 Subject: [PATCH 020/152] fix(epg): address third round of reviewer feedback --- apps/epg/serializers.py | 1 + apps/epg/tasks.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 9d4e80c0..a1354bf0 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -37,6 +37,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'epg_data_count', 'has_channels', ] + extra_kwargs = {'password': {'write_only': True}} def get_epg_data_count(self, obj): """Return the count of EPG data entries instead of all IDs to prevent large payloads""" diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 0ba20864..d5d99446 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -2540,17 +2540,17 @@ def fetch_schedules_direct(source, stations_only=False): } # Only fetch programs where MD5 differs from our cached value - programs_to_fetch = [ + programs_to_fetch = { pid for pid in program_ids_needed if schedule_program_md5s.get(pid) != cached_prog_md5s.get(pid) - ] + } logger.info( f"Program MD5 delta: {len(program_ids_needed)} programs in schedules, " f"{len(programs_to_fetch)} need downloading ({len(program_ids_needed) - len(programs_to_fetch)} unchanged).") program_metadata = {} - program_id_list = programs_to_fetch + program_id_list = list(programs_to_fetch) total_batches = max(1, (len(program_id_list) + SD_PROGRAM_BATCH_SIZE - 1) // SD_PROGRAM_BATCH_SIZE) if program_id_list: From 2070f9e281f762c62d2a37289740ac059a184745 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Mon, 25 May 2026 04:31:42 -0400 Subject: [PATCH 021/152] docker: split base image into builder and runtime stages Keeps compilers in the builder; runtime stage copies only the venv and NumPy wheel. --- docker/DispatcharrBase | 62 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 3040e189..61c460ce 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,4 +1,10 @@ -FROM lscr.io/linuxserver/ffmpeg:latest +# ============================================================================== +# Stage 1: builder +# Installs the Python toolchain + compilers, creates the virtual environment and +# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end +# up in the final image — only the artifacts below are copied forward. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS builder ENV DEBIAN_FRONTEND=noninteractive ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy @@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get update \ && apt-get install --no-install-recommends -y \ python3.13 python3.13-dev python3.13-venv libpython3.13 \ - libpcre3 libpcre3-dev libpq-dev procps pciutils \ - nginx comskip \ - vlc-bin vlc-plugin-base \ - build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build + libpcre3 libpcre3-dev libpq-dev \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \ + && rm -rf /var/lib/apt/lists/* # --- Install UV --- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ @@ -28,11 +33,12 @@ WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ COPY version.py /tmp/build/ COPY README.md /tmp/build/ -RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ - rm -rf /tmp/build +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +# build/pip are installed into the venv only long enough to produce the wheel, +# then uninstalled so they are not carried into the final image's venv. RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ @@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build cd numpy-*/ && \ $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ - cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \ uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip -# --- Clean up build dependencies to reduce image size --- -RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ - apt-get autoremove -y --purge && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* +# ============================================================================== +# Stage 2: final runtime image +# Same ffmpeg base, but installs only the runtime system libraries (no compilers) +# and copies the prebuilt virtual environment + NumPy wheel from the builder. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# --- Install runtime system dependencies (no build toolchain) --- +# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI; +# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget +# and software-properties-common are kept for TLS and the AIO runtime apt step. +RUN apt-get update && apt-get install --no-install-recommends -y \ + ca-certificates software-properties-common gnupg2 curl wget \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install --no-install-recommends -y \ + python3.13 python3.13-venv libpython3.13 \ + libpcre3 libpq-dev libopenblas0 procps pciutils \ + nginx comskip \ + vlc-bin vlc-plugin-base \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Install UV (used at runtime to swap in the legacy NumPy wheel) --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder --- +COPY --from=builder /dispatcharrpy /dispatcharrpy +COPY --from=builder /opt/numpy-*.whl /opt/ # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ From 94c8bd36bfffc6d361ff6f252037ccda9c1854c7 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Mon, 25 May 2026 11:12:46 -0400 Subject: [PATCH 022/152] perf: replace per-request DB connections with geventpool --- dispatcharr/settings.py | 12 ++++++++---- pyproject.toml | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index e8818108..1b103ccf 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -111,7 +111,7 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5 # Database optimization settings DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries -DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections +DATABASE_CONN_MAX_AGE = None # Managed by django-db-geventpool; None = persistent pool connections # Disable atomic requests for performance-sensitive views ATOMIC_REQUESTS = False @@ -221,13 +221,17 @@ if os.getenv("DB_ENGINE", None) == "sqlite": else: DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql", + "ENGINE": "django_db_geventpool.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": 20, # Per-worker pool size; 4 workers × 20 = 80 total < pg max_connections=100 + "REUSE_CONNS": 10, # Connections to keep warm between requests + }, } } @@ -238,9 +242,9 @@ else: ("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY), ], "PostgreSQL") - DATABASES["default"]["OPTIONS"] = { + DATABASES["default"]["OPTIONS"].update({ "sslmode": POSTGRES_SSL_MODE, - } + }) if POSTGRES_SSL_CA_CERT: DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT if POSTGRES_SSL_CERT: diff --git a/pyproject.toml b/pyproject.toml index db7efc78..fe7112f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "yt-dlp", "gevent==26.4.0", "psycogreen", + "django-db-geventpool", "daphne", "uwsgi", "django-cors-headers", From 6021bdf741a89164e568212f97b8365824a742cd Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:01 -0700 Subject: [PATCH 023/152] Extracted utils --- .../utils/forms/RecordingDetailsModalUtils.js | 21 ++ frontend/src/utils/forms/RecordingUtils.js | 181 ++++++++++++++++++ .../utils/forms/RecurringRuleModalUtils.js | 56 +++--- .../utils/forms/SeriesRuleEditorModalUtils.js | 84 ++++++++ .../src/utils/forms/StreamProfileUtils.js | 44 +++++ frontend/src/utils/forms/StreamUtils.js | 19 ++ frontend/src/utils/forms/UserAgentUtils.js | 17 ++ frontend/src/utils/forms/UserUtils.js | 150 +++++++++++++++ frontend/src/utils/guideUtils.js | 23 +-- 9 files changed, 544 insertions(+), 51 deletions(-) create mode 100644 frontend/src/utils/forms/RecordingUtils.js create mode 100644 frontend/src/utils/forms/SeriesRuleEditorModalUtils.js create mode 100644 frontend/src/utils/forms/StreamProfileUtils.js create mode 100644 frontend/src/utils/forms/StreamUtils.js create mode 100644 frontend/src/utils/forms/UserAgentUtils.js create mode 100644 frontend/src/utils/forms/UserUtils.js diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js index c81c3b53..2d9ea0ab 100644 --- a/frontend/src/utils/forms/RecordingDetailsModalUtils.js +++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js @@ -1,3 +1,5 @@ +import API from '../../api.js'; + export const getStatRows = (stats) => { return [ ['Video Codec', stats.video_codec], @@ -86,3 +88,22 @@ export const getUpcomingEpisodes = ( (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) ); }; + +export const getChannel = (id) => { + return API.getChannel(id); +}; + +export const updateRecordingMetadata = ( + recording, + editTitle, + editDescription +) => { + return API.updateRecordingMetadata(recording.id, { + title: editTitle || 'Custom Recording', + description: editDescription, + }); +}; + +export const refreshArtwork = (id) => { + return API.refreshArtwork(id); +}; diff --git a/frontend/src/utils/forms/RecordingUtils.js b/frontend/src/utils/forms/RecordingUtils.js new file mode 100644 index 00000000..fde3b6c5 --- /dev/null +++ b/frontend/src/utils/forms/RecordingUtils.js @@ -0,0 +1,181 @@ +import API from '../../api.js'; +import { + add, + diff, + format, + getNow, + initializeTime, + isValid, + roundToNearest, + setMillisecond, + setSecond, + toDate, + toTimeString, +} from '../dateTimeUtils.js'; +import { isNotEmpty } from '@mantine/form'; + +export const asDate = (value) => { + if (!value) return null; + if (value instanceof Date) return value; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const toIsoIfDate = (value) => { + const dt = asDate(value); + return dt ? dt.toISOString() : value; +}; + +export const toDateString = (value) => { + const dt = asDate(value); + return dt ? format(dt, 'YYYY-MM-DD') : null; +}; + +export const createRoundedDate = (minutesAhead = 0) => { + let rounded = roundToNearest(getNow(), 30); + rounded = setSecond(rounded, 0); + rounded = setMillisecond(rounded, 0); + return toDate(minutesAhead ? add(rounded, minutesAhead, 'minute') : rounded); +}; + +// robust onChange for TimeInput (string or event) +export const timeChange = (setter) => (valOrEvent) => { + if (typeof valOrEvent === 'string') setter(valOrEvent); + else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); +}; + +export const getChannelsSummary = () => { + return API.getChannelsSummary(); +}; +export const updateRecording = (id, data) => { + return API.updateRecording(id, data); +}; +export const createRecording = (data) => { + return API.createRecording(data); +}; +export const createRecurringRule = (data) => { + return API.createRecurringRule(data); +}; + +/** + * Sorts a channels array or object by channel_number (then name) and maps + * each entry to a { value, label } select option. + * + * @param {Array|Object} channels + * @param {(item: object) => string} [labelFn] defaults to `name || "Channel id"` + */ +export const sortedChannelOptions = (channels, labelFn) => { + const list = Array.isArray(channels) + ? channels + : Object.values(channels || {}); + + const defaultLabel = (item) => item.name || `Channel ${item.id}`; + + return [...list] + .sort((a, b) => { + const aNum = Number(a.channel_number) || 0; + const bNum = Number(b.channel_number) || 0; + if (aNum !== bNum) return aNum - bNum; + return (a.name || '').localeCompare(b.name || ''); + }) + .map((item) => ({ + value: String(item.id), + label: (labelFn ?? defaultLabel)(item), + })); +}; + +/** Label that includes the channel number prefix used in Recording/SeriesRule UIs. */ +export const numberedChannelLabel = (item) => + item.channel_number + ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` + : item.name || `Channel ${item.id}`; + +export const getSingleFormDefaults = (recording = null, channel = null) => { + const defaultStart = createRoundedDate(); + const defaultEnd = createRoundedDate(60); + return { + channel_id: recording + ? `${recording.channel}` + : channel + ? `${channel.id}` + : '', + start_time: recording + ? asDate(recording.start_time) || defaultStart + : defaultStart, + end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd, + }; +}; + +export const getRecurringFormDefaults = (channel = null) => { + const defaultStart = createRoundedDate(); + const defaultEnd = createRoundedDate(60); + const defaultDate = new Date(); + return { + channel_id: channel ? `${channel.id}` : '', + days_of_week: [], + start_time: format(defaultStart, 'HH:mm'), + end_time: format(defaultEnd, 'HH:mm'), + rule_name: channel?.name || '', + start_date: defaultDate, + end_date: defaultDate, + }; +}; + +export const buildSinglePayload = (values) => ({ + channel: values.channel_id, + start_time: toIsoIfDate(values.start_time), + end_time: toIsoIfDate(values.end_time), +}); + +export const buildRecurringPayload = (values) => ({ + channel: values.channel_id, + days_of_week: (values.days_of_week || []).map(Number), + start_time: toTimeString(values.start_time), + end_time: toTimeString(values.end_time), + start_date: toDateString(values.start_date), + end_date: toDateString(values.end_date), + name: values.rule_name?.trim() || '', +}); + +export const singleFormValidators = { + channel_id: isNotEmpty('Select a channel'), + start_time: isNotEmpty('Select a start time'), + end_time: (value, values) => { + const start = asDate(values.start_time); + const end = asDate(value); + if (!end) return 'Select an end time'; + if (start && end <= start) return 'End time must be after start time'; + return null; + }, +}; + +export const recurringFormValidators = { + channel_id: isNotEmpty('Select a channel'), + days_of_week: (value) => (value?.length ? null : 'Pick at least one day'), + start_time: (value) => (value ? null : 'Select a start time'), + end_time: (value, values) => { + if (!value) return 'Select an end time'; + const start = initializeTime( + values.start_time, + ['HH:mm', 'hh:mm A', 'h:mm A'], + null, + true + ); + const end = initializeTime( + value, + ['HH:mm', 'hh:mm A', 'h:mm A'], + null, + true + ); + if (isValid(start) && isValid(end) && diff(start, end, 'minute') === 0) + return 'End time must differ from start time'; + return null; + }, + end_date: (value, values) => { + const end = asDate(value); + const start = asDate(values.start_date); + if (!end) return 'Select an end date'; + if (start && end < start) return 'End date cannot be before start date'; + return null; + }, +}; diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js index db8a5dd8..5a747bf5 100644 --- a/frontend/src/utils/forms/RecurringRuleModalUtils.js +++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js @@ -1,25 +1,12 @@ import API from '../../api.js'; -import { toTimeString } from '../dateTimeUtils.js'; -import dayjs from 'dayjs'; - -export const getChannelOptions = (channels) => { - const list = Array.isArray(channels) - ? channels - : Object.values(channels || {}); - return list - .sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) { - return (a.name || '').localeCompare(b.name || ''); - } - return aNum - bNum; - }) - .map((item) => ({ - value: `${item.id}`, - label: item.name || `Channel ${item.id}`, - })); -}; +import { + getNow, + isAfter, + parseDate, + toDate, + toTimeString, +} from '../dateTimeUtils.js'; +import { buildRecurringPayload } from './RecordingUtils.js'; export const getUpcomingOccurrences = ( recordings, @@ -35,7 +22,7 @@ export const getUpcomingOccurrences = ( .filter( (rec) => rec?.custom_properties?.rule?.id === ruleId && - toUserTime(rec.start_time).isAfter(now) + isAfter(toUserTime(rec.start_time), now) ) .sort( (a, b) => @@ -45,17 +32,7 @@ export const getUpcomingOccurrences = ( export const updateRecurringRule = async (ruleId, values) => { await API.updateRecurringRule(ruleId, { - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: values.start_date - ? dayjs(values.start_date).format('YYYY-MM-DD') - : null, - end_date: values.end_date - ? dayjs(values.end_date).format('YYYY-MM-DD') - : null, - name: values.rule_name?.trim() || '', + ...buildRecurringPayload(values), enabled: Boolean(values.enabled), }); }; @@ -67,3 +44,16 @@ export const deleteRecurringRuleById = async (ruleId) => { export const updateRecurringRuleEnabled = async (ruleId, checked) => { await API.updateRecurringRule(ruleId, { enabled: checked }); }; + +export const getFormDefaults = (rule) => { + return { + channel_id: `${rule.channel}`, + days_of_week: (rule.days_of_week || []).map((d) => String(d)), + rule_name: rule.name || '', + start_time: toTimeString(rule.start_time), + end_time: toTimeString(rule.end_time), + start_date: parseDate(rule.start_date) || toDate(getNow()), + end_date: parseDate(rule.end_date), + enabled: Boolean(rule.enabled), + }; +}; diff --git a/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js new file mode 100644 index 00000000..dbcd545e --- /dev/null +++ b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js @@ -0,0 +1,84 @@ +import API from '../../api.js'; +import { + numberedChannelLabel, + sortedChannelOptions, +} from './RecordingUtils.js'; + +export const TITLE_MODES = [ + { label: 'Exact', value: 'exact' }, + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; +export const DESCRIPTION_MODES = [ + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; +export const EPISODE_MODES = [ + { label: 'All episodes', value: 'all' }, + { label: 'New only', value: 'new' }, +]; + +export function formatRange(start, end) { + try { + const s = new Date(start); + const e = new Date(end); + + if(isNaN(s) || isNaN(e)) throw new Error('Invalid date'); + + const sameDay = s.toDateString() === e.toDateString(); + const dateStr = s.toLocaleDateString(); + const startStr = s.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + const endStr = e.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + return sameDay + ? `${dateStr} ${startStr} - ${endStr}` + : `${dateStr} ${startStr} -> ${e.toLocaleString()}`; + } catch { + return `${start} - ${end}`; + } +} + +export const previewSeriesRule = (debouncedPreviewKey, controller) => { + return API.previewSeriesRule(debouncedPreviewKey, { + signal: controller.signal, + }); +}; + +export const getTvgOptions = (tvgs) => { + const seen = new Set(); + const options = []; + for (const t of tvgs || []) { + if (!t.tvg_id || seen.has(t.tvg_id)) continue; + seen.add(t.tvg_id); + options.push({ + value: t.tvg_id, + label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, + }); + } + return options.sort((a, b) => a.label.localeCompare(b.label)); +}; + +export const getChannelOptions = (allChannels, tvgsById, tvgId) => { + const sorted = sortedChannelOptions(allChannels, numberedChannelLabel); + const matching = []; + const others = []; + for (const item of sorted) { + const channel = allChannels.find((c) => String(c.id) === item.value); + const cTvg = channel?.epg_data_id + ? tvgsById?.[channel.epg_data_id]?.tvg_id + : null; + if (tvgId && cTvg && String(cTvg) === String(tvgId)) { + matching.push(item); + } else { + others.push(item); + } + } + return [...matching, ...others]; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/StreamProfileUtils.js b/frontend/src/utils/forms/StreamProfileUtils.js new file mode 100644 index 00000000..edcc7ba6 --- /dev/null +++ b/frontend/src/utils/forms/StreamProfileUtils.js @@ -0,0 +1,44 @@ +// Built-in commands supported by Dispatcharr out of the box. +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; +import * as Yup from 'yup'; + +export const BUILT_IN_COMMANDS = [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: 'streamlink', label: 'Streamlink' }, + { value: 'cvlc', label: 'VLC' }, + { value: 'yt-dlp', label: 'yt-dlp' }, + { value: '__custom__', label: 'Custom…' }, +]; + +// Default parameter examples for each built-in command. +export const COMMAND_EXAMPLES = { + ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1', + streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout', + cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}', + 'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}', +}; + +// Returns '__custom__' when the command isn't one of the built-ins, +// otherwise returns the command value itself. +export const toCommandSelection = (command) => + BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') + ? command + : '__custom__'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + command: Yup.string().required('Command is required'), + parameters: Yup.string(), +}); + +export const getResolver = () => { + return yupResolver(schema); +}; + +export const updateStreamProfile = (profileId, values) => { + return API.updateStreamProfile({ id: profileId, ...values }); +}; +export const addStreamProfile = (values) => { + return API.addStreamProfile(values); +}; diff --git a/frontend/src/utils/forms/StreamUtils.js b/frontend/src/utils/forms/StreamUtils.js new file mode 100644 index 00000000..59998233 --- /dev/null +++ b/frontend/src/utils/forms/StreamUtils.js @@ -0,0 +1,19 @@ +import * as Yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + url: Yup.string().required('URL is required').min(0), +}); + +export const getResolver = () => { + return yupResolver(schema); +}; + +export const updateStream = (streamId, payload) => { + return API.updateStream({ id: streamId, ...payload }); +}; +export const addStream = (payload) => { + return API.addStream(payload); +}; diff --git a/frontend/src/utils/forms/UserAgentUtils.js b/frontend/src/utils/forms/UserAgentUtils.js new file mode 100644 index 00000000..c05c7681 --- /dev/null +++ b/frontend/src/utils/forms/UserAgentUtils.js @@ -0,0 +1,17 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import API from '../../api.js'; +import * as Yup from 'yup'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + user_agent: Yup.string().required('User-Agent is required'), +}); +export const getResolver = () => { + return yupResolver(schema); +}; +export const updateUserAgent = (userAgentId, values) => { + return API.updateUserAgent({ id: userAgentId, ...values }); +}; +export const addUserAgent = (values) => { + return API.addUserAgent(values); +}; diff --git a/frontend/src/utils/forms/UserUtils.js b/frontend/src/utils/forms/UserUtils.js new file mode 100644 index 00000000..256d84bb --- /dev/null +++ b/frontend/src/utils/forms/UserUtils.js @@ -0,0 +1,150 @@ +import {NETWORK_ACCESS_OPTIONS, USER_LEVELS} from "../../constants.js"; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js'; +import API from "../../api.js"; + +const isValidNetworkEntry = (entry) => + entry.match(IPV4_CIDR_REGEX) || + entry.match(IPV6_CIDR_REGEX) || + (entry + '/32').match(IPV4_CIDR_REGEX) || + (entry + '/128').match(IPV6_CIDR_REGEX); +const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS); + +export const createUser = (values) => { + return API.createUser(values); +}; + +export const updateUser = (userId, values, isAdmin, authUser) => { + return API.updateUser( + userId, + values, + isAdmin ? false : authUser.id === userId + ); +}; + +export const generateApiKey = (payload) => { + return API.generateApiKey(payload); +}; + +export const revokeApiKey = (payload) => { + return API.revokeApiKey(payload); +}; + +export const userToFormValues = (user) => { + const customProps = user.custom_properties || {}; + const networks = customProps.allowed_networks || {}; + + return { + username: user.username, + first_name: user.first_name || '', + last_name: user.last_name || '', + email: user.email, + user_level: `${user.user_level}`, + stream_limit: user.stream_limit || 0, + channel_profiles: + user.channel_profiles.length > 0 + ? user.channel_profiles.map((id) => `${id}`) + : ['0'], + xc_password: customProps.xc_password || '', + output_format: customProps.output_format || '', + output_profile: customProps.output_profile + ? `${customProps.output_profile}` + : '', + hide_adult_content: customProps.hide_adult_content || false, + epg_days: customProps.epg_days || 0, + epg_prev_days: customProps.epg_prev_days || 0, + allowed_ips: [ + ...new Set( + NETWORK_KEYS.flatMap((key) => + networks[key] ? networks[key].split(',').filter(Boolean) : [] + ) + ), + ], + }; +}; + +export const formValuesToPayload = (values, existingUser) => { + const customProps = { ...(existingUser?.custom_properties || {}) }; + const payload = { ...values }; + + customProps.xc_password = payload.xc_password || ''; + delete payload.xc_password; + + customProps.output_format = payload.output_format || null; + delete payload.output_format; + + customProps.output_profile = payload.output_profile + ? parseInt(payload.output_profile, 10) + : null; + delete payload.output_profile; + + customProps.hide_adult_content = payload.hide_adult_content || false; + delete payload.hide_adult_content; + + customProps.epg_days = payload.epg_days || 0; + delete payload.epg_days; + + customProps.epg_prev_days = payload.epg_prev_days || 0; + delete payload.epg_prev_days; + + const joined = (payload.allowed_ips || []).join(','); + delete payload.allowed_ips; + const allowed_networks = {}; + if (joined) { + NETWORK_KEYS.forEach((key) => { + allowed_networks[key] = joined; + }); + } + customProps.allowed_networks = allowed_networks; + + payload.custom_properties = customProps; + + if (payload.channel_profiles?.includes('0')) { + payload.channel_profiles = []; + } + + return payload; +}; + +export const getFormInitialValues = () => { + return { + username: '', + first_name: '', + last_name: '', + email: '', + user_level: '0', + stream_limit: 0, + password: '', + xc_password: '', + output_format: '', + output_profile: '', + channel_profiles: [], + hide_adult_content: false, + epg_days: 0, + epg_prev_days: 0, + allowed_ips: [], + }; +} + +export const getFormValidators = (user) => { + return (values) => ({ + username: !values.username + ? 'Username is required' + : values.user_level == USER_LEVELS.STREAMER && + !values.username.match(/^[a-z0-9]+$/i) + ? 'Streamer username must be alphanumeric' + : null, + password: + !user && !values.password && values.user_level != USER_LEVELS.STREAMER + ? 'Password is required' + : null, + xc_password: + values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i) + ? 'XC password must be alphanumeric' + : null, + allowed_ips: (values.allowed_ips || []).some( + (t) => !isValidNetworkEntry(t) + ) + ? 'Invalid IP address or CIDR range' + : null, + }); +} \ No newline at end of file diff --git a/frontend/src/utils/guideUtils.js b/frontend/src/utils/guideUtils.js index 58831713..9d6ec73c 100644 --- a/frontend/src/utils/guideUtils.js +++ b/frontend/src/utils/guideUtils.js @@ -11,7 +11,7 @@ import { getNow, getNowMs, roundToNearest, -} from '../utils/dateTimeUtils.js'; +} from './dateTimeUtils.js'; import API from '../api.js'; export const PROGRAM_HEIGHT = 90; @@ -309,25 +309,12 @@ export const getRuleByProgram = (rules, program) => { ); }; -export const createRecording = async (channel, program) => { - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); +export const createRecording = async (values) => { + await API.createRecording(values); }; -export const createSeriesRule = async (program, mode) => { - await API.createSeriesRule({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); -}; - -export const evaluateSeriesRule = async (program) => { - await API.evaluateSeriesRules(program.tvg_id); +export const createSeriesRule = async (values) => { + await API.createSeriesRule(values); }; export const calculateLeftScrollPosition = (program, start) => { From 1dd0446701ffd51031f5c80acf023483d7630817 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:59 -0700 Subject: [PATCH 024/152] Added tests for utils --- .../RecordingDetailsModalUtils.test.js | 104 ++++ .../forms/__tests__/RecordingUtils.test.js | 503 ++++++++++++++++++ .../__tests__/RecurringRuleModalUtils.test.js | 163 ++---- .../SeriesRuleEditorModalUtils.test.js | 425 +++++++++++++++ .../__tests__/StreamProfileUtils.test.js | 197 +++++++ .../utils/forms/__tests__/StreamUtils.test.js | 95 ++++ .../forms/__tests__/UserAgentUtils.test.js | 95 ++++ .../utils/forms/__tests__/UserUtils.test.js | 432 +++++++++++++++ 8 files changed, 1902 insertions(+), 112 deletions(-) create mode 100644 frontend/src/utils/forms/__tests__/RecordingUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserAgentUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js index c86da2a8..303b891b 100644 --- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js @@ -1,8 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils'; import dayjs from 'dayjs'; +import API from '../../../api.js'; + +vi.mock('../../../api.js', () => ({ + default: { + getChannel: vi.fn(), + updateRecordingMetadata: vi.fn(), + refreshArtwork: vi.fn(), + }, +})); + describe('RecordingDetailsModalUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('getStatRows', () => { it('should return all stats when all values are present', () => { const stats = { @@ -624,4 +638,94 @@ describe('RecordingDetailsModalUtils', () => { expect(result).toEqual([]); }); }); + + describe('getChannel', () => { + it('should call API.getChannel with the given id', async () => { + const mockChannel = { id: 42, name: 'Channel 1' }; + API.getChannel.mockResolvedValue(mockChannel); + + const result = await RecordingDetailsModalUtils.getChannel(42); + + expect(API.getChannel).toHaveBeenCalledWith(42); + expect(result).toEqual(mockChannel); + }); + + it('should propagate API errors', async () => { + API.getChannel.mockRejectedValue(new Error('Not found')); + + await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow('Not found'); + }); + }); + + describe('updateRecordingMetadata', () => { + it('should call API with title and description', async () => { + const mockResponse = { id: 1, title: 'Updated Title' }; + API.updateRecordingMetadata.mockResolvedValue(mockResponse); + + const recording = { id: 1 }; + const result = await RecordingDetailsModalUtils.updateRecordingMetadata( + recording, + 'Updated Title', + 'Updated description' + ); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(1, { + title: 'Updated Title', + description: 'Updated description', + }); + expect(result).toEqual(mockResponse); + }); + + it('should default title to "Custom Recording" when editTitle is empty', async () => { + API.updateRecordingMetadata.mockResolvedValue({}); + + const recording = { id: 5 }; + await RecordingDetailsModalUtils.updateRecordingMetadata(recording, '', 'Some description'); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, { + title: 'Custom Recording', + description: 'Some description', + }); + }); + + it('should default title to "Custom Recording" when editTitle is null', async () => { + API.updateRecordingMetadata.mockResolvedValue({}); + + const recording = { id: 5 }; + await RecordingDetailsModalUtils.updateRecordingMetadata(recording, null, 'desc'); + + expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, { + title: 'Custom Recording', + description: 'desc', + }); + }); + + it('should propagate API errors', async () => { + API.updateRecordingMetadata.mockRejectedValue(new Error('Server error')); + + await expect( + RecordingDetailsModalUtils.updateRecordingMetadata({ id: 1 }, 'Title', 'Desc') + ).rejects.toThrow('Server error'); + }); + }); + + describe('refreshArtwork', () => { + it('should call API.refreshArtwork with the given id', async () => { + const mockResponse = { success: true }; + API.refreshArtwork.mockResolvedValue(mockResponse); + + const result = await RecordingDetailsModalUtils.refreshArtwork(7); + + expect(API.refreshArtwork).toHaveBeenCalledWith(7); + expect(result).toEqual(mockResponse); + }); + + it('should propagate API errors', async () => { + API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed')); + + await expect(RecordingDetailsModalUtils.refreshArtwork(7)).rejects.toThrow( + 'Artwork fetch failed' + ); + }); + }); }); diff --git a/frontend/src/utils/forms/__tests__/RecordingUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js new file mode 100644 index 00000000..be7cf1a2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js @@ -0,0 +1,503 @@ +import { describe, it, expect, vi } from 'vitest'; +import dayjs from 'dayjs'; + +vi.mock('../../../api.js', () => ({ + default: { + getChannelsSummary: vi.fn(), + updateRecording: vi.fn(), + createRecording: vi.fn(), + createRecurringRule: vi.fn(), + }, +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + add: vi.fn((d, amount, unit) => dayjs(d).add(amount, unit)), + diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)), + format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)), + getNow: vi.fn(() => dayjs('2024-06-15T10:00:00')), + initializeTime: vi.fn((val, fmts, ref, strict) => { + for (const fmt of fmts) { + const p = dayjs(val, fmt, strict); + if (p.isValid()) return p; + } + return dayjs('invalid'); + }), + isValid: vi.fn((d) => dayjs(d).isValid()), + roundToNearest: vi.fn((d) => d), + setMillisecond: vi.fn((d, ms) => dayjs(d).millisecond(ms)), + setSecond: vi.fn((d, s) => dayjs(d).second(s)), + toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())), + toTimeString: vi.fn((t) => { + if (!t) return '00:00'; + const match = String(t).match(/(\d{2}:\d{2})/); + return match ? match[1] : t; + }), +})); + +vi.mock('@mantine/form', () => ({ + isNotEmpty: vi.fn((msg) => (value) => (value ? null : msg)), +})); + +import API from '../../../api.js'; +import { + asDate, + toIsoIfDate, + toDateString, + createRoundedDate, + timeChange, + getChannelsSummary, + updateRecording, + createRecording, + createRecurringRule, + sortedChannelOptions, + numberedChannelLabel, + getSingleFormDefaults, + getRecurringFormDefaults, + buildSinglePayload, + buildRecurringPayload, + singleFormValidators, + recurringFormValidators, +} from '../RecordingUtils.js'; + +describe('RecordingUtils', () => { + // ─── asDate ─────────────────────────────────────────────────────────────────── + + describe('asDate', () => { + it('returns null for falsy input', () => { + expect(asDate(null)).toBeNull(); + expect(asDate(undefined)).toBeNull(); + expect(asDate('')).toBeNull(); + }); + + it('returns a Date instance unchanged', () => { + const d = new Date('2024-06-15'); + expect(asDate(d)).toBe(d); + }); + + it('parses a valid ISO string to a Date', () => { + const result = asDate('2024-06-15T10:00:00Z'); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toContain('2024-06-15'); + }); + + it('returns null for an invalid date string', () => { + expect(asDate('not-a-date')).toBeNull(); + }); + }); + +// ─── toIsoIfDate ────────────────────────────────────────────────────────────── + + describe('toIsoIfDate', () => { + it('returns ISO string for a valid Date', () => { + const d = new Date('2024-06-15T00:00:00Z'); + expect(toIsoIfDate(d)).toBe(d.toISOString()); + }); + + it('returns ISO string for a valid date string', () => { + const result = toIsoIfDate('2024-06-15T10:00:00Z'); + expect(result).toMatch(/2024-06-15/); + }); + + it('returns the original value when not a valid date', () => { + expect(toIsoIfDate('some-string')).toBe('some-string'); + }); + + it('returns null for null input', () => { + expect(toIsoIfDate(null)).toBeNull(); + }); + }); + +// ─── toDateString ───────────────────────────────────────────────────────────── + + describe('toDateString', () => { + it('formats a Date to YYYY-MM-DD', () => { + const d = new Date('2024-06-15T12:00:00Z'); + const result = toDateString(d); + expect(result).toBe('2024-06-15'); + }); + + it('formats a valid ISO string to YYYY-MM-DD', () => { + expect(toDateString('2024-06-15T12:00:00')).toBe('2024-06-15'); + }); + + it('returns null for null input', () => { + expect(toDateString(null)).toBeNull(); + }); + + it('returns null for an invalid string', () => { + expect(toDateString('not-a-date')).toBeNull(); + }); + }); + +// ─── createRoundedDate ──────────────────────────────────────────────────────── + + describe('createRoundedDate', () => { + it('returns a Date', () => { + expect(createRoundedDate()).toBeInstanceOf(Date); + }); + + it('returns a Date when minutesAhead is provided', () => { + expect(createRoundedDate(60)).toBeInstanceOf(Date); + }); + }); + +// ─── timeChange ─────────────────────────────────────────────────────────────── + + describe('timeChange', () => { + it('calls setter with string directly', () => { + const setter = vi.fn(); + timeChange(setter)('10:30'); + expect(setter).toHaveBeenCalledWith('10:30'); + }); + + it('calls setter with currentTarget.value from event', () => { + const setter = vi.fn(); + timeChange(setter)({ currentTarget: { value: '09:00' } }); + expect(setter).toHaveBeenCalledWith('09:00'); + }); + + it('does not call setter for unrecognized input', () => { + const setter = vi.fn(); + timeChange(setter)(42); + expect(setter).not.toHaveBeenCalled(); + }); + }); + +// ─── API wrappers ───────────────────────────────────────────────────────────── + + describe('getChannelsSummary', () => { + it('calls API.getChannelsSummary', () => { + API.getChannelsSummary.mockResolvedValueOnce([]); + getChannelsSummary(); + expect(API.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + describe('updateRecording', () => { + it('calls API.updateRecording with id and data', () => { + API.updateRecording.mockResolvedValueOnce({}); + updateRecording(1, { name: 'test' }); + expect(API.updateRecording).toHaveBeenCalledWith(1, { name: 'test' }); + }); + }); + + describe('createRecording', () => { + it('calls API.createRecording with data', () => { + API.createRecording.mockResolvedValueOnce({}); + createRecording({ channel: 1 }); + expect(API.createRecording).toHaveBeenCalledWith({ channel: 1 }); + }); + }); + + describe('createRecurringRule', () => { + it('calls API.createRecurringRule with data', () => { + API.createRecurringRule.mockResolvedValueOnce({}); + createRecurringRule({ channel: 1 }); + expect(API.createRecurringRule).toHaveBeenCalledWith({ channel: 1 }); + }); + }); + +// ─── sortedChannelOptions ───────────────────────────────────────────────────── + + describe('sortedChannelOptions', () => { + const channels = [ + { id: 3, name: 'CNN', channel_number: 20 }, + { id: 1, name: 'ESPN', channel_number: 5 }, + { id: 2, name: 'HBO', channel_number: 10 }, + ]; + + it('sorts channels by channel_number ascending', () => { + const result = sortedChannelOptions(channels); + // ESPN(5) < HBO(10) < CNN(20) → ids 1, 2, 3 + expect(result.map((o) => o.value)).toEqual(['1', '2', '3']); + }); + + it('maps each channel to { value, label }', () => { + const result = sortedChannelOptions([{ id: 7, name: 'FOX', channel_number: 3 }]); + expect(result[0]).toEqual({ value: '7', label: 'FOX' }); + }); + + it('uses custom labelFn when provided', () => { + const result = sortedChannelOptions( + [{ id: 1, name: 'ESPN', channel_number: 5 }], + (item) => `CH${item.channel_number} ${item.name}` + ); + expect(result[0].label).toBe('CH5 ESPN'); + }); + + it('falls back to "Channel id" when name is missing', () => { + const result = sortedChannelOptions([{ id: 9, channel_number: 1 }]); + expect(result[0].label).toBe('Channel 9'); + }); + + it('accepts an object (dict) of channels', () => { + const dict = { + a: { id: 1, name: 'A', channel_number: 2 }, + b: { id: 2, name: 'B', channel_number: 1 }, + }; + const result = sortedChannelOptions(dict); + expect(result[0].value).toBe('2'); + expect(result[1].value).toBe('1'); + }); + + it('returns empty array for null input', () => { + expect(sortedChannelOptions(null)).toEqual([]); + }); + + it('returns empty array for empty array input', () => { + expect(sortedChannelOptions([])).toEqual([]); + }); + + it('sorts by name when channel_numbers are equal', () => { + const input = [ + { id: 2, name: 'Zebra', channel_number: 5 }, + { id: 1, name: 'Apple', channel_number: 5 }, + ]; + const result = sortedChannelOptions(input); + expect(result[0].label).toBe('Apple'); + expect(result[1].label).toBe('Zebra'); + }); + + it('puts channels with no channel_number (0) first in numeric order', () => { + const input = [ + { id: 1, name: 'A', channel_number: 5 }, + { id: 2, name: 'B', channel_number: null }, + ]; + const result = sortedChannelOptions(input); + // null → 0, so it sorts before 5 + expect(result[0].value).toBe('2'); + }); + }); + +// ─── numberedChannelLabel ───────────────────────────────────────────────────── + + describe('numberedChannelLabel', () => { + it('returns "number - name" when both are present', () => { + expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })) + .toBe('501 - HBO'); + }); + + it('returns name only when channel_number is falsy', () => { + expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })) + .toBe('HBO'); + }); + + it('falls back to "Channel id" when name is missing and number present', () => { + expect(numberedChannelLabel({ id: 5, channel_number: 10 })) + .toBe('10 - Channel 5'); + }); + + it('falls back to "Channel id" when both name and number are missing', () => { + expect(numberedChannelLabel({ id: 7 })).toBe('Channel 7'); + }); + }); + +// ─── getSingleFormDefaults ──────────────────────────────────────────────────── + + describe('getSingleFormDefaults', () => { + it('returns defaults with no arguments', () => { + const result = getSingleFormDefaults(); + expect(result.channel_id).toBe(''); + expect(result.start_time).toBeInstanceOf(Date); + expect(result.end_time).toBeInstanceOf(Date); + }); + + it('uses recording values when provided', () => { + const recording = { + channel: 3, + start_time: '2024-06-15T08:00:00Z', + end_time: '2024-06-15T09:00:00Z', + }; + const result = getSingleFormDefaults(recording); + expect(result.channel_id).toBe('3'); + expect(result.start_time).toBeInstanceOf(Date); + expect(result.end_time).toBeInstanceOf(Date); + }); + + it('uses channel id when channel is provided and recording is null', () => { + const result = getSingleFormDefaults(null, { id: 7 }); + expect(result.channel_id).toBe('7'); + }); + }); + +// ─── getRecurringFormDefaults ───────────────────────────────────────────────── + + describe('getRecurringFormDefaults', () => { + it('returns defaults with no arguments', () => { + const result = getRecurringFormDefaults(); + expect(result.channel_id).toBe(''); + expect(result.days_of_week).toEqual([]); + expect(result.rule_name).toBe(''); + expect(typeof result.start_time).toBe('string'); + expect(typeof result.end_time).toBe('string'); + expect(result.start_date).toBeInstanceOf(Date); + expect(result.end_date).toBeInstanceOf(Date); + }); + + it('uses channel name and id when channel is provided', () => { + const result = getRecurringFormDefaults({ id: 5, name: 'ESPN' }); + expect(result.channel_id).toBe('5'); + expect(result.rule_name).toBe('ESPN'); + }); + }); + +// ─── buildSinglePayload ─────────────────────────────────────────────────────── + + describe('buildSinglePayload', () => { + it('builds payload from values', () => { + const values = { + channel_id: '2', + start_time: new Date('2024-06-15T08:00:00Z'), + end_time: new Date('2024-06-15T09:00:00Z'), + }; + const result = buildSinglePayload(values); + expect(result.channel).toBe('2'); + expect(result.start_time).toContain('2024-06-15'); + expect(result.end_time).toContain('2024-06-15'); + }); + }); + +// ─── buildRecurringPayload ──────────────────────────────────────────────────── + + describe('buildRecurringPayload', () => { + it('builds payload from values', () => { + const values = { + channel_id: '3', + days_of_week: ['1', '3', '5'], + start_time: '08:00', + end_time: '09:00', + start_date: new Date('2024-06-15T12:00:00'), + end_date: new Date('2024-12-25T12:00:00'), + rule_name: ' Morning News ', + }; + const result = buildRecurringPayload(values); + expect(result.channel).toBe('3'); + expect(result.days_of_week).toEqual([1, 3, 5]); + expect(result.start_time).toBe('08:00'); + expect(result.end_time).toBe('09:00'); + expect(result.start_date).toBe('2024-06-15'); + expect(result.end_date).toBe('2024-12-25'); + expect(result.name).toBe('Morning News'); + }); + + it('handles empty days_of_week', () => { + const values = { + channel_id: '1', + days_of_week: [], + start_time: '10:00', + end_time: '11:00', + start_date: new Date('2024-06-15T12:00:00'), + end_date: new Date('2024-06-15T12:00:00'), + rule_name: '', + }; + const result = buildRecurringPayload(values); + expect(result.days_of_week).toEqual([]); + expect(result.name).toBe(''); + }); + }); + +// ─── singleFormValidators ───────────────────────────────────────────────────── + + describe('singleFormValidators', () => { + describe('end_time', () => { + it('returns error when end time is missing', () => { + expect(singleFormValidators.end_time(null, { start_time: null })).toBe( + 'Select an end time' + ); + }); + + it('returns error when end time is not after start time', () => { + const start = new Date('2024-06-15T10:00:00Z'); + const end = new Date('2024-06-15T09:00:00Z'); + expect(singleFormValidators.end_time(end, { start_time: start })).toBe( + 'End time must be after start time' + ); + }); + + it('returns error when end equals start', () => { + const t = new Date('2024-06-15T10:00:00Z'); + expect(singleFormValidators.end_time(t, { start_time: t })).toBe( + 'End time must be after start time' + ); + }); + + it('returns null when end is after start', () => { + const start = new Date('2024-06-15T09:00:00Z'); + const end = new Date('2024-06-15T10:00:00Z'); + expect(singleFormValidators.end_time(end, { start_time: start })).toBeNull(); + }); + }); + }); + +// ─── recurringFormValidators ────────────────────────────────────────────────── + + describe('recurringFormValidators', () => { + describe('days_of_week', () => { + it('returns error for empty array', () => { + expect(recurringFormValidators.days_of_week([])).toBe( + 'Pick at least one day' + ); + }); + + it('returns null for non-empty array', () => { + expect(recurringFormValidators.days_of_week([1])).toBeNull(); + }); + }); + + describe('start_time', () => { + it('returns error for falsy value', () => { + expect(recurringFormValidators.start_time(null)).toBe( + 'Select a start time' + ); + }); + + it('returns null for a valid time string', () => { + expect(recurringFormValidators.start_time('10:00')).toBeNull(); + }); + }); + + describe('end_time', () => { + it('returns error when value is falsy', () => { + expect(recurringFormValidators.end_time(null, { start_time: '2024-06-15T10:00:00' })).toBe( + 'Select an end time' + ); + }); + + it('returns error when end equals start (diff === 0)', () => { + const result = recurringFormValidators.end_time('2024-06-15T10:00:00', { + start_time: '2024-06-15T10:00:00', + }); + expect(result).toBe('End time must differ from start time'); + }); + + it('returns null when end differs from start', () => { + const result = recurringFormValidators.end_time('2024-06-15T11:00:00', { + start_time: '2024-06-15T10:00:00', + }); + expect(result).toBeNull(); + }); + }); + + describe('end_date', () => { + it('returns error when end_date is missing', () => { + expect( + recurringFormValidators.end_date(null, { start_date: new Date() }) + ).toBe('Select an end date'); + }); + + it('returns error when end_date is before start_date', () => { + const start = new Date('2024-06-15'); + const end = new Date('2024-06-01'); + expect(recurringFormValidators.end_date(end, { start_date: start })).toBe( + 'End date cannot be before start date' + ); + }); + + it('returns null when end_date is on or after start_date', () => { + const start = new Date('2024-06-15'); + const end = new Date('2024-06-15'); + expect(recurringFormValidators.end_date(end, { start_date: start })).toBeNull(); + }); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js index 04561926..fd6d8db2 100644 --- a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js @@ -10,113 +10,24 @@ vi.mock('../../../api.js', () => ({ }, })); +vi.mock('../../dateTimeUtils.js', () => ({ + toTimeString: vi.fn((t) => { + if (!t) return '00:00'; + const match = String(t).match(/T(\d{2}:\d{2})/); + return match ? match[1] : t; + }), + parseDate: vi.fn((d) => (d ? dayjs(d).toDate() : null)), + toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())), + getNow: vi.fn(() => dayjs()), + isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))), + format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)), +})); + describe('RecurringRuleModalUtils', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('getChannelOptions', () => { - it('should return sorted channel options by channel number', () => { - const channels = { - ch1: { id: 1, channel_number: '10', name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - ch3: { id: 3, channel_number: '15', name: 'CBS' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'NBC' }, - { value: '1', label: 'ABC' }, - { value: '3', label: 'CBS' }, - ]); - }); - - it('should sort alphabetically by name when channel numbers are equal', () => { - const channels = { - ch1: { id: 1, channel_number: '10', name: 'ZBC' }, - ch2: { id: 2, channel_number: '10', name: 'ABC' }, - ch3: { id: 3, channel_number: '10', name: 'MBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'ABC' }, - { value: '3', label: 'MBC' }, - { value: '1', label: 'ZBC' }, - ]); - }); - - it('should handle missing channel numbers', () => { - const channels = { - ch1: { id: 1, name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '1', label: 'ABC' }, - { value: '2', label: 'NBC' }, - ]); - }); - - it('should use fallback label when name is missing', () => { - const channels = { - ch1: { id: 1, channel_number: '10' }, - ch2: { id: 2, channel_number: '5', name: '' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toEqual([ - { value: '2', label: 'Channel 2' }, - { value: '1', label: 'Channel 1' }, - ]); - }); - - it('should handle empty channels object', () => { - const result = RecurringRuleModalUtils.getChannelOptions({}); - - expect(result).toEqual([]); - }); - - it('should handle null channels', () => { - const result = RecurringRuleModalUtils.getChannelOptions(null); - - expect(result).toEqual([]); - }); - - it('should handle undefined channels', () => { - const result = RecurringRuleModalUtils.getChannelOptions(undefined); - - expect(result).toEqual([]); - }); - - it('should convert channel id to string value', () => { - const channels = { - ch1: { id: 123, channel_number: '10', name: 'ABC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result[0].value).toBe('123'); - expect(typeof result[0].value).toBe('string'); - }); - - it('should handle non-numeric channel numbers', () => { - const channels = { - ch1: { id: 1, channel_number: 'HD1', name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' }, - }; - - const result = RecurringRuleModalUtils.getChannelOptions(channels); - - expect(result).toHaveLength(2); - }); - }); - describe('getUpcomingOccurrences', () => { let toUserTime; let userNow; @@ -310,11 +221,11 @@ describe('RecurringRuleModalUtils', () => { const values = { channel_id: '5', days_of_week: ['1', '3', '5'], - start_time: '14:30', - end_time: '16:00', - start_date: '2024-01-01', - end_date: '2024-12-31', - rule_name: 'My Rule', + start_time: '10:00', + end_time: '11:00', + start_date: dayjs('2024-06-15'), + end_date: dayjs('2024-12-25'), + rule_name: ' Test Rule ', enabled: true, }; @@ -323,11 +234,11 @@ describe('RecurringRuleModalUtils', () => { expect(API.updateRecurringRule).toHaveBeenCalledWith(1, { channel: '5', days_of_week: [1, 3, 5], - start_time: '14:30', - end_time: '16:00', - start_date: '2024-01-01', - end_date: '2024-12-31', - name: 'My Rule', + start_time: '10:00', + end_time: '11:00', + start_date: '2024-06-15', + end_date: '2024-12-25', + name: 'Test Rule', enabled: true, }); }); @@ -530,4 +441,32 @@ describe('RecurringRuleModalUtils', () => { }); }); }); + + describe('getFormDefaults', () => { + it('should return formatted defaults from rule', () => { + const rule = { + channel: 5, + days_of_week: [1, 3, 5], + name: 'Test Rule', + start_time: '2024-06-15T10:00:00', + end_time: '2024-06-15T11:00:00', + start_date: '2024-06-15', + end_date: '2024-12-25', + enabled: true, + }; + + const result = RecurringRuleModalUtils.getFormDefaults(rule); + + expect(result).toEqual({ + channel_id: '5', + days_of_week: ['1', '3', '5'], + rule_name: 'Test Rule', + start_time: '10:00', + end_time: '11:00', + start_date: dayjs('2024-06-15').toDate(), + end_date: dayjs('2024-12-25').toDate(), + enabled: true, + }); + }); + }); }); diff --git a/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js new file mode 100644 index 00000000..1731e08d --- /dev/null +++ b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js @@ -0,0 +1,425 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Dependency mocks ─────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + previewSeriesRule: vi.fn(), + }, +})); + +vi.mock('../RecordingUtils.js', () => ({ + numberedChannelLabel: vi.fn( + (item) => + item.channel_number + ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` + : item.name || `Channel ${item.id}` + ), + sortedChannelOptions: vi.fn((channels) => + (Array.isArray(channels) ? channels : []).map((c) => ({ + value: String(c.id), + label: c.name || `Channel ${c.id}`, + })) + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import API from '../../../api.js'; +import { + TITLE_MODES, + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getTvgOptions, + getChannelOptions, + previewSeriesRule, +} from '../SeriesRuleEditorModalUtils.js'; +import { sortedChannelOptions, numberedChannelLabel } from '../RecordingUtils.js'; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('SeriesRuleEditorModalUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Constants ────────────────────────────────────────────────────────────── + + describe('TITLE_MODES', () => { + it('exports an array of mode objects', () => { + expect(Array.isArray(TITLE_MODES)).toBe(true); + expect(TITLE_MODES.length).toBeGreaterThan(0); + }); + + it('every entry has a label and value', () => { + TITLE_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + + it('contains exact, contains, search and regex modes', () => { + const values = TITLE_MODES.map((m) => m.value); + expect(values).toContain('exact'); + expect(values).toContain('contains'); + expect(values).toContain('search'); + expect(values).toContain('regex'); + }); + }); + + describe('DESCRIPTION_MODES', () => { + it('exports an array of mode objects', () => { + expect(Array.isArray(DESCRIPTION_MODES)).toBe(true); + expect(DESCRIPTION_MODES.length).toBeGreaterThan(0); + }); + + it('every entry has a label and value', () => { + DESCRIPTION_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + + it('contains contains, search and regex modes', () => { + const values = DESCRIPTION_MODES.map((m) => m.value); + expect(values).toContain('contains'); + expect(values).toContain('search'); + expect(values).toContain('regex'); + }); + + it('does not contain exact mode', () => { + const values = DESCRIPTION_MODES.map((m) => m.value); + expect(values).not.toContain('exact'); + }); + }); + + describe('EPISODE_MODES', () => { + it('exports an array with all and new modes', () => { + const values = EPISODE_MODES.map((m) => m.value); + expect(values).toContain('all'); + expect(values).toContain('new'); + }); + + it('every entry has a label and value', () => { + EPISODE_MODES.forEach((m) => { + expect(m).toHaveProperty('label'); + expect(m).toHaveProperty('value'); + }); + }); + }); + + // ── formatRange ──────────────────────────────────────────────────────────── + + describe('formatRange', () => { + it('formats same-day range as "date startTime - endTime"', () => { + const start = '2024-06-01T10:00:00'; + const end = '2024-06-01T11:30:00'; + const result = formatRange(start, end); + // Must include a dash separating start and end times (not "->") + expect(result).toMatch(/-(?!>)/); + expect(result).not.toMatch(/->/); + }); + + it('formats cross-day range with "->" separator', () => { + const start = '2024-06-01T23:00:00'; + const end = '2024-06-02T01:00:00'; + const result = formatRange(start, end); + expect(result).toMatch(/->/); + }); + + it('returns fallback string when dates are invalid', () => { + const result = formatRange('not-a-date', 'also-not-a-date'); + expect(result).toBe('not-a-date - also-not-a-date'); + }); + + it('includes a date string component in the output', () => { + const start = '2024-06-01T10:00:00'; + const end = '2024-06-01T11:00:00'; + const result = formatRange(start, end); + // toLocaleDateString produces something non-empty + expect(result.length).toBeGreaterThan(5); + }); + + it('handles ISO strings with timezone offsets', () => { + const start = '2024-06-01T10:00:00Z'; + const end = '2024-06-01T11:00:00Z'; + // Should not throw + expect(() => formatRange(start, end)).not.toThrow(); + }); + }); + + // ── getTvgOptions ────────────────────────────────────────────────────────── + + describe('getTvgOptions', () => { + it('returns empty array for null input', () => { + expect(getTvgOptions(null)).toEqual([]); + }); + + it('returns empty array for empty array input', () => { + expect(getTvgOptions([])).toEqual([]); + }); + + it('maps tvgs to { value, label } options', () => { + const tvgs = [{ tvg_id: 'tvg-1', name: 'Channel One' }]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + value: 'tvg-1', + label: 'Channel One (tvg-1)', + }); + }); + + it('uses tvg_id as label when name is missing', () => { + const tvgs = [{ tvg_id: 'tvg-no-name' }]; + const result = getTvgOptions(tvgs); + expect(result[0]).toEqual({ + value: 'tvg-no-name', + label: 'tvg-no-name', + }); + }); + + it('deduplicates by tvg_id', () => { + const tvgs = [ + { tvg_id: 'tvg-1', name: 'A' }, + { tvg_id: 'tvg-1', name: 'B' }, + { tvg_id: 'tvg-2', name: 'C' }, + ]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(2); + const values = result.map((o) => o.value); + expect(values).toContain('tvg-1'); + expect(values).toContain('tvg-2'); + }); + + it('skips entries with no tvg_id', () => { + const tvgs = [ + { tvg_id: null, name: 'No ID' }, + { tvg_id: '', name: 'Empty ID' }, + { tvg_id: 'tvg-1', name: 'Valid' }, + ]; + const result = getTvgOptions(tvgs); + expect(result).toHaveLength(1); + expect(result[0].value).toBe('tvg-1'); + }); + + it('sorts options alphabetically by label', () => { + const tvgs = [ + { tvg_id: 'tvg-z', name: 'Zebra' }, + { tvg_id: 'tvg-a', name: 'Apple' }, + { tvg_id: 'tvg-m', name: 'Mango' }, + ]; + const result = getTvgOptions(tvgs); + const labels = result.map((o) => o.label); + expect(labels).toEqual([...labels].sort()); + }); + + it('sorts by label text including the tvg_id suffix', () => { + const tvgs = [ + { tvg_id: 'z-id', name: 'Same' }, + { tvg_id: 'a-id', name: 'Same' }, + ]; + const result = getTvgOptions(tvgs); + // "Same (a-id)" < "Same (z-id)" + expect(result[0].value).toBe('a-id'); + }); + }); + + // ── getChannelOptions ────────────────────────────────────────────────────── + + describe('getChannelOptions', () => { + const makeChannels = () => [ + { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' }, + { id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' }, + { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' }, + ]; + + const makeTvgsById = () => ({ + 'epg-1': { tvg_id: 'tvg-espn' }, + 'epg-2': { tvg_id: 'tvg-hbo' }, + 'epg-3': { tvg_id: 'tvg-cnn' }, + }); + + it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => { + const channels = makeChannels(); + getChannelOptions(channels, {}, null); + expect(sortedChannelOptions).toHaveBeenCalledWith( + channels, + numberedChannelLabel + ); + }); + + it('returns all channels when tvgId is null', () => { + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), null); + expect(result).toHaveLength(3); + }); + + it('returns all channels when tvgId is empty string', () => { + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), ''); + expect(result).toHaveLength(3); + }); + + it('places matching channels before non-matching when tvgId set', () => { + // Override sortedChannelOptions to return deterministic output + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'HBO' }, + { value: '3', label: 'CNN' }, + ]); + + const channels = makeChannels(); + const tvgsById = makeTvgsById(); + + // tvgId matches ESPN (epg-1 → tvg-espn) + const result = getChannelOptions(channels, tvgsById, 'tvg-espn'); + + expect(result[0].value).toBe('1'); // ESPN first (matching) + expect(result[1].value).toBe('2'); // HBO second (non-matching) + expect(result[2].value).toBe('3'); // CNN third (non-matching) + }); + + it('returns all in sorted order when no channel matches tvgId', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'HBO' }, + ]); + + const channels = makeChannels(); + const result = getChannelOptions(channels, makeTvgsById(), 'tvg-unknown'); + + // No matches → all in others, order preserved from sortedChannelOptions + expect(result.map((r) => r.value)).toEqual(['1', '2']); + }); + + it('handles channel with no epg_data_id (cTvg is null)', () => { + const channels = [{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null }]; + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '9', label: 'NoEPG' }, + ]); + + const result = getChannelOptions(channels, {}, 'tvg-1'); + + // No epg match → goes to others + expect(result).toHaveLength(1); + expect(result[0].value).toBe('9'); + }); + + it('handles missing epg_data_id entry in tvgsById', () => { + const channels = [{ id: 5, name: 'Unknown', channel_number: 1, epg_data_id: 'epg-missing' }]; + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '5', label: 'Unknown' }, + ]); + + const result = getChannelOptions(channels, {}, 'tvg-1'); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('5'); + }); + + it('handles null tvgsById gracefully', () => { + const channels = makeChannels(); + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + ]); + + expect(() => getChannelOptions(channels, null, 'tvg-espn')).not.toThrow(); + }); + + it('returns empty array for empty channels', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([]); + const result = getChannelOptions([], {}, 'tvg-1'); + expect(result).toEqual([]); + }); + + it('places multiple matching channels before non-matching', () => { + vi.mocked(sortedChannelOptions).mockReturnValueOnce([ + { value: '1', label: 'ESPN' }, + { value: '2', label: 'ESPN2' }, + { value: '3', label: 'CNN' }, + ]); + + const channels = [ + { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' }, + { id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' }, + { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' }, + ]; + const tvgsById = { + 'epg-1': { tvg_id: 'tvg-espn' }, + 'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id + 'epg-3': { tvg_id: 'tvg-cnn' }, + }; + + const result = getChannelOptions(channels, tvgsById, 'tvg-espn'); + + expect(result[0].value).toBe('1'); + expect(result[1].value).toBe('2'); + expect(result[2].value).toBe('3'); + }); + }); + + // ── previewSeriesRule ────────────────────────────────────────────────────── + + describe('previewSeriesRule', () => { + it('calls API.previewSeriesRule with the key and abort signal', async () => { + const mockResult = { matches: [], total: 0 }; + vi.mocked(API.previewSeriesRule).mockResolvedValue(mockResult); + + const controller = new AbortController(); + const key = { title: 'Test Show', mode: 'all' }; + + const result = await previewSeriesRule(key, controller); + + expect(API.previewSeriesRule).toHaveBeenCalledWith(key, { + signal: controller.signal, + }); + expect(result).toBe(mockResult); + }); + + it('passes the abort signal from the controller', async () => { + vi.mocked(API.previewSeriesRule).mockResolvedValue({}); + const controller = new AbortController(); + await previewSeriesRule({ title: 'X' }, controller); + + const callArgs = vi.mocked(API.previewSeriesRule).mock.calls[0]; + expect(callArgs[1].signal).toBe(controller.signal); + }); + + it('propagates rejection from API.previewSeriesRule', async () => { + vi.mocked(API.previewSeriesRule).mockRejectedValue(new Error('Network error')); + const controller = new AbortController(); + await expect(previewSeriesRule({}, controller)).rejects.toThrow('Network error'); + }); + + it('propagates AbortError when signal is aborted', async () => { + const abortError = new DOMException('Aborted', 'AbortError'); + vi.mocked(API.previewSeriesRule).mockRejectedValue(abortError); + + const controller = new AbortController(); + controller.abort(); + + await expect(previewSeriesRule({}, controller)).rejects.toThrow('Aborted'); + }); + + it('passes through all fields of the preview key', async () => { + vi.mocked(API.previewSeriesRule).mockResolvedValue({}); + const controller = new AbortController(); + const key = { + title: 'My Show', + title_mode: 'exact', + description: 'drama', + description_mode: 'contains', + mode: 'new', + tvg_id: 'tvg-1', + channel_id: 5, + }; + + await previewSeriesRule(key, controller); + + expect(API.previewSeriesRule).toHaveBeenCalledWith( + key, + expect.objectContaining({ signal: controller.signal }) + ); + }); + }); +}); + diff --git a/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js new file mode 100644 index 00000000..3ed9357e --- /dev/null +++ b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateStreamProfile: vi.fn(), + addStreamProfile: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + toCommandSelection, + getResolver, + updateStreamProfile, + addStreamProfile, +} from '../StreamProfileUtils.js'; + +describe('StreamProfileUtils', () => { + // ─── BUILT_IN_COMMANDS ──────────────────────────────────────────────────────── + + describe('BUILT_IN_COMMANDS', () => { + it('includes ffmpeg, streamlink, cvlc, yt-dlp, and __custom__', () => { + const values = BUILT_IN_COMMANDS.map((c) => c.value); + expect(values).toContain('ffmpeg'); + expect(values).toContain('streamlink'); + expect(values).toContain('cvlc'); + expect(values).toContain('yt-dlp'); + expect(values).toContain('__custom__'); + }); + + it('each entry has a value and a label', () => { + for (const cmd of BUILT_IN_COMMANDS) { + expect(cmd).toHaveProperty('value'); + expect(cmd).toHaveProperty('label'); + expect(typeof cmd.value).toBe('string'); + expect(typeof cmd.label).toBe('string'); + } + }); + + it('labels the custom entry "Custom…"', () => { + const custom = BUILT_IN_COMMANDS.find((c) => c.value === '__custom__'); + expect(custom?.label).toBe('Custom…'); + }); + }); + + // ─── COMMAND_EXAMPLES ───────────────────────────────────────────────────────── + + describe('COMMAND_EXAMPLES', () => { + it('has an entry for each non-custom built-in command', () => { + const nonCustom = BUILT_IN_COMMANDS.filter((c) => c.value !== '__custom__'); + for (const cmd of nonCustom) { + expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value); + expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string'); + expect(COMMAND_EXAMPLES[cmd.value].length).toBeGreaterThan(0); + } + }); + + it('does not have an entry for __custom__', () => { + expect(COMMAND_EXAMPLES).not.toHaveProperty('__custom__'); + }); + + it('ffmpeg example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.ffmpeg).toContain('{streamUrl}'); + }); + + it('streamlink example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.streamlink).toContain('{streamUrl}'); + }); + + it('cvlc example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES.cvlc).toContain('{streamUrl}'); + }); + + it('yt-dlp example contains {streamUrl}', () => { + expect(COMMAND_EXAMPLES['yt-dlp']).toContain('{streamUrl}'); + }); + }); + + // ─── toCommandSelection ─────────────────────────────────────────────────────── + + describe('toCommandSelection', () => { + it('returns "ffmpeg" for "ffmpeg"', () => { + expect(toCommandSelection('ffmpeg')).toBe('ffmpeg'); + }); + + it('returns "streamlink" for "streamlink"', () => { + expect(toCommandSelection('streamlink')).toBe('streamlink'); + }); + + it('returns "cvlc" for "cvlc"', () => { + expect(toCommandSelection('cvlc')).toBe('cvlc'); + }); + + it('returns "yt-dlp" for "yt-dlp"', () => { + expect(toCommandSelection('yt-dlp')).toBe('yt-dlp'); + }); + + it('returns "__custom__" for "__custom__"', () => { + expect(toCommandSelection('__custom__')).toBe('__custom__'); + }); + + it('returns "__custom__" for an unrecognized command', () => { + expect(toCommandSelection('myspecialtool')).toBe('__custom__'); + }); + + it('returns "__custom__" for an empty string', () => { + expect(toCommandSelection('')).toBe('__custom__'); + }); + + it('returns "__custom__" for null', () => { + expect(toCommandSelection(null)).toBe('__custom__'); + }); + + it('returns "__custom__" for undefined', () => { + expect(toCommandSelection(undefined)).toBe('__custom__'); + }); + }); + + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + + // ─── updateStreamProfile ────────────────────────────────────────────────────── + + describe('updateStreamProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateStreamProfile with id merged into values', () => { + API.updateStreamProfile.mockResolvedValueOnce({ id: 3, name: 'HD' }); + updateStreamProfile(3, { name: 'HD', command: 'ffmpeg' }); + expect(API.updateStreamProfile).toHaveBeenCalledWith({ + id: 3, + name: 'HD', + command: 'ffmpeg', + }); + }); + + it('returns the promise from API.updateStreamProfile', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateStreamProfile.mockReturnValueOnce(resolved); + const result = updateStreamProfile(1, {}); + expect(result).toBe(resolved); + }); + }); + + // ─── addStreamProfile ───────────────────────────────────────────────────────── + + describe('addStreamProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addStreamProfile with the provided values', () => { + API.addStreamProfile.mockResolvedValueOnce({ id: 5 }); + addStreamProfile({ name: 'New', command: 'streamlink', parameters: '' }); + expect(API.addStreamProfile).toHaveBeenCalledWith({ + name: 'New', + command: 'streamlink', + parameters: '', + }); + }); + + it('returns the promise from API.addStreamProfile', () => { + const resolved = Promise.resolve({ id: 5 }); + API.addStreamProfile.mockReturnValueOnce(resolved); + const result = addStreamProfile({}); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addStreamProfile.mockResolvedValueOnce({}); + expect(() => addStreamProfile({})).not.toThrow(); + expect(API.addStreamProfile).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/StreamUtils.test.js b/frontend/src/utils/forms/__tests__/StreamUtils.test.js new file mode 100644 index 00000000..4eeded36 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/StreamUtils.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateStream: vi.fn(), + addStream: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { getResolver, updateStream, addStream } from '../StreamUtils.js'; + +describe('StreamUtils', () => { + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + +// ─── updateStream ───────────────────────────────────────────────────────────── + + describe('updateStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateStream with id merged into payload', () => { + API.updateStream.mockResolvedValueOnce({ id: 1, name: 'Test Stream' }); + updateStream(1, { name: 'Test Stream', url: 'http://example.com' }); + expect(API.updateStream).toHaveBeenCalledWith({ + id: 1, + name: 'Test Stream', + url: 'http://example.com', + }); + }); + + it('returns the promise from API.updateStream', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateStream.mockReturnValueOnce(resolved); + const result = updateStream(1, {}); + expect(result).toBe(resolved); + }); + + it('handles an empty payload without throwing', () => { + API.updateStream.mockResolvedValueOnce({}); + expect(() => updateStream(5, {})).not.toThrow(); + expect(API.updateStream).toHaveBeenCalledWith({ id: 5 }); + }); + }); + +// ─── addStream ──────────────────────────────────────────────────────────────── + + describe('addStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addStream with the provided payload', () => { + API.addStream.mockResolvedValueOnce({ id: 2 }); + addStream({ name: 'New Stream', url: 'http://example.com/stream' }); + expect(API.addStream).toHaveBeenCalledWith({ + name: 'New Stream', + url: 'http://example.com/stream', + }); + }); + + it('returns the promise from API.addStream', () => { + const resolved = Promise.resolve({ id: 2 }); + API.addStream.mockReturnValueOnce(resolved); + const result = addStream({ name: 'test' }); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addStream.mockResolvedValueOnce({}); + expect(() => addStream({})).not.toThrow(); + expect(API.addStream).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js new file mode 100644 index 00000000..ed6163b2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + updateUserAgent: vi.fn(), + addUserAgent: vi.fn(), + }, +})); + +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __schema: schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { getResolver, updateUserAgent, addUserAgent } from '../UserAgentUtils.js'; + +describe('UserAgentUtils', () => { + // ─── getResolver ────────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver and returns its result', () => { + const result = getResolver(); + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('returns the same resolver on subsequent calls', () => { + const r1 = getResolver(); + const r2 = getResolver(); + expect(r1).toEqual(r2); + }); + }); + +// ─── updateUserAgent ────────────────────────────────────────────────────────── + + describe('updateUserAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.updateUserAgent with id merged into values', () => { + API.updateUserAgent.mockResolvedValueOnce({ id: 1, name: 'Chrome' }); + updateUserAgent(1, { name: 'Chrome', user_agent: 'Mozilla/5.0' }); + expect(API.updateUserAgent).toHaveBeenCalledWith({ + id: 1, + name: 'Chrome', + user_agent: 'Mozilla/5.0', + }); + }); + + it('returns the promise from API.updateUserAgent', () => { + const resolved = Promise.resolve({ id: 1 }); + API.updateUserAgent.mockReturnValueOnce(resolved); + const result = updateUserAgent(1, {}); + expect(result).toBe(resolved); + }); + + it('handles an empty values object without throwing', () => { + API.updateUserAgent.mockResolvedValueOnce({}); + expect(() => updateUserAgent(5, {})).not.toThrow(); + expect(API.updateUserAgent).toHaveBeenCalledWith({ id: 5 }); + }); + }); + +// ─── addUserAgent ───────────────────────────────────────────────────────────── + + describe('addUserAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls API.addUserAgent with the provided values', () => { + API.addUserAgent.mockResolvedValueOnce({ id: 2 }); + addUserAgent({ name: 'Firefox', user_agent: 'Mozilla/5.0 (Firefox)' }); + expect(API.addUserAgent).toHaveBeenCalledWith({ + name: 'Firefox', + user_agent: 'Mozilla/5.0 (Firefox)', + }); + }); + + it('returns the promise from API.addUserAgent', () => { + const resolved = Promise.resolve({ id: 2 }); + API.addUserAgent.mockReturnValueOnce(resolved); + const result = addUserAgent({ name: 'test' }); + expect(result).toBe(resolved); + }); + + it('passes through an empty object without error', () => { + API.addUserAgent.mockResolvedValueOnce({}); + expect(() => addUserAgent({})).not.toThrow(); + expect(API.addUserAgent).toHaveBeenCalledWith({}); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/UserUtils.test.js b/frontend/src/utils/forms/__tests__/UserUtils.test.js new file mode 100644 index 00000000..3618228a --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js @@ -0,0 +1,432 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../api.js', () => ({ + default: { + createUser: vi.fn(), + updateUser: vi.fn(), + generateApiKey: vi.fn(), + revokeApiKey: vi.fn(), + }, +})); + +vi.mock('../../../constants.js', () => ({ + USER_LEVELS: { + ADMIN: 0, + STREAMER: 2, + }, + NETWORK_ACCESS_OPTIONS: { + m3u: 'M3U', + xc: 'XC', + mpegts: 'MPEG-TS', + }, +})); + +vi.mock('../../networkUtils.js', () => ({ + IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, + IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/, +})); + +import API from '../../../api.js'; +import { + createUser, + updateUser, + generateApiKey, + revokeApiKey, + userToFormValues, + formValuesToPayload, + getFormInitialValues, + getFormValidators, +} from '../UserUtils.js'; + +describe('UserUtils', () => { + // ─── createUser ─────────────────────────────────────────────────────────────── + + describe('createUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.createUser with the provided values', () => { + API.createUser.mockResolvedValueOnce({ id: 1 }); + createUser({ username: 'alice', password: 'pass' }); + expect(API.createUser).toHaveBeenCalledWith({ username: 'alice', password: 'pass' }); + }); + + it('returns the promise from API.createUser', () => { + const resolved = Promise.resolve({ id: 1 }); + API.createUser.mockReturnValueOnce(resolved); + expect(createUser({})).toBe(resolved); + }); + }); + +// ─── updateUser ─────────────────────────────────────────────────────────────── + + describe('updateUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.updateUser with selfEdit=false when isAdmin is true', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(1, { username: 'bob' }, true, { id: 1 }); + expect(API.updateUser).toHaveBeenCalledWith(1, { username: 'bob' }, false); + }); + + it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(5, { username: 'carol' }, false, { id: 5 }); + expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'carol' }, true); + }); + + it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => { + API.updateUser.mockResolvedValueOnce({}); + updateUser(5, { username: 'dave' }, false, { id: 99 }); + expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'dave' }, false); + }); + + it('returns the promise from API.updateUser', () => { + const resolved = Promise.resolve({}); + API.updateUser.mockReturnValueOnce(resolved); + expect(updateUser(1, {}, true, { id: 1 })).toBe(resolved); + }); + }); + +// ─── generateApiKey ─────────────────────────────────────────────────────────── + + describe('generateApiKey', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.generateApiKey with the payload', () => { + API.generateApiKey.mockResolvedValueOnce({ key: 'abc123' }); + generateApiKey({ user_id: 1 }); + expect(API.generateApiKey).toHaveBeenCalledWith({ user_id: 1 }); + }); + + it('returns the promise from API.generateApiKey', () => { + const resolved = Promise.resolve({ key: 'abc' }); + API.generateApiKey.mockReturnValueOnce(resolved); + expect(generateApiKey({})).toBe(resolved); + }); + }); + +// ─── revokeApiKey ───────────────────────────────────────────────────────────── + + describe('revokeApiKey', () => { + beforeEach(() => vi.clearAllMocks()); + + it('calls API.revokeApiKey with the payload', () => { + API.revokeApiKey.mockResolvedValueOnce({}); + revokeApiKey({ key: 'abc123' }); + expect(API.revokeApiKey).toHaveBeenCalledWith({ key: 'abc123' }); + }); + + it('returns the promise from API.revokeApiKey', () => { + const resolved = Promise.resolve({}); + API.revokeApiKey.mockReturnValueOnce(resolved); + expect(revokeApiKey({})).toBe(resolved); + }); + }); + +// ─── userToFormValues ───────────────────────────────────────────────────────── + + describe('userToFormValues', () => { + const makeUser = (overrides = {}) => ({ + username: 'alice', + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + user_level: 1, + stream_limit: 2, + channel_profiles: ['1', '2'], + custom_properties: {}, + ...overrides, + }); + + it('maps basic user fields to form values', () => { + const result = userToFormValues(makeUser()); + expect(result.username).toBe('alice'); + expect(result.first_name).toBe('Alice'); + expect(result.last_name).toBe('Smith'); + expect(result.email).toBe('alice@example.com'); + expect(result.user_level).toBe('1'); + expect(result.stream_limit).toBe(2); + }); + + it('defaults first_name and last_name to empty string when absent', () => { + const result = userToFormValues(makeUser({ first_name: null, last_name: null })); + expect(result.first_name).toBe(''); + expect(result.last_name).toBe(''); + }); + + it('defaults stream_limit to 0 when absent', () => { + const result = userToFormValues(makeUser({ stream_limit: null })); + expect(result.stream_limit).toBe(0); + }); + + it('uses channel_profiles when non-empty', () => { + const result = userToFormValues(makeUser({ channel_profiles: ['3', '4'] })); + expect(result.channel_profiles).toEqual(['3', '4']); + }); + + it('maps custom_properties fields with defaults', () => { + const result = userToFormValues(makeUser({ custom_properties: {} })); + expect(result.xc_password).toBe(''); + expect(result.output_format).toBe(''); + expect(result.output_profile).toBe(''); + expect(result.hide_adult_content).toBe(false); + expect(result.epg_days).toBe(0); + expect(result.epg_prev_days).toBe(0); + }); + + it('maps custom_properties when present', () => { + const user = makeUser({ + custom_properties: { + xc_password: 'xpass', + output_format: 'ts', + output_profile: 5, + hide_adult_content: true, + epg_days: 7, + epg_prev_days: 2, + allowed_networks: { + m3u: '192.168.1.0/24,10.0.0.1/32', + xc: '192.168.1.0/24,10.0.0.1/32', + }, + }, + }); + const result = userToFormValues(user); + expect(result.xc_password).toBe('xpass'); + expect(result.output_format).toBe('ts'); + expect(result.output_profile).toBe('5'); + expect(result.hide_adult_content).toBe(true); + expect(result.epg_days).toBe(7); + expect(result.epg_prev_days).toBe(2); + }); + + it('deduplicates allowed_ips from allowed_networks', () => { + const user = makeUser({ + custom_properties: { + allowed_networks: { + m3u: '192.168.1.0/24,10.0.0.1/32', + xc: '192.168.1.0/24,10.0.0.1/32', + }, + }, + }); + const result = userToFormValues(user); + expect(result.allowed_ips).toEqual(['192.168.1.0/24', '10.0.0.1/32']); + }); + + it('returns empty allowed_ips when no allowed_networks', () => { + const result = userToFormValues(makeUser({ custom_properties: {} })); + expect(result.allowed_ips).toEqual([]); + }); + }); + +// ─── formValuesToPayload ────────────────────────────────────────────────────── + + describe('formValuesToPayload', () => { + const makeValues = (overrides = {}) => ({ + username: 'alice', + email: 'alice@example.com', + xc_password: 'mypass', + output_format: 'ts', + output_profile: '3', + hide_adult_content: true, + epg_days: 7, + epg_prev_days: 2, + allowed_ips: ['192.168.1.0/24'], + channel_profiles: ['1', '2'], + ...overrides, + }); + + it('moves xc_password into custom_properties and removes it from payload', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.xc_password).toBeUndefined(); + expect(result.custom_properties.xc_password).toBe('mypass'); + }); + + it('moves output_format into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.output_format).toBeUndefined(); + expect(result.custom_properties.output_format).toBe('ts'); + }); + + it('parses output_profile as int in custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.output_profile).toBeUndefined(); + expect(result.custom_properties.output_profile).toBe(3); + }); + + it('sets output_profile to null when empty string', () => { + const result = formValuesToPayload(makeValues({ output_profile: '' }), null); + expect(result.custom_properties.output_profile).toBeNull(); + }); + + it('moves hide_adult_content into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.hide_adult_content).toBeUndefined(); + expect(result.custom_properties.hide_adult_content).toBe(true); + }); + + it('moves epg_days and epg_prev_days into custom_properties', () => { + const result = formValuesToPayload(makeValues(), null); + expect(result.epg_days).toBeUndefined(); + expect(result.epg_prev_days).toBeUndefined(); + expect(result.custom_properties.epg_days).toBe(7); + expect(result.custom_properties.epg_prev_days).toBe(2); + }); + + it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => { + const result = formValuesToPayload(makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }), null); + expect(result.allowed_ips).toBeUndefined(); + expect(result.custom_properties.allowed_networks.m3u).toBe('192.168.1.0/24,10.0.0.1/32'); + expect(result.custom_properties.allowed_networks.xc).toBe('192.168.1.0/24,10.0.0.1/32'); + expect(result.custom_properties.allowed_networks.mpegts).toBe('192.168.1.0/24,10.0.0.1/32'); + }); + + it('sets empty allowed_networks when allowed_ips is empty', () => { + const result = formValuesToPayload(makeValues({ allowed_ips: [] }), null); + expect(result.custom_properties.allowed_networks).toEqual({}); + }); + + it('sets channel_profiles to empty array when it includes "0"', () => { + const result = formValuesToPayload(makeValues({ channel_profiles: ['0'] }), null); + expect(result.channel_profiles).toEqual([]); + }); + + it('preserves channel_profiles when it does not include "0"', () => { + const result = formValuesToPayload(makeValues({ channel_profiles: ['1', '2'] }), null); + expect(result.channel_profiles).toEqual(['1', '2']); + }); + + it('merges existing custom_properties from existingUser', () => { + const existingUser = { custom_properties: { some_other_prop: 'keep_me' } }; + const result = formValuesToPayload(makeValues(), existingUser); + expect(result.custom_properties.some_other_prop).toBe('keep_me'); + }); + + it('handles null existingUser gracefully', () => { + expect(() => formValuesToPayload(makeValues(), null)).not.toThrow(); + }); + }); + +// ─── getFormInitialValues ───────────────────────────────────────────────────── + + describe('getFormInitialValues', () => { + it('returns the expected default structure', () => { + const result = getFormInitialValues(); + expect(result).toEqual({ + username: '', + first_name: '', + last_name: '', + email: '', + user_level: '0', + stream_limit: 0, + password: '', + xc_password: '', + output_format: '', + output_profile: '', + channel_profiles: [], + hide_adult_content: false, + epg_days: 0, + epg_prev_days: 0, + allowed_ips: [], + }); + }); + + it('returns a new object on each call', () => { + const a = getFormInitialValues(); + const b = getFormInitialValues(); + expect(a).not.toBe(b); + }); + }); + +// ─── getFormValidators ──────────────────────────────────────────────────────── + + describe('getFormValidators', () => { + const validate = (user, values) => getFormValidators(user)(values); + + describe('username', () => { + it('returns error when username is empty', () => { + const result = validate(null, { ...getFormInitialValues(), username: '' }); + expect(result.username).toBe('Username is required'); + }); + + it('returns null for a valid non-streamer username', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice', user_level: '0' }); + expect(result.username).toBeNull(); + }); + + it('returns null for a valid alphanumeric streamer username', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice123', user_level: '2' }); + expect(result.username).toBeNull(); + }); + + it('returns error for streamer username with non-alphanumeric characters', () => { + const result = validate(null, { ...getFormInitialValues(), username: 'alice_123', user_level: '2' }); + expect(result.username).toBe('Streamer username must be alphanumeric'); + }); + }); + + describe('password', () => { + it('returns error when creating a non-streamer user without a password', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: '' }); + expect(result.password).toBe('Password is required'); + }); + + it('returns null when creating a streamer user without a password', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '2', password: '' }); + expect(result.password).toBeNull(); + }); + + it('returns null when editing an existing user without a password', () => { + const result = validate({ id: 1 }, { ...getFormInitialValues(), user_level: '0', password: '' }); + expect(result.password).toBeNull(); + }); + + it('returns null when password is provided for new user', () => { + const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: 'secret' }); + expect(result.password).toBeNull(); + }); + }); + + describe('xc_password', () => { + it('returns null when xc_password is empty', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: '' }); + expect(result.xc_password).toBeNull(); + }); + + it('returns null for a valid alphanumeric xc_password', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc123' }); + expect(result.xc_password).toBeNull(); + }); + + it('returns error for xc_password with non-alphanumeric characters', () => { + const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc!@#' }); + expect(result.xc_password).toBe('XC password must be alphanumeric'); + }); + }); + + describe('allowed_ips', () => { + it('returns null for an empty allowed_ips array', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: [] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns null for a valid IPv4 CIDR', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24'] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns null for a bare IPv4 address (auto-appends /32)', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['10.0.0.1'] }); + expect(result.allowed_ips).toBeNull(); + }); + + it('returns error for an invalid IP entry', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['not-an-ip'] }); + expect(result.allowed_ips).toBe('Invalid IP address or CIDR range'); + }); + + it('returns error when any entry in the list is invalid', () => { + const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24', 'bad-entry'] }); + expect(result.allowed_ips).toBe('Invalid IP address or CIDR range'); + }); + }); + }); +}); \ No newline at end of file From 275a93b9b550ce73829549d102fc9a09ad0dd080 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:05:07 -0700 Subject: [PATCH 025/152] Added functionality to dateTimeUtils.js --- .../src/utils/__tests__/dateTimeUtils.test.js | 77 ++++++++++++++++++- frontend/src/utils/dateTimeUtils.js | 20 ++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index bfdc4fbd..5ab6ed48 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -62,6 +62,24 @@ describe('dateTimeUtils', () => { const result = dateTimeUtils.initializeTime(date); expect(result.format()).toBe(dayjs(date).format()); }); + + it('should handle custom format and locale', () => { + const result = dateTimeUtils.initializeTime('15/01/2024', 'DD/MM/YYYY'); + expect(result.isValid()).toBe(true); + expect(result.date()).toBe(15); + expect(result.month()).toBe(0); // January = 0 + expect(result.year()).toBe(2024); + }); + + 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); + 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); + expect(valid.isValid()).toBe(true); + }); }); describe('startOfDay', () => { @@ -433,7 +451,7 @@ describe('dateTimeUtils', () => { }); it('should return original string for unparseable format', () => { - expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM'); + expect(dateTimeUtils.toTimeString('not-a-time')).toBe('not-a-time'); }); it('should return original string for invalid format', () => { @@ -712,4 +730,61 @@ describe('dateTimeUtils', () => { }); }); }); + + describe('isValid', () => { + it('should return true for a valid date string', () => { + expect(dateTimeUtils.isValid('2024-01-15T10:00:00Z')).toBe(true); + }); + + it('should return false for an invalid date string', () => { + expect(dateTimeUtils.isValid('not-a-date')).toBe(false); + }); + + it('should return true for a Date object', () => { + expect(dateTimeUtils.isValid(new Date())).toBe(true); + }); + + it('should return false for an invalid Date object', () => { + expect(dateTimeUtils.isValid(new Date('invalid'))).toBe(false); + }); + }); + + describe('toDate', () => { + it('should convert a date string to a Date object', () => { + const result = dateTimeUtils.toDate('2024-01-15T10:00:00Z'); + expect(result).toBeInstanceOf(Date); + }); + + it('should convert a dayjs object to a Date object', () => { + const djs = dayjs('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.toDate(djs); + expect(result).toBeInstanceOf(Date); + }); + }); + + describe('setMillisecond', () => { + it('should set the millisecond on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00.000Z'); + const result = dateTimeUtils.setMillisecond(date, 500); + expect(result.millisecond()).toBe(500); + }); + + it('should return 0 milliseconds when set to 0', () => { + const date = dayjs.utc('2024-01-15T10:00:00.999Z'); + const result = dateTimeUtils.setMillisecond(date, 0); + expect(result.millisecond()).toBe(0); + }); + }); + + describe('getMillisecond', () => { + it('should return the millisecond from a date', () => { + const date = dayjs.utc('2024-01-15T14:00:00.123Z'); + expect(dateTimeUtils.getMillisecond(date)).toBe(123); + }); + + it('should return 0 for a date with no milliseconds', () => { + const date = dayjs.utc('2024-01-15T14:00:00.000Z'); + expect(dateTimeUtils.getMillisecond(date)).toBe(0); + }); + }); }); diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 3930188b..f973e0b0 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; import useSettingsStore from '../store/settings'; import useLocalStorage from '../hooks/useLocalStorage'; @@ -11,12 +12,21 @@ dayjs.extend(duration); dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(timezone); +dayjs.extend(customParseFormat); export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); export const convertToSec = (dateTime) => dayjs(dateTime).unix(); -export const initializeTime = (dateTime) => dayjs(dateTime); +export const initializeTime = (dateTime, format = null, locale = null, strict = false) => { + if (format && locale) { + return dayjs(dateTime, format, locale, strict); + } else if (format) { + return dayjs(dateTime, format, strict); + } else { + return dayjs(dateTime); + } +} export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); @@ -43,6 +53,10 @@ export const getNow = () => dayjs(); export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize(); +export const isValid = (dateTime) => dayjs(dateTime).isValid(); + +export const toDate = (dateTime) => dayjs(dateTime).toDate(); + export const formatExactDuration = (seconds) => { if (seconds < 60) return `${seconds.toFixed(1)} seconds`; if (seconds < 3600) { @@ -76,6 +90,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 getMonth = (dateTime) => dayjs(dateTime).month(); export const getYear = (dateTime) => dayjs(dateTime).year(); @@ -88,6 +104,8 @@ export const getMinute = (dateTime) => dayjs(dateTime).minute(); export const getSecond = (dateTime) => dayjs(dateTime).second(); +export const getMillisecond = (dateTime) => dayjs(dateTime).millisecond(); + export const getNowMs = () => Date.now(); export const roundToNearest = (dateTime, minutes) => { From 688ca2b40552fe520ff3724d645ad5cf13daf1ef Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:04 -0700 Subject: [PATCH 026/152] Updated Guide component and utils --- frontend/src/pages/Guide.jsx | 19 +- frontend/src/pages/__tests__/Guide.test.jsx | 40 +- .../src/pages/__tests__/guideUtils.test.js | 1146 +++++++---------- 3 files changed, 513 insertions(+), 692 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c445407e..aa319913 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { buildChannelIdMap, - calculateDesiredScrollPosition, calculateEarliestProgramStart, calculateEnd, calculateHourTimeline, calculateLatestProgramEnd, - calculateLeftScrollPosition, calculateNowPosition, calculateScrollPosition, calculateScrollPositionByTimeClick, @@ -47,7 +45,6 @@ import { computeRowHeights, createRecording, createSeriesRule, - evaluateSeriesRule, fetchPrograms, fetchRules, filterGuideChannels, @@ -66,6 +63,7 @@ import { PX_PER_MS, calcProgressPct, sortChannels, + evaluateSeriesRulesByTvgId, } from '../utils/guideUtils'; import API from '../api'; import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; @@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await createRecording(channel, program); + await createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); showNotification({ title: 'Recording scheduled' }); }, []); const saveSeriesRule = useCallback(async (program, mode) => { - await createSeriesRule(program, mode); - await evaluateSeriesRule(program); + await createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); + await evaluateSeriesRulesByTvgId(program.tvg_id); // recordings_refreshed WS event triggers the debounced fetchRecordings() showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx index d66f9be0..e1ce551a 100644 --- a/frontend/src/pages/__tests__/Guide.test.jsx +++ b/frontend/src/pages/__tests__/Guide.test.jsx @@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; import useLocalStorage from '../../hooks/useLocalStorage'; -import { showNotification } from '../../utils/notificationUtils.js'; import * as guideUtils from '../../utils/guideUtils'; import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js'; import * as dateTimeUtils from '../../utils/dateTimeUtils.js'; import userEvent from '@testing-library/user-event'; // Mock dependencies -vi.mock('../../store/channels'); -vi.mock('../../store/logos'); -vi.mock('../../store/epgs'); -vi.mock('../../store/settings'); -vi.mock('../../store/useVideoStore'); -vi.mock('../../hooks/useLocalStorage'); -vi.mock('../../api'); +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/logos', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/settings', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/useVideoStore', () => ({ + default: vi.fn(), +})); +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); +vi.mock('../../api', () => ({ + default: { + getAllChannelIds: vi.fn(), + getChannelsSummary: vi.fn(), + }, +})); vi.mock('@mantine/hooks', () => ({ useElementSize: () => ({ @@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({ })); vi.mock('../../components/ProgramDetailModal', () => ({ __esModule: true, - default: ({ program, channel, opened, onClose, onRecord }) => + default: ({ program, opened, onClose, onRecord }) => opened ? (
{program?.title}
@@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => { fetchPrograms: vi.fn(), createRecording: vi.fn(), createSeriesRule: vi.fn(), - evaluateSeriesRule: vi.fn(), fetchRules: vi.fn(), filterGuideChannels: vi.fn(), getGroupOptions: vi.fn(), getProfileOptions: vi.fn(), + sortChannels: vi.fn((ch) => ch), }; }); vi.mock('../../utils/cards/RecordingCardUtils.js', async () => { @@ -403,7 +419,6 @@ describe('Guide', () => { ); guideUtils.createRecording.mockResolvedValue(undefined); guideUtils.createSeriesRule.mockResolvedValue(undefined); - guideUtils.evaluateSeriesRule.mockResolvedValue(undefined); guideUtils.getGroupOptions.mockReturnValue([ { value: 'all', label: 'All Groups' }, { value: 'group-1', label: 'News' }, @@ -666,8 +681,7 @@ describe('Guide', () => { await waitFor(() => { expect(guideUtils.createRecording).toHaveBeenCalledWith( - expect.objectContaining({ id: 'channel-1' }), - expect.objectContaining({ id: 'prog-1' }) + expect.objectContaining({ channel: 'channel-1' }), ); }); }); diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js index 18307016..2cd321ca 100644 --- a/frontend/src/pages/__tests__/guideUtils.test.js +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -48,6 +48,8 @@ describe('guideUtils', () => { vi.clearAllMocks(); }); + // ── buildChannelIdMap ────────────────────────────────────────────────────── + describe('buildChannelIdMap', () => { it('should create map with channel UUIDs when no EPG data', () => { const channels = [ @@ -123,850 +125,638 @@ describe('guideUtils', () => { }); }); + // ── mapProgramsByChannel ─────────────────────────────────────────────────── + describe('mapProgramsByChannel', () => { it('should return empty map when no programs', () => { - const channelIdByTvgId = new Map(); - - const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId); - + const result = guideUtils.mapProgramsByChannel([], new Map([['tvg-1', [1]]])); expect(result.size).toBe(0); }); it('should return empty map when no channel mapping', () => { - const programs = [{ tvg_id: 'tvg-1' }]; - + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }]; const result = guideUtils.mapProgramsByChannel(programs, new Map()); - expect(result.size).toBe(0); }); it('should map programs to channels', () => { - const nowMs = 1000000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); - - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + const nowMs = dayjs().valueOf(); + const startMs = nowMs - 10000; + const endMs = nowMs + 10000; + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + + expect(result.has(1)).toBe(true); expect(result.get(1)).toHaveLength(1); - expect(result.get(1)[0]).toMatchObject({ - id: 1, - tvg_id: 'tvg-1', - }); }); it('should precompute startMs and endMs', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); - dateTimeUtils.convertToMs.mockImplementation((time) => - typeof time === 'number' ? time : dayjs(time).valueOf() - ); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const expectedStartMs = dayjs(startTime).valueOf(); + const expectedEndMs = dayjs(endTime).valueOf(); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(expectedStartMs) + .mockReturnValueOnce(expectedEndMs); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(expectedStartMs - 1000); + + const programs = [{ tvg_id: 'tvg-1', start_time: startTime, end_time: endTime }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const mapped = result.get(1)[0]; - expect(result.get(1)[0]).toHaveProperty('startMs'); - expect(result.get(1)[0]).toHaveProperty('endMs'); + expect(mapped.startMs).toBe(expectedStartMs); + expect(mapped.endMs).toBe(expectedEndMs); }); it('should mark program as live when now is between start and end', () => { + const nowMs = 5000; const startMs = 1000; - const endMs = 2000; - const nowMs = 1500; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)[0].isLive).toBe(true); expect(result.get(1)[0].isPast).toBe(false); }); it('should mark program as past when now is after end', () => { + const nowMs = 20000; const startMs = 1000; - const endMs = 2000; - const nowMs = 3000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); - expect(result.get(1)[0].isLive).toBe(false); expect(result.get(1)[0].isPast).toBe(true); + expect(result.get(1)[0].isLive).toBe(false); }); it('should add program to multiple channels with same tvg_id', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 5000; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 10000 }]; + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)).toHaveLength(1); expect(result.get(2)).toHaveLength(1); - expect(result.get(3)).toHaveLength(1); }); it('should sort programs by start time', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 0; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); const programs = [ - { - id: 2, - tvg_id: 'tvg-1', - startMs: 2000, - endMs: 3000, - start_time: '2024-01-15T11:00:00Z', - end_time: '2024-01-15T12:00:00Z', - }, - { - id: 1, - tvg_id: 'tvg-1', - startMs: 1000, - endMs: 2000, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, + { tvg_id: 'tvg-1', startMs: 3000, endMs: 4000 }, + { tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }, ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const list = result.get(1); - expect(result.get(1)[0].id).toBe(1); - expect(result.get(1)[1].id).toBe(2); + expect(list[0].startMs).toBe(1000); + expect(list[1].startMs).toBe(3000); }); }); + // ── computeRowHeights ───────────────────────────────────────────────────── + describe('computeRowHeights', () => { it('should return empty array when no channels', () => { - const result = guideUtils.computeRowHeights([]); - - expect(result).toEqual([]); + expect(guideUtils.computeRowHeights([])).toEqual([]); + expect(guideUtils.computeRowHeights(null)).toEqual([]); }); it('should return default height for all channels', () => { - const channels = [{ id: 1 }, { id: 2 }]; - + const channels = [{}, {}, {}]; const result = guideUtils.computeRowHeights(channels); - - expect(result).toEqual([ - guideUtils.PROGRAM_HEIGHT, - guideUtils.PROGRAM_HEIGHT, - ]); + expect(result).toEqual([90, 90, 90]); }); it('should use custom default height when provided', () => { - const channels = [{ id: 1 }]; - const customDefault = 100; - - const result = guideUtils.computeRowHeights(channels, customDefault); - - expect(result).toEqual([customDefault]); + const channels = [{}, {}]; + const result = guideUtils.computeRowHeights(channels, 60); + expect(result).toEqual([60, 60]); }); }); + // ── fetchPrograms ───────────────────────────────────────────────────────── + describe('fetchPrograms', () => { it('should fetch and transform programs', async () => { - const mockPrograms = [ - { - id: 1, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - API.getGrid.mockResolvedValue(mockPrograms); - dateTimeUtils.convertToMs.mockReturnValue(1000); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const raw = [{ id: 1, start_time: startTime, end_time: endTime }]; + vi.mocked(API.getGrid).mockResolvedValue(raw); + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(dayjs(startTime).valueOf()) + .mockReturnValueOnce(dayjs(endTime).valueOf()); const result = await guideUtils.fetchPrograms(); - expect(API.getGrid).toHaveBeenCalledTimes(1); - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('startMs'); - expect(result[0]).toHaveProperty('endMs'); + expect(API.getGrid).toHaveBeenCalled(); + expect(result[0].startMs).toBe(dayjs(startTime).valueOf()); + expect(result[0].endMs).toBe(dayjs(endTime).valueOf()); }); }); + // ── sortChannels ────────────────────────────────────────────────────────── + describe('sortChannels', () => { it('should sort channels by channel number', () => { const channels = { - 1: { id: 1, channel_number: 3 }, - 2: { id: 2, channel_number: 1 }, - 3: { id: 3, channel_number: 2 }, + a: { id: 1, channel_number: 3 }, + b: { id: 2, channel_number: 1 }, + c: { id: 3, channel_number: 2 }, }; - const result = guideUtils.sortChannels(channels); - - expect(result[0].channel_number).toBe(1); - expect(result[1].channel_number).toBe(2); - expect(result[2].channel_number).toBe(3); + expect(result.map((c) => c.channel_number)).toEqual([1, 2, 3]); }); it('should put channels without number at end', () => { const channels = { - 1: { id: 1, channel_number: 2 }, - 2: { id: 2, channel_number: null }, - 3: { id: 3, channel_number: 1 }, + a: { id: 1, channel_number: 2 }, + b: { id: 2, channel_number: null }, + c: { id: 3, channel_number: 1 }, }; - const result = guideUtils.sortChannels(channels); - expect(result[0].channel_number).toBe(1); expect(result[1].channel_number).toBe(2); expect(result[2].channel_number).toBeNull(); }); }); + // ── filterGuideChannels ─────────────────────────────────────────────────── + describe('filterGuideChannels', () => { + const channels = [ + { id: 1, name: 'ESPN', channel_group_id: 10 }, + { id: 2, name: 'CNN', channel_group_id: 20 }, + { id: 3, name: 'ESPN2', channel_group_id: 10 }, + ]; + it('should return all channels when no filters', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(2); + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {}); + expect(result).toHaveLength(3); }); it('should filter by search query', () => { - const channels = [ - { id: 1, name: 'ESPN' }, - { id: 2, name: 'CNN' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe('ESPN'); + const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {}); + expect(result).toHaveLength(2); + expect(result.map((c) => c.name)).toEqual(['ESPN', 'ESPN2']); }); it('should filter by channel group', () => { - const channels = [ - { id: 1, name: 'Channel 1', channel_group_id: 1 }, - { id: 2, name: 'Channel 2', channel_group_id: 2 }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - '1', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].channel_group_id).toBe(1); + const result = guideUtils.filterGuideChannels(channels, '', '10', 'all', {}); + expect(result).toHaveLength(2); + expect(result.every((c) => c.channel_group_id === 10)).toBe(true); }); it('should filter by profile with array of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; const profiles = { - profile1: { + 'p1': { channels: [ { id: 1, enabled: true }, { id: 2, enabled: false }, - ], - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should filter by profile with Set of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - const profiles = { - profile1: { - channels: new Set([1]), - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should apply multiple filters together', () => { - const channels = [ - { id: 1, name: 'ESPN', channel_group_id: 1 }, - { id: 2, name: 'ESPN2', channel_group_id: 2 }, - { id: 3, name: 'CNN', channel_group_id: 1 }, - ]; - const profiles = { - profile1: { - channels: [ - { id: 1, enabled: true }, { id: 3, enabled: true }, ], }, }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - '1', - 'profile1', - profiles - ); + it('should filter by profile with Set of channels', () => { + const profiles = { + 'p1': { channels: new Set([1, 3]) }, + }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); + it('should apply multiple filters together', () => { + const profiles = { + 'p1': { channels: [{ id: 1, enabled: true }, { id: 3, enabled: true }] }, + }; + const result = guideUtils.filterGuideChannels(channels, 'espn2', '10', 'p1', profiles); expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); + expect(result[0].name).toBe('ESPN2'); }); }); + // ── calculateEarliestProgramStart ───────────────────────────────────────── + describe('calculateEarliestProgramStart', () => { it('should return default when no programs', () => { - const defaultStart = dayjs('2024-01-15T00:00:00Z'); - + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const result = guideUtils.calculateEarliestProgramStart([], defaultStart); - expect(result).toBe(defaultStart); }); it('should return earliest program start', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isBefore.mockImplementation((a, b) => - dayjs(a).isBefore(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const programs = [ - { start_time: '2024-01-15T12:00:00Z' }, - { start_time: '2024-01-15T10:00:00Z' }, - { start_time: '2024-01-15T14:00:00Z' }, + { start_time: '2024-01-01T09:00:00Z' }, + { start_time: '2024-01-01T08:00:00Z' }, + { start_time: '2024-01-01T11:00:00Z' }, ]; - const defaultStart = dayjs.utc('2024-01-16T00:00:00Z'); - const result = guideUtils.calculateEarliestProgramStart( - programs, - defaultStart - ); - - expect(result.hour()).toBe(10); + const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T08:00:00Z').toISOString()); }); }); + // ── calculateLatestProgramEnd ───────────────────────────────────────────── + describe('calculateLatestProgramEnd', () => { it('should return default when no programs', () => { - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const result = guideUtils.calculateLatestProgramEnd([], defaultEnd); - expect(result).toBe(defaultEnd); }); it('should return latest program end', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isAfter.mockImplementation((a, b) => - dayjs(a).isAfter(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isAfter).mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b))); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const programs = [ - { end_time: '2024-01-15T12:00:00Z' }, - { end_time: '2024-01-15T18:00:00Z' }, - { end_time: '2024-01-15T14:00:00Z' }, + { end_time: '2024-01-01T20:00:00Z' }, + { end_time: '2024-01-01T23:00:00Z' }, + { end_time: '2024-01-01T21:00:00Z' }, ]; - const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z'); const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd); - - expect(result.hour()).toBe(18); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T23:00:00Z').toISOString()); }); }); + // ── calculateStart ──────────────────────────────────────────────────────── + describe('calculateStart', () => { it('should return earliest when before default', () => { - const earliest = dayjs('2024-01-15T08:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const earliest = dayjs('2024-01-01T08:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(earliest); }); it('should return default when earliest is after', () => { - const earliest = dayjs('2024-01-15T12:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); + const earliest = dayjs('2024-01-01T11:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(defaultStart); }); }); + // ── calculateEnd ────────────────────────────────────────────────────────── + describe('calculateEnd', () => { it('should return latest when after default', () => { - const latest = dayjs('2024-01-16T02:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(true); + const latest = dayjs('2024-01-01T23:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(latest); }); it('should return default when latest is before', () => { - const latest = dayjs('2024-01-15T22:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(false); + const latest = dayjs('2024-01-01T20:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(defaultEnd); }); }); + // ── mapChannelsById ─────────────────────────────────────────────────────── + describe('mapChannelsById', () => { it('should create map of channels by id', () => { const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, + { id: 1, name: 'ESPN' }, + { id: 2, name: 'CNN' }, ]; - const result = guideUtils.mapChannelsById(channels); - - expect(result.get(1).name).toBe('Channel 1'); - expect(result.get(2).name).toBe('Channel 2'); + expect(result.get(1)).toEqual({ id: 1, name: 'ESPN' }); + expect(result.get(2)).toEqual({ id: 2, name: 'CNN' }); }); }); + // ── mapRecordingsByProgramId ─────────────────────────────────────────────── + describe('mapRecordingsByProgramId', () => { it('should return empty map for null recordings', () => { const result = guideUtils.mapRecordingsByProgramId(null); - expect(result.size).toBe(0); }); it('should map recordings by program id', () => { const recordings = [ { - id: 1, - custom_properties: { - program: { id: 'program-1' }, - }, - }, - { - id: 2, - custom_properties: { - program: { id: 'program-2' }, - }, + custom_properties: { program: { id: 42 }, status: 'pending' }, }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - - expect(result.get('program-1').id).toBe(1); - expect(result.get('program-2').id).toBe(2); + expect(result.has(42)).toBe(true); }); it('should skip recordings without program id', () => { const recordings = [ - { - id: 1, - custom_properties: {}, - }, + { custom_properties: { status: 'pending' } }, + { custom_properties: { program: {}, status: 'pending' } }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - expect(result.size).toBe(0); }); it('should exclude terminal status recordings', () => { - const recordings = [ - { - id: 1, - custom_properties: { program: { id: 'p1' }, status: 'completed' }, - }, - { - id: 2, - custom_properties: { program: { id: 'p2' }, status: 'stopped' }, - }, - { - id: 3, - custom_properties: { program: { id: 'p3' }, status: 'interrupted' }, - }, - { - id: 4, - custom_properties: { program: { id: 'p4' }, status: 'failed' }, - }, - { - id: 5, - custom_properties: { program: { id: 'p5' }, status: 'recording' }, - }, - { id: 6, custom_properties: { program: { id: 'p6' } } }, - ]; - + const terminalStatuses = ['stopped', 'completed', 'interrupted', 'failed']; + const recordings = terminalStatuses.map((status, i) => ({ + custom_properties: { program: { id: i + 1 }, status }, + })); const result = guideUtils.mapRecordingsByProgramId(recordings); + expect(result.size).toBe(0); + }); + it('should include non-terminal status recordings', () => { + const recordings = [ + { custom_properties: { program: { id: 1 }, status: 'pending' } }, + { custom_properties: { program: { id: 2 }, status: 'recording' } }, + ]; + const result = guideUtils.mapRecordingsByProgramId(recordings); expect(result.size).toBe(2); - expect(result.get('p5').id).toBe(5); - expect(result.get('p6').id).toBe(6); }); }); + // ── formatTime ──────────────────────────────────────────────────────────── + describe('formatTime', () => { it('should return "Today" for today', () => { - const today = dayjs(); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(true); - - const result = guideUtils.formatTime(today, 'MM/DD'); + const result = guideUtils.formatTime(now, 'MMM D'); expect(result).toBe('Today'); }); it('should return "Tomorrow" for tomorrow', () => { - const today = dayjs(); - const tomorrow = today.add(1, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const tomorrow = now.add(1, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true); - - const result = guideUtils.formatTime(tomorrow, 'MM/DD'); + const result = guideUtils.formatTime(tomorrow, 'MMM D'); expect(result).toBe('Tomorrow'); }); it('should return day name within a week', () => { - const today = dayjs(); - const future = today.add(3, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const inThreeDays = now.add(3, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(true); - dateTimeUtils.format.mockReturnValue('Wednesday'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('Wednesday'); + const result = guideUtils.formatTime(inThreeDays, 'MMM D'); + expect(result).toBe(inThreeDays.format('dddd')); }); it('should return formatted date beyond a week', () => { - const today = dayjs(); - const future = today.add(10, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const beyondWeek = now.add(10, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.format.mockReturnValue('01/25'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('01/25'); + const result = guideUtils.formatTime(beyondWeek, 'MMM D'); + expect(result).toBe(beyondWeek.format('MMM D')); }); }); + // ── calculateHourTimeline ───────────────────────────────────────────────── + describe('calculateHourTimeline', () => { it('should generate hours between start and end', () => { - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T13:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T13:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockReturnValue(true); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Today'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); expect(result).toHaveLength(3); - expect(formatDayLabel).toHaveBeenCalledTimes(3); }); it('should mark new day transitions', () => { - const start = dayjs('2024-01-15T23:00:00Z'); - const end = dayjs('2024-01-16T02:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T23:00:00Z'); + const end = dayjs('2024-01-02T02:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => + dayjs(t).utc().startOf('day') ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockImplementation((a, b, unit) => - dayjs(a).isSame(dayjs(b), unit) + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).utc().isSame(dayjs(b).utc(), unit) ); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Day'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).utc().format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); - expect(result[0].isNewDay).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].isNewDay).toBe(true); // 23:00 UTC — first entry, always new day + expect(result[1].isNewDay).toBe(true); // 00:00 UTC — crosses into Jan 2 + expect(result[2].isNewDay).toBe(false); // 01:00 UTC — same day as 00:00 }); }); + // ── calculateNowPosition ────────────────────────────────────────────────── + describe('calculateNowPosition', () => { it('should return -1 when now is before start', () => { - const now = dayjs('2024-01-15T09:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const now = dayjs('2024-01-01T09:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should return -1 when now is after end', () => { - const now = dayjs('2024-01-15T19:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(true); + const now = dayjs('2024-01-01T23:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should calculate position when now is between start and end', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(false); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); // 60 minutes const result = guideUtils.calculateNowPosition(now, start, end); - - expect(result).toBeGreaterThan(0); + // 60 minutes / 15 min increment * MINUTE_BLOCK_WIDTH(112.5) + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateScrollPosition ─────────────────────────────────────────────── + describe('calculateScrollPosition', () => { it('should calculate scroll position for current time', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T11:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); const result = guideUtils.calculateScrollPosition(now, start); - - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeGreaterThan(0); }); it('should return 0 when calculated position is negative', () => { - const now = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(0); + const now = dayjs('2024-01-01T10:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPosition(now, start); - expect(result).toBe(0); }); }); + // ── matchChannelByTvgId ─────────────────────────────────────────────────── + describe('matchChannelByTvgId', () => { it('should return null when no matching channel ids', () => { const channelIdByTvgId = new Map(); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelById = new Map([[1, { id: 1, name: 'ESPN' }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); it('should return first matching channel', () => { - const channel = { id: 1, name: 'Channel 1' }; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); - const channelById = new Map([[1, channel]]); + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); + const channelById = new Map([ + [1, { id: 1, name: 'ESPN' }], + [2, { id: 2, name: 'ESPN HD' }], + ]); - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); - - expect(result).toBe(channel); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + expect(result).toEqual({ id: 1, name: 'ESPN' }); }); it('should return null when channel not in channelById map', () => { - const channelIdByTvgId = new Map([['tvg-1', [999]]]); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelIdByTvgId = new Map([['tvg-1', [99]]]); + const channelById = new Map([[1, { id: 1 }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); }); + // ── fetchRules ──────────────────────────────────────────────────────────── + describe('fetchRules', () => { it('should fetch series rules from API', async () => { - const mockRules = [{ id: 1, tvg_id: 'tvg-1' }]; - API.listSeriesRules.mockResolvedValue(mockRules); + const rules = [{ tvg_id: 'tvg-1', title: 'Show' }]; + vi.mocked(API.listSeriesRules).mockResolvedValue(rules); const result = await guideUtils.fetchRules(); - expect(API.listSeriesRules).toHaveBeenCalledTimes(1); - expect(result).toBe(mockRules); + expect(API.listSeriesRules).toHaveBeenCalled(); + expect(result).toEqual(rules); }); }); + // ── getRuleByProgram ────────────────────────────────────────────────────── + describe('getRuleByProgram', () => { it('should return null when no rules', () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(null, program); - + const result = guideUtils.getRuleByProgram(null, { tvg_id: 'tvg-1' }); expect(result).toBeUndefined(); }); it('should find rule by tvg_id without title', () => { - const rules = [{ tvg_id: 'tvg-1', title: null }]; - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 'tvg-1', title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Anything' }); + expect(result).toEqual(rules[0]); }); it('should find rule by tvg_id and title', () => { @@ -974,274 +764,272 @@ describe('guideUtils', () => { { tvg_id: 'tvg-1', title: 'Show A' }, { tvg_id: 'tvg-1', title: 'Show B' }, ]; - const program = { tvg_id: 'tvg-1', title: 'Show B' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[1]); + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Show B' }); + expect(result).toEqual(rules[1]); }); it('should handle string comparison for tvg_id', () => { - const rules = [{ tvg_id: 123, title: null }]; - const program = { tvg_id: '123', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 123, title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: '123', title: 'Show' }); + expect(result).toEqual(rules[0]); }); }); + // ── createRecording ─────────────────────────────────────────────────────── + describe('createRecording', () => { it('should create recording via API', async () => { - const channel = { id: 1 }; - const program = { - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }; + vi.mocked(API.createRecording).mockResolvedValue({}); + const values = { channel_id: 1, start_time: '2024-01-01T10:00:00Z' }; - await guideUtils.createRecording(channel, program); + await guideUtils.createRecording(values); - expect(API.createRecording).toHaveBeenCalledWith({ - channel: '1', - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); + expect(API.createRecording).toHaveBeenCalledWith(values); }); }); + // ── createSeriesRule ────────────────────────────────────────────────────── + describe('createSeriesRule', () => { it('should create series rule via API', async () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - const mode = 'all'; + vi.mocked(API.createSeriesRule).mockResolvedValue({}); + const values = { tvg_id: 'tvg-1', title: 'Show' }; - await guideUtils.createSeriesRule(program, mode); + await guideUtils.createSeriesRule(values); - expect(API.createSeriesRule).toHaveBeenCalledWith({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); + expect(API.createSeriesRule).toHaveBeenCalledWith(values); }); }); + // ── evaluateSeriesRule ──────────────────────────────────────────────────── + describe('evaluateSeriesRule', () => { it('should evaluate series rule via API', async () => { - const program = { tvg_id: 'tvg-1' }; + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); - await guideUtils.evaluateSeriesRule(program); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); - expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id); + expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); }); }); + // ── calculateLeftScrollPosition ─────────────────────────────────────────── + describe('calculateLeftScrollPosition', () => { it('should calculate left position using startMs', () => { - const program = { - startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(), - }; - const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf(); - dateTimeUtils.convertToMs.mockImplementation((time) => { - if (typeof time === 'number') return time; - return dayjs.utc(time).valueOf(); - }); + const startMs = 60 * 60 * 1000; // 1 hour in ms + const start = '2024-01-01T00:00:00Z'; + vi.mocked(dateTimeUtils.convertToMs).mockReturnValue(0); + const program = { startMs }; const result = guideUtils.calculateLeftScrollPosition(program, start); - expect(result).toBeGreaterThanOrEqual(0); + // (60 min / 15 min increment) * MINUTE_BLOCK_WIDTH + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); it('should calculate left position from start_time when no startMs', () => { - const program = { - start_time: '2024-01-15T10:30:00Z', - }; - const start = '2024-01-15T10:00:00Z'; - dateTimeUtils.convertToMs.mockImplementation((time) => - dayjs(time).valueOf() - ); + const startTimeMs = 30 * 60 * 1000; // 30 min + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(startTimeMs) // program start_time + .mockReturnValueOnce(0); // guide start - const result = guideUtils.calculateLeftScrollPosition(program, start); + const program = { start_time: '2024-01-01T00:30:00Z' }; + const result = guideUtils.calculateLeftScrollPosition(program, '2024-01-01T00:00:00Z'); - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBe((30 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateDesiredScrollPosition ──────────────────────────────────────── + describe('calculateDesiredScrollPosition', () => { it('should subtract 20 from left position', () => { const result = guideUtils.calculateDesiredScrollPosition(100); - expect(result).toBe(80); }); it('should return 0 when result would be negative', () => { const result = guideUtils.calculateDesiredScrollPosition(10); - expect(result).toBe(0); }); }); + // ── calculateScrollPositionByTimeClick ──────────────────────────────────── + describe('calculateScrollPositionByTimeClick', () => { + const makeEvent = (clientX, rectLeft, rectWidth) => ({ + currentTarget: { + getBoundingClientRect: () => ({ left: rectLeft, width: rectWidth }), + }, + clientX, + }); + it('should calculate scroll position from time click', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 100, width: 450 }), - }, - clientX: 325, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(60); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => ({ + ...dayjs(t).add(n, u), + minute: (m) => dayjs(t).add(n, u).minute(m), + })); - const result = guideUtils.calculateScrollPositionByTimeClick( - event, - clickedTime, - start - ); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + const start = dayjs('2024-01-01T10:00:00Z'); + // Click at 50% of a 600px element → 30 min into hour → snaps to 30 + const event = makeEvent(350, 100, 500); + vi.mocked(dateTimeUtils.diff).mockReturnValue(90); + + const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); expect(result).toBeGreaterThanOrEqual(0); }); it('should snap to 15-minute increments', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 112.5, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(75); - - guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); - - expect(dateTimeUtils.diff).toHaveBeenCalled(); - }); - - it('should handle click at end of hour', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 450, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(120); + // Click at 10% of a 600px element → 6 min → snaps to 0 + const event = makeEvent(160, 100, 600); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPositionByTimeClick( event, clickedTime, - start + dayjs() ); + expect(result).toBe(0); + }); - expect(dateTimeUtils.add).toHaveBeenCalledWith( - expect.anything(), - 1, - 'hour' + it('should handle click at end of hour (snappedMinute === 60)', () => { + // 100% across the element → 60 min → snappedMinute = 60 → use add(1, hour).minute(0) + const event = makeEvent(700, 100, 600); + const nextHour = dayjs('2024-01-01T11:00:00Z'); + const addResult = { minute: vi.fn().mockReturnValue(nextHour) }; + vi.mocked(dateTimeUtils.add).mockReturnValue(addResult); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + dayjs('2024-01-01T10:00:00Z'), + dayjs('2024-01-01T10:00:00Z') ); + expect(result).toBeGreaterThanOrEqual(0); }); }); + // ── getGroupOptions ─────────────────────────────────────────────────────── + describe('getGroupOptions', () => { it('should return only "All" when no channel groups', () => { const result = guideUtils.getGroupOptions(null, []); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Channel Groups' }]); }); it('should include groups used by channels', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 1 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result).toHaveLength(3); + expect(result.map((o) => o.label)).toContain('Sports'); + expect(result.map((o) => o.label)).toContain('News'); }); it('should exclude groups not used by any channel', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 99, name: 'Unused' }, }; - const channels = [{ id: 1, channel_group_id: 1 }]; + const guideChannels = [{ id: 1, channel_group_id: 10 }]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result.map((o) => o.label)).not.toContain('Unused'); }); it('should sort groups alphabetically', () => { const channelGroups = { - 1: { id: 1, name: 'Z Group' }, - 2: { id: 2, name: 'A Group' }, - 3: { id: 3, name: 'M Group' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'Movies' }, + 3: { id: 30, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 2 }, - { id: 3, channel_group_id: 3 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, + { id: 3, channel_group_id: 30 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result[1].label).toBe('A Group'); - expect(result[2].label).toBe('M Group'); - expect(result[3].label).toBe('Z Group'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + const labels = result.slice(1).map((o) => o.label); + expect(labels).toEqual([...labels].sort()); }); }); + // ── getProfileOptions ───────────────────────────────────────────────────── + describe('getProfileOptions', () => { it('should return only "All" when no profiles', () => { const result = guideUtils.getProfileOptions(null); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Profiles' }]); }); it('should include all profiles except id 0', () => { const profiles = { - 0: { id: '0', name: 'All' }, - 1: { id: '1', name: 'Profile 1' }, - 2: { id: '2', name: 'Profile 2' }, + '0': { id: '0', name: 'Default' }, + '1': { id: '1', name: 'Kids' }, + '2': { id: '2', name: 'Sports' }, }; const result = guideUtils.getProfileOptions(profiles); - - expect(result).toHaveLength(3); - expect(result[1].label).toBe('Profile 1'); - expect(result[2].label).toBe('Profile 2'); + expect(result).toHaveLength(3); // All + Kids + Sports + expect(result.map((o) => o.label)).not.toContain('Default'); + expect(result.map((o) => o.label)).toContain('Kids'); + expect(result.map((o) => o.label)).toContain('Sports'); }); }); + // ── calcProgressPct ─────────────────────────────────────────────────────── + + describe('calcProgressPct', () => { + it('should return 0 when now is at start', () => { + const result = guideUtils.calcProgressPct(1000, 1000, 60000); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should return 1 when now is at or past end', () => { + // nowMs >= startMs + durationMs → elapsed >= duration → clamped to 1 + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = startMs + durationMs + 1000; // past the end + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBe(1); + }); + + it('should return value between 0 and 1 for midpoint', () => { + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = 30 * 60 * 1000; // 30 minutes in + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + }); + + // ── formatSeasonEpisode ─────────────────────────────────────────────────── + describe('formatSeasonEpisode', () => { it('should format both season and episode', () => { - expect(guideUtils.formatSeasonEpisode(1, 3)).toBe('S01E03'); + expect(guideUtils.formatSeasonEpisode(1, 2)).toBe('S01E02'); }); it('should pad numbers to 2 digits', () => { - expect(guideUtils.formatSeasonEpisode(1, 1)).toBe('S01E01'); + expect(guideUtils.formatSeasonEpisode(3, 7)).toBe('S03E07'); }); it('should handle large numbers without truncation', () => { - expect(guideUtils.formatSeasonEpisode(12, 24)).toBe('S12E24'); + expect(guideUtils.formatSeasonEpisode(10, 10)).toBe('S10E10'); }); it('should handle numbers greater than 99', () => { @@ -1249,19 +1037,19 @@ describe('guideUtils', () => { }); it('should return season only when episode is null', () => { - expect(guideUtils.formatSeasonEpisode(5, null)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, null)).toBe('S02'); }); it('should return season only when episode is undefined', () => { - expect(guideUtils.formatSeasonEpisode(5, undefined)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, undefined)).toBe('S02'); }); it('should return episode only when season is null', () => { - expect(guideUtils.formatSeasonEpisode(null, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(null, 5)).toBe('E05'); }); it('should return episode only when season is undefined', () => { - expect(guideUtils.formatSeasonEpisode(undefined, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(undefined, 5)).toBe('E05'); }); it('should return null when both are null', () => { @@ -1277,32 +1065,44 @@ describe('guideUtils', () => { }); it('should handle season zero with episode', () => { - expect(guideUtils.formatSeasonEpisode(0, 5)).toBe('S00E05'); + expect(guideUtils.formatSeasonEpisode(0, 1)).toBe('S00E01'); }); }); + // ── deleteSeriesRuleByTvgId ─────────────────────────────────────────────── + describe('deleteSeriesRuleByTvgId', () => { it('should delete series rule via API with tvg_id and title', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1', 'My Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', 'My Show'); }); it('should forward undefined title when not provided', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', undefined); }); it('should work with empty tvg_id for title-only rules', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('', 'Title-Only Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('', 'Title-Only Show'); }); }); + // ── evaluateSeriesRulesByTvgId ──────────────────────────────────────────── + describe('evaluateSeriesRulesByTvgId', () => { it('should evaluate series rules via API', async () => { + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); From e766c4dd54a9414536a9da4314a243b8ea5e9810 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:52 -0700 Subject: [PATCH 027/152] Refactored components --- frontend/src/components/forms/Recording.jsx | 252 ++++-------------- .../forms/RecordingDetailsModal.jsx | 70 ++--- .../components/forms/RecurringRuleModal.jsx | 91 ++----- .../src/components/forms/ScheduleInput.jsx | 12 +- .../components/forms/SeriesRecordingModal.jsx | 26 +- .../forms/SeriesRuleEditorModal.jsx | 124 +++------ frontend/src/components/forms/Stream.jsx | 21 +- .../src/components/forms/StreamProfile.jsx | 81 ++---- .../src/components/forms/SuperuserForm.jsx | 16 +- frontend/src/components/forms/User.jsx | 228 ++++------------ frontend/src/components/forms/UserAgent.jsx | 28 +- 11 files changed, 270 insertions(+), 679 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 23f78867..163a43a7 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,86 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import API from '../../api'; import { Alert, Button, + Group, + Loader, Modal, + MultiSelect, + SegmentedControl, Select, Stack, - SegmentedControl, - MultiSelect, - Group, TextInput, - Loader, } from '@mantine/core'; -import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; +import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; -import { isNotEmpty, useForm } from '@mantine/form'; +import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; - -const DAY_OPTIONS = [ - { value: '6', label: 'Sun' }, - { value: '0', label: 'Mon' }, - { value: '1', label: 'Tue' }, - { value: '2', label: 'Wed' }, - { value: '3', label: 'Thu' }, - { value: '4', label: 'Fri' }, - { value: '5', label: 'Sat' }, -]; - -const asDate = (value) => { - if (!value) return null; - if (value instanceof Date) return value; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -}; - -const toIsoIfDate = (value) => { - const dt = asDate(value); - return dt ? dt.toISOString() : value; -}; - -// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" -const toTimeString = (value) => { - if (!value) return '00:00'; - if (typeof value === 'string') { - const parsed = dayjs( - value, - ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], - true - ); - if (parsed.isValid()) return parsed.format('HH:mm'); - return value; - } - const dt = asDate(value); - if (!dt) return '00:00'; - return dayjs(dt).format('HH:mm'); -}; - -const toDateString = (value) => { - const dt = asDate(value); - if (!dt) return null; - const year = dt.getFullYear(); - const month = String(dt.getMonth() + 1).padStart(2, '0'); - const day = String(dt.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const createRoundedDate = (minutesAhead = 0) => { - const dt = new Date(); - dt.setSeconds(0); - dt.setMilliseconds(0); - dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); - return dt; -}; - -// robust onChange for TimeInput (string or event) -const timeChange = (setter) => (valOrEvent) => { - if (typeof valOrEvent === 'string') setter(valOrEvent); - else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); -}; +import { + RECURRING_DAY_OPTIONS, + toTimeString, +} from '../../utils/dateTimeUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildRecurringPayload, + buildSinglePayload, + createRecording, + createRecurringRule, + getChannelsSummary, + getRecurringFormDefaults, + getSingleFormDefaults, + numberedChannelLabel, + recurringFormValidators, + singleFormValidators, + sortedChannelOptions, + timeChange, + updateRecording, +} from '../../utils/forms/RecordingUtils.js'; const RecordingModal = ({ recording = null, @@ -98,117 +52,29 @@ const RecordingModal = ({ const [mode, setMode] = useState('single'); const [submitting, setSubmitting] = useState(false); - const defaultStart = createRoundedDate(); - const defaultEnd = createRoundedDate(60); - const defaultDate = new Date(); - - // One-time form const singleForm = useForm({ mode: 'controlled', - initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', - start_time: recording - ? asDate(recording.start_time) || defaultStart - : defaultStart, - end_time: recording - ? asDate(recording.end_time) || defaultEnd - : defaultEnd, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - start_time: isNotEmpty('Select a start time'), - end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; - return null; - }, - }, + initialValues: getSingleFormDefaults(recording, channel), + validate: singleFormValidators, }); - // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', validateInputOnChange: false, validateInputOnBlur: true, - initialValues: { - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: dayjs(defaultStart).format('HH:mm'), - end_time: dayjs(defaultEnd).format('HH:mm'), - rule_name: '', - start_date: defaultDate, - end_date: defaultDate, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - start_time: (value) => (value ? null : 'Select a start time'), - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const start = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - start.isValid() && - end.isValid() && - end.diff(start, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const end = asDate(value); - const start = asDate(values.start_date); - if (!end) return 'Select an end date'; - if (start && end < start) return 'End date cannot be before start date'; - return null; - }, - }, + initialValues: getRecurringFormDefaults(channel), + validate: recurringFormValidators, }); useEffect(() => { if (!isOpen) return; - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); - - if (recording && recording.id) { + if (recording?.id) { setMode('single'); - singleForm.setValues({ - channel_id: `${recording.channel}`, - start_time: asDate(recording.start_time) || defaultStart, - end_time: asDate(recording.end_time) || defaultEnd, - }); + singleForm.setValues(getSingleFormDefaults(recording, channel)); } else { - // Reset forms for fresh open - singleForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - start_time: freshStart, - end_time: freshEnd, - }); - - const startStr = dayjs(freshStart).format('HH:mm'); - recurringForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: startStr, - end_time: dayjs(freshEnd).format('HH:mm'), - rule_name: channel?.name || '', - start_date: freshDate, - end_date: freshDate, - }); + singleForm.setValues(getSingleFormDefaults(null, channel)); + recurringForm.setValues(getRecurringFormDefaults(channel)); setMode('single'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -221,7 +87,7 @@ const RecordingModal = ({ if (!isOpen) return; try { setIsChannelsLoading(true); - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (cancelled) return; setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { @@ -238,19 +104,7 @@ const RecordingModal = ({ }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(allChannels) ? [...allChannels] : []; - list.sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); - return aNum - bNum; - }); - return list.map((item) => ({ - value: `${item.id}`, - label: item.channel_number - ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` - : item.name || `Channel ${item.id}`, - })); + return sortedChannelOptions(allChannels, numberedChannelLabel); }, [allChannels]); const resetForms = () => { @@ -267,25 +121,18 @@ const RecordingModal = ({ const handleSingleSubmit = async (values) => { try { setSubmitting(true); + const payload = buildSinglePayload(values); if (recording && recording.id) { - await API.updateRecording(recording.id, { - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await updateRecording(recording.id, payload); + showNotification({ title: 'Recording updated', message: 'Recording schedule updated successfully', color: 'green', autoClose: 2500, }); } else { - await API.createRecording({ - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await createRecording(payload); + showNotification({ title: 'Recording scheduled', message: 'One-time recording added to DVR queue', color: 'green', @@ -304,18 +151,10 @@ const RecordingModal = ({ const handleRecurringSubmit = async (values) => { try { setSubmitting(true); - await API.createRecurringRule({ - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: toDateString(values.start_date), - end_date: toDateString(values.end_date), - name: values.rule_name?.trim() || '', - }); + await createRecurringRule(buildRecurringPayload(values)); await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ + showNotification({ title: 'Recurring rule saved', message: 'Future slots will be scheduled automatically', color: 'green', @@ -427,7 +266,10 @@ const RecordingModal = ({ key={recurringForm.key('days_of_week')} label="Every" placeholder="Select days" - data={DAY_OPTIONS} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable nothingFoundMessage="No match" diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index bc8b245f..43351765 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,11 +1,13 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; -import React from 'react'; -import { Pencil, RefreshCcw, Check, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Pencil, RefreshCcw, X } from 'lucide-react'; import { ActionIcon, Badge, @@ -21,7 +23,6 @@ import { TextInput, } from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; -import { notifications } from '@mantine/notifications'; import defaultLogo from '../../images/logo.png'; import { deleteRecordingById, @@ -33,10 +34,14 @@ import { runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { + getChannel, getRating, getStatRows, getUpcomingEpisodes, + refreshArtwork, + updateRecordingMetadata, } from '../../utils/forms/RecordingDetailsModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; const RecordingDetailsModal = ({ opened, @@ -51,21 +56,21 @@ const RecordingDetailsModal = ({ }) => { const allRecordings = useChannelsStore((s) => s.recordings); // Local channel cache to avoid the global channels map - const [channelsById, setChannelsById] = React.useState({}); + const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); - const [childOpen, setChildOpen] = React.useState(false); - const [childRec, setChildRec] = React.useState(null); + const [childOpen, setChildOpen] = useState(false); + const [childRec, setChildRec] = useState(null); const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const [editing, setEditing] = React.useState(false); + const [editing, setEditing] = useState(false); // Prefer the store version of the recording for live updates // (e.g., after artwork refresh or metadata edit via WebSocket). // Preserve _group_count from the categorized prop — the store version // doesn't carry this client-side field, so without merging it back // isSeriesGroup would always be false and the episode list hidden. - const safeRecording = React.useMemo(() => { + const safeRecording = useMemo(() => { if (recording?.id && Array.isArray(allRecordings)) { const found = allRecordings.find((r) => r.id === recording.id); if (found) { @@ -81,7 +86,7 @@ const RecordingDetailsModal = ({ const program = customProps.program || {}; // Derive poster URL from live store data instead of the stale prop snapshot. - const livePosterUrl = React.useMemo( + const livePosterUrl = useMemo( () => getPosterUrl( customProps.poster_logo_id, @@ -93,17 +98,17 @@ const RecordingDetailsModal = ({ // Optimistic overrides — show saved values immediately without waiting // for the WebSocket round-trip to refresh the store. - const [savedTitle, setSavedTitle] = React.useState(null); - const [savedDescription, setSavedDescription] = React.useState(null); + const [savedTitle, setSavedTitle] = useState(null); + const [savedDescription, setSavedDescription] = useState(null); const recordingName = savedTitle ?? (program.title || 'Custom Recording'); const description = savedDescription ?? (program.description || customProps.description || ''); - const [editTitle, setEditTitle] = React.useState(''); - const [editDescription, setEditDescription] = React.useState(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); // Reset optimistic state when the recording changes - React.useEffect(() => { + useEffect(() => { setSavedTitle(null); setSavedDescription(null); setEditing(false); @@ -129,7 +134,7 @@ const RecordingDetailsModal = ({ const isSeriesGroup = Boolean( safeRecording._group_count && safeRecording._group_count > 1 ); - const upcomingEpisodes = React.useMemo(() => { + const upcomingEpisodes = useMemo(() => { return getUpcomingEpisodes( isSeriesGroup, allRecordings, @@ -147,7 +152,7 @@ const RecordingDetailsModal = ({ ]); // Ensure channel is available for a given id - const loadChannel = React.useCallback( + const loadChannel = useCallback( async (id) => { if (!id) { return null; @@ -159,7 +164,7 @@ const RecordingDetailsModal = ({ } try { - const ch = await API.getChannel(id); + const ch = await getChannel(id); if (ch && ch.id === id) { setChannelsById((prev) => ({ ...prev, [id]: ch })); return ch; @@ -177,7 +182,7 @@ const RecordingDetailsModal = ({ ); // When opening a child episode, fetch that episode's channel - React.useEffect(() => { + useEffect(() => { if (!childOpen || !childRec) return; loadChannel(childRec.channel); }, [childOpen, childRec, loadChannel]); @@ -188,7 +193,7 @@ const RecordingDetailsModal = ({ const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if (isAfter(now, s) && isBefore(now, e)) { const ch = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); @@ -228,14 +233,11 @@ const RecordingDetailsModal = ({ const saveMetadata = async () => { try { - await API.updateRecordingMetadata(recording.id, { - title: editTitle || 'Custom Recording', - description: editDescription, - }); + await updateRecordingMetadata(recording, editTitle, editDescription); setSavedTitle(editTitle || 'Custom Recording'); setSavedDescription(editDescription); setEditing(false); - notifications.show({ + showNotification({ title: 'Saved', message: 'Recording metadata updated', color: 'green', @@ -249,8 +251,8 @@ const RecordingDetailsModal = ({ const handleRefreshArtwork = async (e) => { e.stopPropagation?.(); try { - await API.refreshArtwork(recording.id); - notifications.show({ + await refreshArtwork(recording.id); + showNotification({ title: 'Refreshing artwork', message: 'Poster resolution started', color: 'blue.5', @@ -265,7 +267,7 @@ const RecordingDetailsModal = ({ e.stopPropagation?.(); try { await runComSkip(recording); - notifications.show({ + showNotification({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', @@ -344,8 +346,8 @@ const RecordingDetailsModal = ({ )} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} @@ -468,7 +470,7 @@ const RecordingDetailsModal = ({ {onWatchLive && } {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } + {onEdit && isAfter(start, userNow()) && } {(customProps.status === 'completed' || customProps.status === 'stopped' || customProps.status === 'interrupted') && @@ -486,8 +488,8 @@ const RecordingDetailsModal = ({ - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} {rating && ( @@ -599,7 +601,7 @@ const RecordingDetailsModal = ({ title: { color: 'white' }, }} > - {isSeriesGroup ? Series() : Movie()} + {isSeriesGroup ? : } ); }; diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 9fe49220..d9007d34 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -1,16 +1,15 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api.js'; import { - parseDate, + format, + getNow, RECURRING_DAY_OPTIONS, + toDate, toTimeString, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; -import dayjs from 'dayjs'; -import { notifications } from '@mantine/notifications'; import { Badge, Button, @@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteRecurringRuleById, - getChannelOptions, + getFormDefaults, getUpcomingOccurrences, updateRecurringRule, updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getChannelsSummary, + getRecurringFormDefaults, + recurringFormValidators, + sortedChannelOptions, +} from '../../utils/forms/RecordingUtils.js'; const RecurringRuleModal = ({ opened, @@ -56,66 +62,18 @@ const RecurringRuleModal = ({ const rule = recurringRules.find((r) => r.id === ruleId); const channelOptions = useMemo(() => { - return getChannelOptions(allChannels); + return sortedChannelOptions(allChannels); }, [allChannels]); const form = useForm({ mode: 'controlled', - initialValues: { - channel_id: '', - days_of_week: [], - rule_name: '', - start_time: dayjs().startOf('hour').format('HH:mm'), - end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), - start_date: dayjs().toDate(), - end_date: dayjs().toDate(), - enabled: true, - }, - validate: { - channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const startValue = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - startValue.isValid() && - endValue.isValid() && - endValue.diff(startValue, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const endDate = dayjs(value); - const startDate = dayjs(values.start_date); - if (!value) return 'Select an end date'; - if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { - return 'End date cannot be before start date'; - } - return null; - }, - }, + initialValues: { ...getRecurringFormDefaults(), enabled: true }, + validate: recurringFormValidators, }); useEffect(() => { if (opened && rule) { - form.setValues({ - channel_id: `${rule.channel}`, - days_of_week: (rule.days_of_week || []).map((d) => String(d)), - rule_name: rule.name || '', - start_time: toTimeString(rule.start_time), - end_time: toTimeString(rule.end_time), - start_date: parseDate(rule.start_date) || dayjs().toDate(), - end_date: parseDate(rule.end_date), - enabled: Boolean(rule.enabled), - }); + form.setValues(getFormDefaults(rule)); } else { form.reset(); } @@ -127,7 +85,7 @@ const RecurringRuleModal = ({ let cancelled = false; (async () => { try { - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { console.warn('Failed to load channels for recurring rule modal', e); @@ -149,7 +107,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRule(ruleId, values); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule updated', message: 'Schedule adjustments saved', color: 'green', @@ -169,7 +127,7 @@ const RecurringRuleModal = ({ try { await deleteRecurringRuleById(ruleId); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule removed', message: 'All future occurrences were cancelled', color: 'red', @@ -189,7 +147,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRuleEnabled(ruleId, checked); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', message: checked ? 'Future occurrences will resume' @@ -210,7 +168,7 @@ const RecurringRuleModal = ({ try { await deleteRecordingById(occurrence.id); // recording_cancelled WS event handles recording list update - notifications.show({ + showNotification({ title: 'Occurrence cancelled', message: 'The selected airing was removed', color: 'yellow', @@ -246,7 +204,7 @@ const RecurringRuleModal = ({ setDeleting(true); try { await deleteRecordingById(sourceRecording.id); - notifications.show({ + showNotification({ title: 'Recording deleted', color: 'green', autoClose: 2500, @@ -275,7 +233,7 @@ const RecurringRuleModal = ({ }; const handleStartDateChange = (value) => { - form.setFieldValue('start_date', value || dayjs().toDate()); + form.setFieldValue('start_date', value || toDate(getNow())); }; const handleEndDateChange = (value) => { @@ -302,10 +260,11 @@ const RecurringRuleModal = ({ - {occStart.format(`${dateformat}, YYYY`)} + {format(occStart, `${dateformat}, YYYY`)} - {occStart.format(timeformat)} – {occEnd.format(timeformat)} + {format(occStart, timeformat)} –{' '} + {format(occEnd, timeformat)} diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx index eb4aa70d..a0cbb854 100644 --- a/frontend/src/components/forms/ScheduleInput.jsx +++ b/frontend/src/components/forms/ScheduleInput.jsx @@ -38,8 +38,8 @@ import { Popover, ActionIcon, Group, - Divider, - SimpleGrid, + PopoverTarget, + PopoverDropdown, } from '@mantine/core'; import { Info } from 'lucide-react'; import { validateCronExpression } from '../../utils/cronUtils'; @@ -118,12 +118,12 @@ export default function ScheduleInput({ Cron Expression - + - - + + COMMON EXAMPLES @@ -169,7 +169,7 @@ export default function ScheduleInput({ 30 14 1 * * - + } diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 67aaced5..6ec6cea4 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = { regex: 'Title regex', }; +const renderRuleSummary = (r) => { + const titleMode = (r.title_mode || 'exact').toLowerCase(); + const parts = []; + parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); + if (r.title) { + parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); + } + if (r.description) { + parts.push(`Description: "${r.description}"`); + } + return parts.join(' | '); +}; + export default function SeriesRecordingModal({ opened, onClose, @@ -59,19 +72,6 @@ export default function SeriesRecordingModal({ onRulesUpdate(updated); }; - const renderRuleSummary = (r) => { - const titleMode = (r.title_mode || 'exact').toLowerCase(); - const parts = []; - parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); - if (r.title) { - parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); - } - if (r.description) { - parts.push(`Description: "${r.description}"`); - } - return parts.join(' | '); - }; - return ( <> ${e.toLocaleString()}`; - } catch { - return `${start} - ${end}`; - } -} +import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js'; +import { + createSeriesRule, + evaluateSeriesRulesByTvgId, +} from '../../utils/guideUtils.js'; +import { + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getChannelOptions, + getTvgOptions, + previewSeriesRule, + TITLE_MODES, +} from '../../utils/forms/SeriesRuleEditorModalUtils.js'; export default function SeriesRuleEditorModal({ opened, @@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({ useEffect(() => { if (!opened) return; let cancelled = false; - API.getChannelsSummary() + getChannelsSummary() .then((chans) => { if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); }) @@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({ setPreviewLoading(true); setPreviewError(null); - API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + previewSeriesRule(debouncedPreviewKey, controller) .then((resp) => { if (controller.signal.aborted) return; setPreview(resp || { matches: [], total: 0 }); @@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({ // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value // since the same channel can appear across multiple EPG sources. const tvgOptions = useMemo(() => { - const seen = new Set(); - const options = []; - for (const t of tvgs || []) { - if (!t.tvg_id || seen.has(t.tvg_id)) continue; - seen.add(t.tvg_id); - options.push({ - value: t.tvg_id, - label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, - }); - } - return options.sort((a, b) => a.label.localeCompare(b.label)); + return getTvgOptions(tvgs); }, [tvgs]); - // Channel select options: prefer channels matching the selected tvg_id. const channelOptions = useMemo(() => { - const sorted = [...allChannels].sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum !== bNum) return aNum - bNum; - return (a.name || '').localeCompare(b.name || ''); - }); - const matching = []; - const others = []; - for (const c of sorted) { - const item = { - value: String(c.id), - label: c.channel_number - ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` - : c.name || `Channel ${c.id}`, - }; - const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; - if (tvgId && cTvg && String(cTvg) === String(tvgId)) { - matching.push(item); - } else { - others.push(item); - } - } - return [...matching, ...others]; + return getChannelOptions(allChannels, tvgsById, tvgId); }, [allChannels, tvgsById, tvgId]); const canSave = !!(payload.title || payload.description); @@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({ const handleSave = async () => { setSaving(true); try { - await API.createSeriesRule(payload); + await createSeriesRule(payload); // Trigger evaluation so matching upcoming programs get scheduled. try { - await API.evaluateSeriesRules(payload.tvg_id); + await evaluateSeriesRulesByTvgId(payload.tvg_id); await useChannelsStore.getState().fetchRecordings(); } catch (e) { console.warn('Failed to evaluate after save', e); @@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({ showNotification({ title: 'Series rule saved' }); if (onSaved) await onSaved(); onClose(); + } catch (e) { + console.error('Failed to save series rule', e); } finally { setSaving(false); } @@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({ )} - + {(preview.matches || []).map((p) => ( @@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({ )} - +
+ ), + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data.map((item) => ( + + ))} +
+ ), + Select: ({ label, disabled, rightSection, data, ...props }) => ( +
+ + {rightSection} + +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), +})); + +// ── @mantine/dates ───────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DatePickerInput: ({ label, value, onChange }) => ( +
+ + onChange(e.target.value ? new Date(e.target.value) : null)} + /> +
+ ), + DateTimePicker: ({ label, ...props }) => ( +
+ + +
+ ), + TimeInput: ({ label, value, onChange, onBlur }) => ( +
+ + +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleAlert: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js'; + +const setupStoreMock = () => { + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules }) + ); + + return { mockFetchRecordings, mockFetchRecurringRules }; +}; + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: '2024-06-01T10:00:00Z', + end_time: '2024-06-01T11:00:00Z', + custom_properties: { program: { title: 'Test Show' } }, + ...overrides, +}); + +const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 }); + +describe('RecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([ + { id: 'ch-1', name: 'HBO', channel_number: 501 }, + ]); + vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined); + setupStoreMock(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Alert ────────────────────────────────────────────────────────────────── + + describe('scheduling conflict alert', () => { + it('renders the scheduling conflicts alert', () => { + render(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts'); + }); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + describe('mode switching', () => { + it('defaults to "single" mode', () => { + render(); + expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true'); + }); + + it('switches to recurring mode when Recurring button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true'); + }); + + it('shows DateTimePicker fields in single mode', () => { + render(); + expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument(); + expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument(); + }); + + it('shows recurring fields when in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument(); + }); + + it('disables mode toggle when editing an existing recording', () => { + render(); + expect(screen.getByTestId('mode-single')).toBeDisabled(); + expect(screen.getByTestId('mode-recurring')).toBeDisabled(); + }); + + it('shows "Schedule Recording" submit button in single mode', () => { + render(); + expect(screen.getByText('Schedule Recording')).toBeInTheDocument(); + }); + + it('shows "Save Rule" submit button in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByText('Save Rule')).toBeInTheDocument(); + }); + }); + + // ── Channel loading ──────────────────────────────────────────────────────── + + describe('channel loading', () => { + it('calls getChannelsSummary when modal opens', async () => { + render(); + await waitFor(() => { + expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + it('calls sortedChannelOptions with loaded channels', async () => { + const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }]; + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + channels, + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => { + vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + [], + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('does not load channels when modal is closed', () => { + render(); + expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled(); + }); + }); + + // ── Single form submit (create) ──────────────────────────────────────────── + + describe('single mode – create recording', () => { + it('calls buildSinglePayload with form values on submit', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecording when no existing recording', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).toHaveBeenCalled(); + }); + }); + + it('shows "Recording scheduled" notification after successful create', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording scheduled', color: 'green' }) + ); + }); + }); + + it('calls fetchRecordings after successful create', async () => { + const { mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful create', async () => { + const onClose = vi.fn(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not call showNotification when createRecording throws', async () => { + vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail')); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Single form submit (update) ──────────────────────────────────────────── + + describe('single mode – update recording', () => { + it('calls updateRecording when editing an existing recording', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.updateRecording).toHaveBeenCalledWith( + 'rec-1', + expect.anything() + ); + }); + }); + + it('does not call createRecording when updating', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).not.toHaveBeenCalled(); + }); + }); + + it('shows "Recording updated" notification after successful update', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording updated', color: 'green' }) + ); + }); + }); + }); + + // ── Recurring form submit ────────────────────────────────────────────────── + + describe('recurring mode – create rule', () => { + it('calls buildRecurringPayload with form values on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecurringRule on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecurringRule).toHaveBeenCalled(); + }); + }); + + it('calls fetchRecurringRules and fetchRecordings on success', async () => { + const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(mockFetchRecurringRules).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('shows "Recurring rule saved" notification on success', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recurring rule saved', color: 'green' }) + ); + }); + }); + + it('calls onClose after successful recurring submit', async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not show notification when createRecurringRule throws', async () => { + vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail')); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Form initialization ──────────────────────────────────────────────────── + + describe('form initialization', () => { + it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => { + const recording = makeRecording(); + const channel = makeChannel(); + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel); + }); + + it('calls getSingleFormDefaults with null when opening for new recording', () => { + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null); + }); + + it('calls getRecurringFormDefaults with channel on open', () => { + const channel = makeChannel(); + render(); + expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel); + }); + }); + + // ── Close / reset ────────────────────────────────────────────────────────── + + describe('close and reset', () => { + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx new file mode 100644 index 00000000..91216ac1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx @@ -0,0 +1,770 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ + default: Object.assign(vi.fn(), { + getState: vi.fn(() => ({ showVideo: vi.fn() })), + }), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getShowVideoUrl: vi.fn(), + runComSkip: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({ + getChannel: vi.fn(), + getRating: vi.fn(), + getStatRows: vi.fn(), + getUpcomingEpisodes: vi.fn(), + refreshArtwork: vi.fn(), + updateRecordingMetadata: vi.fn(), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: () => , + Pencil: () => , + RefreshCcw: () => , + X: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + {children} + ), + Button: ({ children, onClick, disabled, loading, size, variant, color }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
{children}
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + {children} + ), + Textarea: ({ label, value, onChange, placeholder, ...props }) => ( +
+ +