From bd202638ef0d4a8a7ab1b184e73a64b2beb1b799 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 14 May 2026 14:50:58 -0500 Subject: [PATCH 001/180] fix: Add psycogreen to dependencies for improved concurrency support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 81878424..8519b755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "python-vlc", "yt-dlp", "gevent==26.4.0", + "psycogreen", "daphne", "uwsgi", "django-cors-headers", From 6b9d621cf778490fcb70f3b7b1d58d610609c68b Mon Sep 17 00:00:00 2001 From: mwhit Date: Sun, 17 May 2026 04:52:07 +0000 Subject: [PATCH 002/180] 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 004/180] 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 005/180] 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 006/180] 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 007/180] 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 008/180] 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 009/180] 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 010/180] 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 50e23cbfd77d61dcb05dc4a977c0cf16a40e09a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 17 May 2026 20:17:59 -0500 Subject: [PATCH 011/180] Enhancement: add django-redis to dependencies for improved caching support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8519b755..c8c958cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "channels", "channels-redis==4.3.0", "django-filter", + "django-redis", "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", From 6f316977ad423b2c9204e13924a3f526235fca26 Mon Sep 17 00:00:00 2001 From: mwhit Date: Mon, 18 May 2026 22:46:56 +0000 Subject: [PATCH 012/180] 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 322c7c9323e466467e7284589263de11ea43e54d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 19 May 2026 09:05:18 -0500 Subject: [PATCH 013/180] Security: Update Django dependency to version 6.0.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c8c958cb..8542216c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "AGPL-3.0-only" requires-python = ">=3.13" dynamic = ["version"] dependencies = [ - "Django==6.0.4", + "Django==6.0.5", "psycopg2-binary==2.9.12", "celery[redis]==5.6.3", "djangorestframework==3.17.1", From d8c0850fd8472e53dceeb2608686c9d15375d01b Mon Sep 17 00:00:00 2001 From: mwhit Date: Tue, 19 May 2026 23:48:11 +0000 Subject: [PATCH 014/180] 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 015/180] 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 fb89a7c97753eaf25a03d9935bdc6c23506ff255 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 16:46:32 -0500 Subject: [PATCH 016/180] Enhancement: refactor ChannelsTable and CustomTable components for improved selection handling and performance Bug Fix: Shift select on channel table. --- CHANGELOG.md | 7 +++ .../src/components/tables/ChannelsTable.jsx | 5 +- .../tables/CustomTable/CustomTableBody.jsx | 5 +- .../components/tables/CustomTable/index.jsx | 46 +++++-------------- 4 files changed, 25 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8058317..f0ee26a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger. - **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block. - **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes. +- **Shift+click range selection in the channels table broken after row memoization.** After `MemoizedTableRow` was introduced, `handleShiftSelect` captured `lastClickedId` from its render-time closure. Because the memo comparator intentionally excludes callback function references, unselected rows retained the stale closure where `lastClickedId === null`, so every shift+click from a previously unchecked row fell through to a plain toggle instead of selecting the range. Added `lastClickedIdRef` and `allRowIdsRef` alongside the existing `selectedTableIdsRef`; `handleShiftSelect` now reads from those refs so every row uses the current anchor ID and full ID list regardless of which render produced the closure. +- **Selected rows in the channels table did not show the teal highlight.** `MemoizedTableRow` applied `backgroundColor: '#163632'` based on `row.getIsSelected()` from TanStack Table's API. Because `state.rowSelection` was never wired into `useReactTable`, `row.getIsSelected()` always returned `false` and selected rows remained unstyled. Changed to use the `isSelected` prop, which is correctly derived from `selectedTableIdsSet` and already tracked by the memo comparator. +- **`blur` event listener in `useTable` leaked on component unmount.** The `useEffect` cleanup function called `window.removeEventListener('blur', ...)` with a newly created anonymous function literal that never matched the handler registered at setup time, so the listener was never removed. Extracted to a named `handleBlur` constant so setup and cleanup reference the same function. ### Performance @@ -143,6 +146,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`JsonResponse` for ID list and channel summary endpoints.** The `stream_ids`, `channel_ids`, and channel `summary` endpoints now use `django.http.JsonResponse` instead of DRF's `Response`, bypassing the DRF renderer pipeline (content-type negotiation, serializer dispatch) for responses that are already plain Python structures. Removes overhead on every channel-table load and stream-table load. - **Logo queryset annotates `channel_count` to eliminate N+1 in `LogoSerializer`.** `LogoViewSet.get_queryset()` now annotates each row with `Count('channels')`. `LogoSerializer.get_channel_count()` and `get_is_used()` read the annotation directly instead of issuing a separate `COUNT(*)` per logo. The `used=true` and `used=false` list filters use the annotation for their conditions, removing the `DISTINCT` that was previously required. - **`ChannelProfileSerializer` reads prefetched memberships.** `ChannelProfileViewSet.get_queryset()` now prefetches enabled `ChannelProfileMembership` rows into `enabled_memberships`. `ChannelProfileSerializer.get_channels()` uses the prefetched set when available, eliminating one query per profile in any response that lists multiple profiles. +- **Channel table selection re-render reduction.** + - Removed `isShiftKeyDown` React state from `useTable`. Setting it on every `keydown`/`keyup` event triggered a re-render of any table consumer (`ChannelsTable`, `CustomTableBody`, ~50 memoized row comparisons) each time shift was pressed or released. The visual shift-key effect is handled entirely by `document.body.classList` manipulation, so the React state served no purpose. + - Removed the `selectedChannelIds` reactive Zustand store subscription from `ChannelsTable`. Each checkbox click wrote to both local `selectedTableIds` state and the store via `onRowSelectionChange`, causing two sequential re-renders of `ChannelsTable` per click. The two consumers of this subscription (`deleteChannel` and `ChannelBatchForm`) now read from `table.selectedTableIds` directly. + - Removed the dead `rowSelection` useMemo that built a TanStack row-selection map from `selectedTableIds`. The map was placed in `tableInstance` but `state.rowSelection` was never passed to `useReactTable`, so TanStack never consumed it. Eliminated a full page-row iteration on every selection change. ## [0.24.0] - 2026-05-03 diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index ae0e9e69..797471ae 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -291,7 +291,6 @@ const ChannelsTable = ({ onReady }) => { const setSelectedChannelIds = useChannelsTableStore( (s) => s.setSelectedChannelIds ); - const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); const setExpandedChannelId = useChannelsTableStore( (s) => s.setExpandedChannelId ); @@ -613,7 +612,7 @@ const ChannelsTable = ({ onReady }) => { table.setSelectedTableIds([]); - if (selectedChannelIds.length > 0) { + if (table.selectedTableIds.length > 0) { // Use bulk delete for multiple selections setIsBulkDelete(true); setChannelToDelete(null); @@ -1726,7 +1725,7 @@ const ChannelsTable = ({ onReady }) => { /> diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index cae17e67..02aa08e7 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -35,11 +35,14 @@ const MemoizedTableRow = React.memo( { + if (e.shiftKey) e.preventDefault(); + }} style={{ display: 'flex', width: '100%', minWidth: '100%', - ...(row.getIsSelected() && { + ...(isSelected && { backgroundColor: '#163632', }), ...customRowStyles, diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 0c081721..3df90747 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -30,7 +30,10 @@ const useTable = ({ selectedTableIdsRef.current = selectedTableIds; const [expandedRowIds, setExpandedRowIds] = useState([]); const [lastClickedId, setLastClickedId] = useState(null); - const [isShiftKeyDown, setIsShiftKeyDown] = useState(false); + const lastClickedIdRef = useRef(lastClickedId); + lastClickedIdRef.current = lastClickedId; + const allRowIdsRef = useRef(allRowIds); + allRowIdsRef.current = allRowIds; // Use shared table preferences hook const { headerPinned, setHeaderPinned, tableSize, setTableSize } = @@ -39,8 +42,6 @@ const useTable = ({ // Event handlers for shift key detection with improved handling const handleKeyDown = useCallback((e) => { if (e.key === 'Shift') { - setIsShiftKeyDown(true); - // Apply the class to disable text selection immediately document.body.classList.add('shift-key-active'); // Set a style attribute directly on body for extra assurance document.body.style.userSelect = 'none'; @@ -52,8 +53,6 @@ const useTable = ({ const handleKeyUp = useCallback((e) => { if (e.key === 'Shift') { - setIsShiftKeyDown(false); - // Remove the class when shift is released document.body.classList.remove('shift-key-active'); // Reset the style attributes document.body.style.removeProperty('user-select'); @@ -68,27 +67,19 @@ const useTable = ({ window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); - // Also detect blur/focus events to handle cases where shift is held and window loses focus - window.addEventListener('blur', () => { - setIsShiftKeyDown(false); + const handleBlur = () => { document.body.classList.remove('shift-key-active'); document.body.style.removeProperty('user-select'); document.body.style.removeProperty('-webkit-user-select'); document.body.style.removeProperty('-ms-user-select'); document.body.style.removeProperty('cursor'); - }); + }; + window.addEventListener('blur', handleBlur); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); - window.removeEventListener('blur', () => { - setIsShiftKeyDown(false); - document.body.classList.remove('shift-key-active'); - document.body.style.removeProperty('user-select'); - document.body.style.removeProperty('-webkit-user-select'); - document.body.style.removeProperty('-ms-user-select'); - document.body.style.removeProperty('cursor'); - }); + window.removeEventListener('blur', handleBlur); }; }, [handleKeyDown, handleKeyUp]); @@ -129,16 +120,6 @@ const useTable = ({ } }; - const rowSelection = useMemo(() => { - const selection = {}; - table.getRowModel().rows.forEach((row) => { - if (selectedTableIdsSet.has(row.original.id)) { - selection[row.id] = true; - } - }); - return selection; - }, [selectedTableIdsSet, table.getRowModel().rows]); - const onSelectAllChange = async (e) => { const selectAll = e.target.checked; if (selectAll) { @@ -162,22 +143,22 @@ const useTable = ({ // Handle the shift+click selection const handleShiftSelect = (rowId, isShiftKey) => { - if (!isShiftKey || lastClickedId === null) { + if (!isShiftKey || lastClickedIdRef.current === null) { // Normal selection behavior setLastClickedId(rowId); return false; // Return false to indicate we're not handling it } // Handle shift-click range selection - const currentIndex = allRowIds.indexOf(rowId); - const lastIndex = allRowIds.indexOf(lastClickedId); + const currentIndex = allRowIdsRef.current.indexOf(rowId); + const lastIndex = allRowIdsRef.current.indexOf(lastClickedIdRef.current); if (currentIndex === -1 || lastIndex === -1) return false; // Determine range const startIndex = Math.min(currentIndex, lastIndex); const endIndex = Math.max(currentIndex, lastIndex); - const rangeIds = allRowIds.slice(startIndex, endIndex + 1); + const rangeIds = allRowIdsRef.current.slice(startIndex, endIndex + 1); // Preserve existing selections outside the range const idsOutsideRange = selectedTableIdsRef.current.filter( @@ -252,14 +233,12 @@ const useTable = ({ ...options, selectedTableIds, updateSelectedTableIds, - rowSelection, allRowIds, onSelectAllChange, selectedTableIdsSet, expandedRowIds, expandedRowRenderer, setSelectedTableIds, - isShiftKeyDown, // Include shift key state in the table instance headerPinned, setHeaderPinned, tableSize, @@ -269,7 +248,6 @@ const useTable = ({ selectedTableIdsSet, expandedRowIds, allRowIds, - isShiftKeyDown, options, headerPinned, setHeaderPinned, From 429a6a6bdc16d03f0c225274f19dbade4ca4adc4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 16:52:18 -0500 Subject: [PATCH 017/180] tests: Fixed DVR settings form test. --- .../utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js index 35bb6501..227df50d 100644 --- a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js @@ -71,6 +71,8 @@ describe('DvrSettingsFormUtils', () => { movie_fallback_template: '', comskip_enabled: false, comskip_custom_path: '', + comskip_mode: 'cut', + comskip_hw_accel: 'none', pre_offset_minutes: 0, post_offset_minutes: 0, }); From 821dccc380eb23cb3bfab344bde5c916afe3dc30 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 17:22:42 -0500 Subject: [PATCH 018/180] Enhancement: update cursor styles for shift key active state to improve user experience --- .../components/tables/CustomTable/index.jsx | 2 +- frontend/src/components/tables/table.css | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 3df90747..0065b4c2 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -47,7 +47,7 @@ const useTable = ({ document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; document.body.style.msUserSelect = 'none'; - document.body.style.cursor = 'pointer'; + document.body.style.cursor = 'default'; } }, []); diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 1e21540a..ea3fb017 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -152,8 +152,8 @@ html { } /* Prevent text selection when shift key is pressed */ -.shift-key-active { - cursor: pointer !important; +body.shift-key-active { + cursor: default !important; } .shift-key-active *, @@ -167,10 +167,10 @@ html { } /* Always allow text selection in editable elements */ -.shift-key-active input, +.shift-key-active input:not([type='checkbox']):not([type='radio']), .shift-key-active textarea, .shift-key-active [contenteditable='true'], -.shift-key-active .table-input-header input { +.shift-key-active .table-input-header input:not([type='checkbox']):not([type='radio']) { user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; @@ -179,13 +179,13 @@ html { } /* Improve specificity and ensure text selection is disabled when shift is pressed */ -.shift-key-active, -.shift-key-active * { +body.shift-key-active, +body.shift-key-active * { user-select: none !important; -webkit-user-select: none !important; -moz-user-select: none !important; -ms-user-select: none !important; - cursor: pointer !important; + cursor: default !important; } /* Add a visual indicator when shift is pressed */ @@ -198,13 +198,13 @@ html { } /* Always allow text selection in inputs even when shift is pressed */ -.shift-key-active input, -.shift-key-active textarea, -.shift-key-active [contenteditable='true'], -.shift-key-active select, -.shift-key-active .mantine-Select-input, -.shift-key-active .mantine-MultiSelect-input, -.shift-key-active .table-input-header input { +body.shift-key-active input:not([type='checkbox']):not([type='radio']), +body.shift-key-active textarea, +body.shift-key-active [contenteditable='true'], +body.shift-key-active select, +body.shift-key-active .mantine-Select-input, +body.shift-key-active .mantine-MultiSelect-input, +body.shift-key-active .table-input-header input:not([type='checkbox']):not([type='radio']) { user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; @@ -212,6 +212,12 @@ html { cursor: text !important; } +/* Keep pointer cursor on checkboxes/radios when shift is pressed */ +body.shift-key-active input[type='checkbox'], +body.shift-key-active input[type='radio'] { + cursor: pointer !important; +} + /* Column resize handle styles */ .resizer { position: absolute; From e0bb2d925ed51ee9b900e64de23532607a0e2a23 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 20 May 2026 17:27:32 -0500 Subject: [PATCH 019/180] Enhancement: implement Shift+click and Ctrl+click row selection in tables for improved user interaction --- CHANGELOG.md | 4 +++ .../tables/CustomTable/CustomTable.jsx | 1 + .../tables/CustomTable/CustomTableBody.jsx | 4 +++ .../components/tables/CustomTable/index.jsx | 28 ++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ee26a9..054d160b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types. - **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches. - **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule. +- **Shift+click and Ctrl+click row selection in tables.** Clicking anywhere on a non-interactive area of a row now participates in selection: + - **Shift+click**: extends selection from the last-clicked row to the current row (range select), identical to shift+clicking the checkbox. + - **Ctrl+click** (Cmd+click on Mac): toggles the clicked row in or out of the current selection without disturbing other selected rows. + - Plain clicks on action buttons, checkboxes, inputs, links, and menus are unaffected. The expand chevron uses `stopPropagation` so expanding a row does not also trigger selection. ### Changed diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 8b0a9f0c..d0dcdbd8 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -76,6 +76,7 @@ const CustomTable = ({ table }) => { tableCellProps={table.tableCellProps} enableDragDrop={table.enableDragDrop} selectedTableIdsSet={table.selectedTableIdsSet} + handleRowClickRef={table.handleRowClickRef} /> ); diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 02aa08e7..170d51e6 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -17,6 +17,7 @@ const MemoizedTableRow = React.memo( isSelected, renderBodyCellRef, expandedRowRendererRef, + handleRowClickRef, getRowStyles, tableCellProps, enableDragDrop, @@ -38,6 +39,7 @@ const MemoizedTableRow = React.memo( onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }} + onClick={(e) => handleRowClickRef?.current?.(row.original.id, e)} style={{ display: 'flex', width: '100%', @@ -101,6 +103,7 @@ const CustomTableBody = ({ tableCellProps, enableDragDrop = false, selectedTableIdsSet, + handleRowClickRef, }) => { // Store callbacks in refs so memoized rows always access the latest versions // without the function references themselves triggering re-renders. @@ -127,6 +130,7 @@ const CustomTableBody = ({ } renderBodyCellRef={renderBodyCellRef} expandedRowRendererRef={expandedRowRendererRef} + handleRowClickRef={handleRowClickRef} getRowStyles={getRowStyles} tableCellProps={tableCellProps} enableDragDrop={enableDragDrop} diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 0065b4c2..e691dc6e 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -34,6 +34,7 @@ const useTable = ({ lastClickedIdRef.current = lastClickedId; const allRowIdsRef = useRef(allRowIds); allRowIdsRef.current = allRowIds; + const handleRowClickRef = useRef(null); // Use shared table preferences hook const { headerPinned, setHeaderPinned, tableSize, setTableSize } = @@ -171,6 +172,29 @@ const useTable = ({ return true; // Return true to indicate we've handled it }; + const handleRowClick = (rowId, e) => { + if ( + e.target.closest( + 'button, a, input, select, textarea, [role="menuitem"], [role="option"], [role="button"]' + ) + ) { + return; + } + if (e.shiftKey) { + handleShiftSelect(rowId, true); + } else if (e.ctrlKey || e.metaKey) { + const newSet = new Set(selectedTableIdsRef.current); + if (newSet.has(rowId)) { + newSet.delete(rowId); + } else { + newSet.add(rowId); + setLastClickedId(rowId); + } + updateSelectedTableIds([...newSet]); + } + }; + handleRowClickRef.current = handleRowClick; + const renderBodyCell = ({ row, cell }) => { if (bodyCellRenderFns[cell.column.id]) { return bodyCellRenderFns[cell.column.id]({ row, cell }); @@ -209,7 +233,8 @@ const useTable = ({ return (
{ + onClick={(e) => { + e.stopPropagation(); onRowExpansion(row); }} > @@ -239,6 +264,7 @@ const useTable = ({ expandedRowIds, expandedRowRenderer, setSelectedTableIds, + handleRowClickRef, headerPinned, setHeaderPinned, tableSize, From e2074c192116a6274249c047e84a7216c827d862 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 21:52:28 -0400 Subject: [PATCH 020/180] 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 021/180] 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 022/180] 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 17e6e8c2381b8a9ef6aabfb9e6ce7808775bacc8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 10:13:28 -0500 Subject: [PATCH 023/180] chore: Cleanup unused code. --- apps/proxy/live_proxy/output/ts/generator.py | 3 +- apps/proxy/live_proxy/utils.py | 48 -------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/apps/proxy/live_proxy/output/ts/generator.py b/apps/proxy/live_proxy/output/ts/generator.py index 68ddacca..250a9b3a 100644 --- a/apps/proxy/live_proxy/output/ts/generator.py +++ b/apps/proxy/live_proxy/output/ts/generator.py @@ -169,8 +169,7 @@ class StreamGenerator: yield create_ts_packet('error', "Error: Channel is stopping") return False - # Send PAT+PMT+null so clients recognise a valid TS program and keep - # buffering instead of timing out from missing program info. + # Send null packets to keep the connection alive during initialization. if time.time() - last_keepalive >= keepalive_interval: logger.debug(f"[{self.client_id}] Sending keepalive during initialization") keepalive_data = create_ts_packet('null') diff --git a/apps/proxy/live_proxy/utils.py b/apps/proxy/live_proxy/utils.py index 5177f1ce..c83ce3ba 100644 --- a/apps/proxy/live_proxy/utils.py +++ b/apps/proxy/live_proxy/utils.py @@ -1,6 +1,5 @@ import logging import re -import struct from urllib.parse import urlparse import inspect @@ -58,53 +57,6 @@ def get_client_ip(request): ip = request.META.get('REMOTE_ADDR') return ip -def _mpeg_crc32(data): - crc = 0xFFFFFFFF - for b in data: - crc ^= b << 24 - for _ in range(8): - if crc & 0x80000000: - crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF - else: - crc = (crc << 1) & 0xFFFFFFFF - return crc - - -def create_ts_pat_pmt_packets(): - """ - Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100). - - Declares program 1 with an H.264 video track at PID 0x0101. - TS clients like VLC need PAT/PMT to recognise a stream as valid; without - them they time out waiting for program info even while receiving null packets. - Returns exactly 376 bytes (2 x 188-byte TS packets). - """ - # PAT section: program 1 mapped to PMT at PID 0x0100 - pat_body = bytes([ - 0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13 - 0x00, 0x01, # transport_stream_id=1 - 0xC1, 0x00, 0x00, # version=0, current=1, section 0/0 - 0x00, 0x01, # program_number=1 - 0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID) - ]) - pat_body += struct.pack('>I', _mpeg_crc32(pat_body)) - pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body))) - - # PMT section: program 1, H.264 video at PID 0x0101 - pmt_body = bytes([ - 0x02, 0xB0, 0x12, # table_id=PMT, section_length=18 - 0x00, 0x01, # program_number=1 - 0xC1, 0x00, 0x00, # version=0, current=1, section 0/0 - 0xE1, 0x01, # PCR_PID=0x0101 - 0xF0, 0x00, # program_info_length=0 - 0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101 - ]) - pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body)) - pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body))) - - return pat_packet + pmt_packet - - def create_ts_packet(packet_type='null', message=None): """ Create a Transport Stream (TS) packet for various purposes. From f416c7c25112f795c650bc7b2923443fac6a06e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:00:52 -0500 Subject: [PATCH 024/180] Enhancement: add About modal with version info and community links; refactor SVG icons into separate module --- CHANGELOG.md | 3 + frontend/src/components/AboutModal.jsx | 144 ++++++++++++++++++ frontend/src/components/PluginDetailPanel.jsx | 13 +- frontend/src/components/Sidebar.jsx | 30 +++- frontend/src/components/icons.jsx | 13 ++ 5 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/AboutModal.jsx create mode 100644 frontend/src/components/icons.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 054d160b..69e709f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states. - **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option: - **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut. - **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this. @@ -48,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file. + - **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations. - **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected. - **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs. diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx new file mode 100644 index 00000000..55342cb3 --- /dev/null +++ b/frontend/src/components/AboutModal.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + Box, + Button, + Divider, + Group, + Modal, + SimpleGrid, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { BookOpen, Github, Heart, Users } from 'lucide-react'; +import { DiscordIcon } from './icons.jsx'; +import logo from '../images/logo.png'; +import useSettingsStore from '../store/settings'; + +const AboutModal = ({ isOpen, onClose }) => { + const appVersion = useSettingsStore((s) => s.version); + const versionString = `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`; + + return ( + + + + Dispatcharr + + + Dispatcharr + + + {versionString} + + + + + + + + + + + + + + + + + + + + + + + + + + + + Contributors + + + + Dispatcharr is built by the community, for the community. Thank you + to every contributor, tester, and supporter who has helped make this + project what it is. + + + + + + + In memory of{' '} + + Jesse Mann + + . + + + + + + ); +}; + +export default AboutModal; diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 602e0852..27d553a9 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -23,18 +23,7 @@ import { } from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; import { formatKB } from '../utils/networkUtils.js'; - -export const GitHubIcon = ({ size = 16 }) => ( - - - -); - -export const DiscordIcon = ({ size = 16 }) => ( - - - -); +import { DiscordIcon, GitHubIcon } from './icons.jsx'; /** * Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals. diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index f5332d3a..c17f02e4 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,7 +1,15 @@ import React, { useRef, useState, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; -import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react'; +import { + Copy, + LogOut, + ChevronDown, + ChevronRight, + Heart, + HelpCircle, +} from 'lucide-react'; +import AboutModal from './AboutModal'; import { getOrderedNavItems } from '../config/navigation'; import { Avatar, @@ -156,6 +164,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const publicIPRef = useRef(null); const [userFormOpen, setUserFormOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); const closeUserForm = () => setUserFormOpen(false); @@ -372,6 +381,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { + + setAboutOpen(true)} + > + + + {isAuthenticated && } @@ -389,10 +407,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { > {isAuthenticated && } + + setAboutOpen(true)} + > + + + )} + setAboutOpen(false)} /> ); }; diff --git a/frontend/src/components/icons.jsx b/frontend/src/components/icons.jsx new file mode 100644 index 00000000..540f4e68 --- /dev/null +++ b/frontend/src/components/icons.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export const GitHubIcon = ({ size = 16 }) => ( + + + +); + +export const DiscordIcon = ({ size = 16 }) => ( + + + +); From 2f66b7c183f849e2daf1dd294b88df95a25f275f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:01:10 -0500 Subject: [PATCH 025/180] chore: remove python-gnupg dependency from project --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8542216c..db7efc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", - "python-gnupg", ] [build-system] From 7e0ea5eae0b3dc0b26014b01236902faa9e08a1a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:12:31 -0500 Subject: [PATCH 026/180] Enhancement: add caching for plugin detail responses and implement cache invalidation on repo refresh. Bug Fix: The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. --- CHANGELOG.md | 5 ++ Plugin_repo.md | 126 ++++++++++++++++------------- apps/plugins/api_views.py | 163 ++++++++++++++++++++++++++++++++++---- 3 files changed, 222 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e709f1..8d12736e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dependency updates: - `Django` 6.0.4 → 6.0.5 (security patch; see Security section) +### Removed + +- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it). + ### Fixed - **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead. @@ -116,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason. - `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages. - `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee. +- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh. - **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218) - **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810) - **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810) diff --git a/Plugin_repo.md b/Plugin_repo.md index 6df4dec5..1e02efb7 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -81,6 +81,7 @@ This is the simplest valid repo manifest - one plugin with enough info to show i Dispatcharr accepts two top-level shapes: **Wrapped (supports signing):** + ```json { "manifest": { "plugins": [...], ... }, @@ -89,6 +90,7 @@ Dispatcharr accepts two top-level shapes: ``` **Flat (no signing):** + ```json { "plugins": [...], @@ -118,36 +120,36 @@ If the name contains any of these, the repo will be rejected on add and skipped ### Top-Level Metadata -| Field | Required | Description | -|-------|----------|-------------| -| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | -| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | -| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | -| `plugins` | **Yes** | Array of plugin entry objects. | +| Field | Required | Description | +| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | +| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | +| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | +| `plugins` | **Yes** | Array of plugin entry objects. | ### Plugin Entry Fields -| Field | Required | Description | -|-------|----------|-------------| -| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | -| `name` | **Yes** | Human-readable display name. | -| `description` | No | Short description shown on the plugin card. | -| `author` | No | Author or organization name. | -| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | -| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | -| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | -| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | -| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | -| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | -| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | -| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | -| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | -| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | -| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | -| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | -| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | -| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | -| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | +| Field | Required | Description | +| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | +| `name` | **Yes** | Human-readable display name. | +| `description` | No | Short description shown on the plugin card. | +| `author` | No | Author or organization name. | +| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | +| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | +| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | +| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | +| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | +| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | +| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | +| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | +| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | +| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | +| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | +| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | +| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything. @@ -160,6 +162,7 @@ If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) ``` This lets you keep plugin entries compact: + ```json { "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", @@ -175,6 +178,7 @@ This lets you keep plugin entries compact: ``` **Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL: + ``` {registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png ``` @@ -186,6 +190,7 @@ This lets you keep plugin entries compact: The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead. Include a per-plugin manifest if you want to: + - Offer multiple downloadable versions - Show per-version compatibility ranges - Display build timestamps and commit links for each version @@ -196,6 +201,7 @@ Include a per-plugin manifest if you want to: Same as the root manifest - both flat and wrapped formats are accepted: **Flat (no signing):** + ```json { "slug": "...", @@ -204,6 +210,7 @@ Same as the root manifest - both flat and wrapped formats are accepted: ``` **Wrapped (supports signing):** + ```json { "manifest": { @@ -272,33 +279,33 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. ### Per-Plugin Manifest Fields -| Field | Required | Description | -|-------|----------|-------------| -| `slug` | No | Plugin identifier (should match the repo entry). | -| `name` | No | Display name. | -| `description` | No | Full description shown in the detail modal. | -| `author` | No | Author/org name shown in the detail modal. | -| `license` | No | SPDX license identifier. | -| `latest_version` | No | Latest version string. | -| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | -| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | -| `versions` | No | Array of version objects (newest first recommended). | -| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | +| Field | Required | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `slug` | No | Plugin identifier (should match the repo entry). | +| `name` | No | Display name. | +| `description` | No | Full description shown in the detail modal. | +| `author` | No | Author/org name shown in the detail modal. | +| `license` | No | SPDX license identifier. | +| `latest_version` | No | Latest version string. | +| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | +| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | +| `versions` | No | Array of version objects (newest first recommended). | +| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | ### Version Object Fields -| Field | Required | Description | -|-------|----------|-------------| -| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | -| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | -| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | -| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | -| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | -| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | -| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | -| `size` | No | Size of this version's zip in kilobytes. Informational only. | -| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | -| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | +| Field | Required | Description | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | +| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | +| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | +| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | +| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | +| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | +| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `size` | No | Size of this version's zip in kilobytes. Informational only. | +| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | +| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`. @@ -348,6 +355,7 @@ jq -c '.manifest' manifest.json | gpg --armor --detach-sign ``` In code terms: + ```python import json canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n" @@ -371,11 +379,11 @@ Use the wrapped format so the signature sits alongside the manifest: ### Verification Results -| Result | Meaning | UI Badge | -|--------|---------|----------| -| `true` | Valid signature | Green checkmark | -| `false` | Invalid signature or verification error | Red X | -| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral | +| Result | Meaning | UI Badge | +| ------- | ------------------------------------------------------------------- | --------------- | +| `true` | Valid signature | Green checkmark | +| `false` | Invalid signature or verification error | Red X | +| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral | ### Signing Workflow Example @@ -446,6 +454,7 @@ my_plugin-1.0.0.zip ``` Or with a subdirectory: + ``` my_plugin-1.0.0.zip my_plugin/ @@ -477,6 +486,7 @@ The plugin is installed **disabled** by default. The user can enable it from the Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection. A plugin shows "Update Available" when: + - It is managed (installed from a repo) - Its `installed_version` differs from `latest_version` - It was installed from the same repo @@ -488,7 +498,9 @@ A plugin shows "Update Available" when: A plugin repo manifest is just a JSON file served over HTTPS. Some options: ### GitHub Pages / Raw Content + Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs: + ``` https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json ``` @@ -496,9 +508,11 @@ https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json Use `root_url` pointing to your releases branch/path so version URLs stay relative. ### Static File Server + Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed. ### GitHub Releases + You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch. --- diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 8f7a2c71..80ab591a 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -1,7 +1,6 @@ import hashlib import ipaddress import logging -import io import json import re import socket @@ -9,6 +8,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status, serializers from drf_spectacular.utils import extend_schema, inline_serializer +from django.core.cache import cache from django.conf import settings from django.core.files.uploadedfile import UploadedFile from django.http import FileResponse @@ -335,6 +335,21 @@ def _save_fetched_manifest_to_repo(repo, data, verified): return None +def _invalidate_plugin_detail_cache(repo_id, manifest_data): + manifest = manifest_data.get("manifest", manifest_data) + root_url = manifest.get("root_url", "").rstrip("/") + keys = [] + for p in manifest.get("plugins", []): + url = p.get("manifest_url", "") + if not url: + continue + if root_url and not url.startswith(("http://", "https://")): + url = f"{root_url}/{url}" + keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}") + if keys: + cache.delete_many(keys) + + def _unmanage_dropped_slugs(repo, new_manifest_data): """After a manifest refresh, clear source_repo on any installed plugins whose slug is no longer listed in the repo's manifest. Also syncs the @@ -571,6 +586,7 @@ class PluginDeleteAPIView(PluginAuthMixin, APIView): # --------------------------------------------------------------------------- MANIFEST_FETCH_TIMEOUT = 15 +PLUGIN_DETAIL_CACHE_TTL = 300 # seconds OFFICIAL_KEY_PATH = os.path.join( os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub" @@ -589,6 +605,101 @@ def _normalize_pgp_key(text): return text +def _gpg_run(cmd, input_data=None, timeout=30): + """ + Run a GPG command using os.posix_spawn. + + os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang + that fork()-based approaches suffer under gevent+uWSGI. select.select() + and time.sleep() are gevent-patched so reads and the waitpid poll yield to + the hub cooperatively. + + Returns (returncode, stdout_bytes, stderr_bytes). + """ + import select as _select + import signal as _signal + import time as _time + + stdin_r, stdin_w = os.pipe() + stdout_r, stdout_w = os.pipe() + stderr_r, stderr_w = os.pipe() + + try: + executable = shutil.which(cmd[0]) or cmd[0] + pid = os.posix_spawn( + executable, cmd, os.environ, + file_actions=[ + (os.POSIX_SPAWN_DUP2, stdin_r, 0), + (os.POSIX_SPAWN_DUP2, stdout_w, 1), + (os.POSIX_SPAWN_DUP2, stderr_w, 2), + (os.POSIX_SPAWN_CLOSE, stdin_r), + (os.POSIX_SPAWN_CLOSE, stdin_w), + (os.POSIX_SPAWN_CLOSE, stdout_w), + (os.POSIX_SPAWN_CLOSE, stderr_w), + (os.POSIX_SPAWN_CLOSE, stdout_r), + (os.POSIX_SPAWN_CLOSE, stderr_r), + ], + ) + except Exception: + for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w): + try: + os.close(fd) + except OSError: + pass + raise + + for fd in (stdin_r, stdout_w, stderr_w): + os.close(fd) + + try: + if input_data: + os.write(stdin_w, input_data) + finally: + os.close(stdin_w) + + out, err = [], [] + done = set() + deadline = _time.monotonic() + timeout + try: + while len(done) < 2: + remaining = deadline - _time.monotonic() + if remaining <= 0: + try: + os.kill(pid, _signal.SIGKILL) + except ProcessLookupError: + pass + break + fds = [fd for fd in (stdout_r, stderr_r) if fd not in done] + readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5)) + for fd in readable: + data = os.read(fd, 8192) + if data: + (out if fd == stdout_r else err).append(data) + else: + done.add(fd) + finally: + for fd in (stdout_r, stderr_r): + try: + os.close(fd) + except OSError: + pass + + deadline = _time.monotonic() + 5.0 + while _time.monotonic() < deadline: + try: + wpid, st = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return -1, b"".join(out), b"".join(err) + if wpid == pid: + if os.WIFEXITED(st): + return os.WEXITSTATUS(st), b"".join(out), b"".join(err) + if os.WIFSIGNALED(st): + return -os.WTERMSIG(st), b"".join(out), b"".join(err) + return -1, b"".join(out), b"".join(err) + _time.sleep(0.01) + return -1, b"".join(out), b"".join(err) + + def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None): """Verify a detached GPG signature over the canonical manifest JSON. @@ -597,7 +708,7 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= repos). When *None* the bundled official key is used instead. Returns True if valid, False if invalid/error, None if verification - could not be attempted (no signature, no key, gnupg missing, etc.). + could not be attempted (no signature, no key, gpg binary missing, etc.). """ if not signature_armored: return None @@ -614,18 +725,19 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= logger.debug("No GPG public key available; skipping verification") return None - try: - import gnupg - except ImportError: - logger.debug("python-gnupg not installed; skipping signature verification") - return None - tmp_home = tempfile.mkdtemp(prefix="gpg_verify_") try: - gpg = gnupg.GPG(gnupghome=tmp_home) - import_result = gpg.import_keys(key_text) - if not import_result.fingerprints: - logger.warning("Failed to import GPG public key") + key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text + rc, _, import_stderr = _gpg_run( + ["gpg", "--batch", "--no-tty", "--status-fd", "2", + "--homedir", tmp_home, "--import"], + input_data=key_bytes, + ) + if logger.isEnabledFor(logging.DEBUG): + for line in import_stderr.decode("utf-8", errors="replace").splitlines(): + logger.debug("gpg import: %s", line) + if rc != 0: + logger.warning("GPG key import failed (rc=%d)", rc) return None # Must match what the signing script produces: jq -c '.manifest' @@ -639,8 +751,18 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text= with open(sig_path, "w") as sf: sf.write(signature_armored) - verified = gpg.verify_data(sig_path, manifest_bytes) - return bool(verified) + rc, _, verify_stderr = _gpg_run( + ["gpg", "--batch", "--no-tty", "--status-fd", "2", + "--homedir", tmp_home, "--verify", sig_path, "-"], + input_data=manifest_bytes, + ) + if logger.isEnabledFor(logging.DEBUG): + for line in verify_stderr.decode("utf-8", errors="replace").splitlines(): + logger.debug("gpg verify: %s", line) + return rc == 0 and b"VALIDSIG" in verify_stderr + except FileNotFoundError: + logger.debug("gpg binary not found; skipping signature verification") + return None except Exception: logger.exception("GPG signature verification error") return False @@ -881,6 +1003,7 @@ class PluginRepoRefreshAPIView(PluginAuthMixin, APIView): if err: return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) _unmanage_dropped_slugs(repo, data) + _invalidate_plugin_detail_cache(repo.id, data) return Response(PluginRepoSerializer(repo).data) @@ -1017,6 +1140,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView): _validate_fetch_url(manifest_url) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}" + cached = cache.get(cache_key) + if cached is not None: + return Response(cached) + try: resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT) resp.raise_for_status() @@ -1045,10 +1174,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView): if url_val and not url_val.startswith(("http://", "https://")): manifest_obj["latest"][url_field] = f"{root_url}/{url_val}" - return Response({ + result = { "manifest": manifest_obj, "signature_verified": verified, - }) + } + cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL) + return Response(result) except Exception as e: logger.exception("Failed to fetch plugin manifest from %s", manifest_url) return Response( From 9c22a52c145d766a593c8fb20e3a096905043f27 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 16:01:10 -0500 Subject: [PATCH 027/180] chore: remove python-gnupg dependency from project --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8542216c..db7efc78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "django-celery-beat>=2.9.0", "lxml==6.1.0", "packaging", - "python-gnupg", ] [build-system] From 1ab1f638a3fcf834643df5e788b61fbb7351d136 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 May 2026 17:08:01 -0500 Subject: [PATCH 028/180] tests: Fix frontend test for sidebar. --- frontend/src/components/__tests__/Sidebar.test.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx index 478078c7..d46010a7 100644 --- a/frontend/src/components/__tests__/Sidebar.test.jsx +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -22,6 +22,10 @@ vi.mock('../NotificationCenter', () => ({ ), })); +vi.mock('../AboutModal', () => ({ + default: () => null, +})); + // Mock lucide-react icons vi.mock('lucide-react', () => ({ ListOrdered: ({ onClick }) => ( @@ -59,6 +63,7 @@ vi.mock('lucide-react', () => ({ Heart: () =>
, Package: () =>
, Download: () =>
, + HelpCircle: () =>
, })); // Mock UserForm component From e3269a21ce56e1d75e2de567744551a165b63576 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 21 May 2026 22:33:35 +0000 Subject: [PATCH 029/180] Release v0.25.0 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d12736e..f753e9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-05-21 + ### Security - Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs: diff --git a/version.py b/version.py index ae571bbd..c6dd8a87 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.24.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.25.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 59587cbe663e0a1543a0497e6063aa774b008b9f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 22 May 2026 11:51:16 -0500 Subject: [PATCH 030/180] Fix XC stream URL format output handling and improve M3U playlist generation performance. Removed unused imports in apps\output --- CHANGELOG.md | 9 +++++++++ apps/output/views.py | 33 +++++++++++++++++---------------- apps/proxy/live_proxy/views.py | 7 ++++++- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f753e9a5..07233ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. +- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. + +### Performance + +- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation. + ## [0.25.0] - 2026-05-21 ### Security diff --git a/apps/output/views.py b/apps/output/views.py index 089df38a..1a73923d 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,6 +1,5 @@ from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse import json -from rest_framework.response import Response from django.urls import reverse from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream from apps.channels.utils import format_channel_number @@ -16,7 +15,7 @@ from datetime import datetime, timedelta import html import time from tzlocal import get_localzone -from urllib.parse import urlparse +from urllib.parse import urlencode import base64 import logging from django.db.models.functions import Lower @@ -211,7 +210,21 @@ def generate_m3u(request, profile_name=None, user=None): # This is an XC API request - use XC-style EPG URL base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" + # Build the query-string suffix for stream URLs once - it's the same for every channel + xc_qs = {} + if output_profile_id: + xc_qs['output_profile'] = output_profile_id + if output_format_param: + xc_qs['output_format'] = output_format_param + xc_qs_suffix = f"?{urlencode(xc_qs)}" if xc_qs else "" else: + # Pre-compute proxy query-string suffix (same for every channel in this request) + proxy_qs = {} + if output_profile_id: + proxy_qs['output_profile'] = output_profile_id + if output_format_param: + proxy_qs['output_format'] = output_format_param + proxy_qs_suffix = f"?{urlencode(proxy_qs)}" if proxy_qs else "" # Regular request - use standard EPG endpoint epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) @@ -219,7 +232,6 @@ def generate_m3u(request, profile_name=None, user=None): preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days'] query_params = {k: v for k, v in request.GET.items() if k in preserved_params} if query_params: - from urllib.parse import urlencode epg_url = f"{epg_base_url}?{urlencode(query_params)}" else: epg_url = epg_base_url @@ -281,9 +293,7 @@ def generate_m3u(request, profile_name=None, user=None): # Determine the stream URL based on request type if is_xc_request: - # XC API request - use XC-style stream URL format - base_url = build_absolute_uri_with_port(request, '') - stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}" elif use_direct_urls: # Try to get the first stream's direct URL all_streams = channel.streams.all() @@ -297,16 +307,7 @@ def generate_m3u(request, profile_name=None, user=None): else: # Standard behavior - use proxy URL base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") - qs_parts = {} - if output_profile_id: - qs_parts['output_profile'] = output_profile_id - if output_format_param: - qs_parts['output_format'] = output_format_param - if qs_parts: - from urllib.parse import urlencode - stream_url = f"{base_stream_url}?{urlencode(qs_parts)}" - else: - stream_url = base_stream_url + stream_url = f"{base_stream_url}{proxy_qs_suffix}" m3u_content += extinf_line + stream_url + "\n" diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..a2610c93 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -630,7 +630,12 @@ def stream_xc(request, username, password, channel_id): else: channel = get_object_or_404(Channel, id=channel_id) - force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts' + if extension.lower() == '.mp4': + force_format = 'fmp4' + elif extension.lower() == '.ts': + force_format = 'mpegts' + else: + force_format = None return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format) From 137241f873ca9a57cad006753f6f23d1ec149347 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 22 May 2026 13:53:00 -0500 Subject: [PATCH 031/180] Bug Fix: Fix auto sync migration. (Fixes #1259) --- CHANGELOG.md | 1 + apps/channels/migrations/0037_auto_sync_overhaul.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07233ffc..7ae64497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259) - **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. - **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. diff --git a/apps/channels/migrations/0037_auto_sync_overhaul.py b/apps/channels/migrations/0037_auto_sync_overhaul.py index d93a1789..86f34762 100644 --- a/apps/channels/migrations/0037_auto_sync_overhaul.py +++ b/apps/channels/migrations/0037_auto_sync_overhaul.py @@ -62,6 +62,9 @@ def backfill_auto_created_by_null(apps, schema_editor): f"(ambiguous/no streams): {demoted}" ) + with schema_editor.connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") + def reverse_auto_created_by_null(apps, schema_editor): # Forward decisions cannot be cleanly reverted (no record of the @@ -101,6 +104,9 @@ def reverse_backfill_channel_number_nulls(apps, schema_editor): ch.save(update_fields=["channel_number"]) next_num += 1.0 + with schema_editor.connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") + class Migration(migrations.Migration): From 0d1d1f27221d883fbc675edc0f78ccbbf26115e0 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Sat, 23 May 2026 14:20:13 -0400 Subject: [PATCH 032/180] 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 812d25b987e33dcaf8e8680b26d6391634db88fa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 13:54:41 -0500 Subject: [PATCH 033/180] fix: resolve SynchronousOnlyOperation in ORM calls by allowing async unsafe operations --- CHANGELOG.md | 1 + apps/channels/tasks.py | 14 ++++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae64497..5e3896f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260) - **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259) - **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly. - **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback. diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 91748652..598ca297 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1361,11 +1361,8 @@ def _evaluate_series_rules_locked(tvg_id, result): # Notify frontend to refresh try: - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'updates', - {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}}, - ) + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}) except Exception: pass @@ -1440,11 +1437,8 @@ def reschedule_upcoming_recordings_for_offset_change_impl(): # Notify frontend to refresh try: - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'updates', - {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}}, - ) + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "rescheduled": changed}) except Exception: pass From 5ea194059be5e247bf5df350d1309c10699c97c4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 23 May 2026 19:17:36 +0000 Subject: [PATCH 034/180] Release v0.25.1 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3896f7..7e998abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.1] - 2026-05-23 + ### Fixed - **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260) diff --git a/version.py b/version.py index c6dd8a87..5f17fe8a 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.25.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.25.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From 92d3068be0e964478ec2cb18f41183cf916a490a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:25:44 -0500 Subject: [PATCH 035/180] 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 036/180] 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 037/180] 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 038/180] 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 039/180] 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 040/180] 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 041/180] 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 042/180] 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 043/180] 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 044/180] 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 045/180] 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 }) => ( +
+ +