From 2781b21a8902ab0e9d12e15e1ec189ac0e0dab23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Marcoux?= Date: Tue, 19 May 2026 22:28:35 +0200 Subject: [PATCH 01/56] feat: built-in XC catch-up (timeshift) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds native catch-up/timeshift replay for Xtream Codes providers through the same HTTPStreamReader transport pipeline as live TV. Timeshift proxy (apps/timeshift/): - URL cascade: 3 candidate timestamp formats per provider, per-account format cache for fast-forward seek performance - MPEG-TS preamble stripping (shared with HTTPStreamReader) - Stats integration: timeshift viewers appear on /stats with TIMESHIFT badge - Auth via hmac.compare_digest on XC password Catchup detection — denormalized for zero-cost output queries: - Stream.is_catchup + Stream.catchup_days populated at XC import time - Channel.has_catchup + Channel.catchup_days + Channel.catchup_provider_stream_id rolled up via ChannelStream post_save signal (UI path) and explicit SQL after bulk_create (import path) - _xc_channel_entry() reads denormalized fields instead of per-channel custom_properties JSON introspection (eliminates N+1 queries) - Migration 0038 backfills existing data via raw SQL XC API enhancements: - server_info.timezone + start/end + time_now use configured timezone (triple consistency rule — fixes wrong-programme-plays bug) - Dynamic has_archive flag + auto prev_days for catch-up channels - XMLTV timestamps rewritten to local timezone for catch-up clients HTTPStreamReader extended (apps/proxy/live_proxy/input/http_streamer.py): - 1 MB pipe buffer via fcntl F_SETPIPE_SZ (eliminates producer/consumer ping-pong that halved throughput) - Pre-opened response= for URL cascade workflows - strip_ts_preamble= for XC servers emitting PHP warnings before TS - find_ts_sync() as shared utility - Builds on upstream O_NONBLOCK + select() write loop Provider stream_id lookup order: - stream_xc() and xc_get_epg() try internal Channel.id first, fall back to provider stream_id only when needed (avoids unconditional query on every request) Also includes: - VOD provider cascade in stream_vod() — iterates all M3U relations by priority when first provider is at capacity - Defensive null-safety: custom_sid: None → "" in get_live_streams, get_vod_streams, get_vod_info, get_series_info (fixes iPlayTV crash on JSON null for string fields) - Timeshift settings UI (timezone selector, debug toggle) - StreamConnectionCard violet TIMESHIFT badge - Orphan cleanup skips timeshift_* virtual channels --- CHANGELOG.md | 5 + .../migrations/0038_add_catchup_fields.py | 120 ++++ apps/channels/models.py | 32 ++ apps/channels/serializers.py | 1 + apps/channels/signals.py | 26 +- apps/channels/utils.py | 54 ++ apps/m3u/tasks.py | 59 +- apps/output/views.py | 237 +++++++- apps/proxy/live_proxy/channel_status.py | 14 + apps/proxy/live_proxy/constants.py | 2 + apps/proxy/live_proxy/input/http_streamer.py | 289 +++++++--- apps/proxy/live_proxy/server.py | 9 + apps/proxy/live_proxy/views.py | 25 +- apps/timeshift/__init__.py | 0 apps/timeshift/apps.py | 7 + apps/timeshift/helpers.py | 116 ++++ apps/timeshift/tests/__init__.py | 0 apps/timeshift/tests/test_helpers.py | 56 ++ apps/timeshift/tests/test_views.py | 177 ++++++ apps/timeshift/views.py | 512 ++++++++++++++++++ core/models.py | 24 + dispatcharr/settings.py | 1 + dispatcharr/urls.py | 8 + .../components/cards/StreamConnectionCard.jsx | 20 +- .../forms/settings/TimeshiftSettingsForm.jsx | 116 ++++ frontend/src/pages/Settings.jsx | 16 + .../settings/TimeshiftSettingsFormUtils.js | 17 + frontend/src/utils/pages/SettingsUtils.js | 43 ++ 28 files changed, 1867 insertions(+), 119 deletions(-) create mode 100644 apps/channels/migrations/0038_add_catchup_fields.py create mode 100644 apps/timeshift/__init__.py create mode 100644 apps/timeshift/apps.py create mode 100644 apps/timeshift/helpers.py create mode 100644 apps/timeshift/tests/__init__.py create mode 100644 apps/timeshift/tests/test_helpers.py create mode 100644 apps/timeshift/tests/test_views.py create mode 100644 apps/timeshift/views.py create mode 100644 frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx create mode 100644 frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cef64db..8d2b046c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **None** (default): software decode. - **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container. - **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container. +- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. + - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries. + - **`Settings → Timeshift` panel** with four knobs (defaults: `UTC`, `en`, `0`, off): `default_timezone`, `default_language`, `xmltv_prev_days_override`, `debug_logging`. + - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. + - **Byte path uses a dedicated producer thread + `os.pipe`** matching the pattern in `apps/proxy/live_proxy/input/http_streamer.py:HTTPStreamReader`. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: - `/hdhr/output_profile//lineup.json` - output profile only - `/hdhr//output_profile//lineup.json` - channel profile + output profile diff --git a/apps/channels/migrations/0038_add_catchup_fields.py b/apps/channels/migrations/0038_add_catchup_fields.py new file mode 100644 index 00000000..97a0b925 --- /dev/null +++ b/apps/channels/migrations/0038_add_catchup_fields.py @@ -0,0 +1,120 @@ +"""Add denormalized catch-up fields to Stream and Channel. + +Populated at M3U/XC import time so _xc_channel_entry() can read them as +zero-cost column reads instead of introspecting custom_properties JSON +per channel on every xc_get_live_streams call. +""" + +from django.db import migrations, models + + +def backfill_stream_catchup(apps, schema_editor): + """Derive is_catchup/catchup_days from Stream.custom_properties JSON.""" + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE dispatcharr_channels_stream + SET is_catchup = TRUE, + catchup_days = COALESCE( + (custom_properties->>'tv_archive_duration')::int, 7 + ) + WHERE custom_properties IS NOT NULL + AND custom_properties != 'null'::jsonb + AND ( + custom_properties->>'tv_archive' = '1' + OR custom_properties->>'tv_archive' = 'True' + ) + """) + + +def backfill_channel_catchup(apps, schema_editor): + """Roll up catch-up fields from streams to channels.""" + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE dispatcharr_channels_channel c SET + is_catchup = EXISTS ( + SELECT 1 FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), + catchup_days = COALESCE(( + SELECT MAX(s.catchup_days) FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), 0), + catchup_provider_stream_id = COALESCE(( + SELECT s.custom_properties->>'stream_id' + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ORDER BY cs."order" LIMIT 1 + ), '') + """) + + +class Migration(migrations.Migration): + + dependencies = [ + ("dispatcharr_channels", "0037_auto_sync_overhaul"), + ] + + operations = [ + # Stream fields + migrations.AddField( + model_name="stream", + name="is_catchup", + field=models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)", + ), + ), + migrations.AddField( + model_name="stream", + name="catchup_days", + field=models.PositiveIntegerField( + default=0, + help_text="Number of days of catch-up archive available (tv_archive_duration)", + ), + ), + # Channel fields + migrations.AddField( + model_name="channel", + name="is_catchup", + field=models.BooleanField( + default=False, + db_index=True, + help_text="Whether any stream on this channel supports catch-up (tv_archive=1)", + ), + ), + migrations.AddField( + model_name="channel", + name="catchup_days", + field=models.PositiveIntegerField( + default=0, + help_text="Max catch-up archive days across all streams on this channel", + ), + ), + migrations.AddField( + model_name="channel", + name="catchup_provider_stream_id", + field=models.CharField( + max_length=64, + blank=True, + default="", + help_text="Provider stream_id of the highest-priority catch-up stream", + ), + ), + # Backfill existing data + migrations.RunPython( + backfill_stream_catchup, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + backfill_channel_catchup, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 59af3e00..8964f067 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -134,6 +134,19 @@ class Stream(models.Model): db_index=True ) + # Denormalized catch-up fields — populated at M3U/XC import time from + # custom_properties["tv_archive"] and ["tv_archive_duration"]. Avoids + # per-channel JSON introspection on every xc_get_live_streams call. + is_catchup = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)", + ) + catchup_days = models.PositiveIntegerField( + default=0, + help_text="Number of days of catch-up archive available (tv_archive_duration)", + ) + class Meta: # If you use m3u_account, you might do unique_together = ('name','url','m3u_account') verbose_name = "Stream" @@ -374,6 +387,25 @@ class Channel(models.Model): help_text="The M3U account that auto-created this channel" ) + # Denormalized catch-up fields — rolled up from the channel's streams at + # import time and via post_save/post_delete signals on ChannelStream. + # Eliminates per-channel DB queries in _xc_channel_entry(). + is_catchup = models.BooleanField( + default=False, + db_index=True, + help_text="Whether any stream on this channel supports catch-up (tv_archive=1)", + ) + catchup_days = models.PositiveIntegerField( + default=0, + help_text="Max catch-up archive days across all streams on this channel", + ) + catchup_provider_stream_id = models.CharField( + max_length=64, + blank=True, + default="", + help_text="Provider stream_id of the highest-priority catch-up stream (for timeshift URL construction)", + ) + # Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries. # Auto-sync still recognizes them so they are not recreated when their # underlying provider stream persists; this is an output-layer concern, not diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index f5c83527..c0bdc525 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -454,6 +454,7 @@ class ChannelSerializer(serializers.ModelSerializer): "logo_id", "user_level", "is_adult", + "is_catchup", "hidden_from_output", "auto_created", "auto_created_by", diff --git a/apps/channels/signals.py b/apps/channels/signals.py index a1917b25..f1615c9a 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from django.utils.timezone import now, is_aware, make_aware from celery.result import AsyncResult from django_celery_beat.models import ClockedSchedule, PeriodicTask -from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording +from .models import Channel, Stream, ChannelStream, ChannelProfile, ChannelProfileMembership, Recording from apps.m3u.models import M3UAccount from apps.epg.tasks import parse_programs_for_tvg_id import json @@ -365,3 +365,27 @@ def schedule_task_on_save(sender, instance, created, **kwargs): @receiver(post_delete, sender=Recording) def revoke_task_on_delete(sender, instance, **kwargs): revoke_task(instance.task_id) + + +@receiver([post_save, post_delete], sender=ChannelStream) +def update_channel_catchup_fields(sender, instance, **kwargs): + """Roll up denormalized catch-up fields from streams to the channel. + + Covers the UI/API path (admin adds/removes a stream from a channel). + The bulk-import path bypasses signals and uses explicit SQL instead — + see apps/m3u/tasks.py after ChannelStream.objects.bulk_create(). + """ + channel = instance.channel + best = ( + channel.streams + .filter(is_catchup=True) + .order_by("channelstream__order") + .first() + ) + Channel.objects.filter(pk=channel.pk).update( + is_catchup=best is not None, + catchup_days=best.catchup_days if best else 0, + catchup_provider_stream_id=( + (best.custom_properties or {}).get("stream_id", "") if best else "" + ), + ) diff --git a/apps/channels/utils.py b/apps/channels/utils.py index f41c2b72..2ab343bb 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -16,6 +16,60 @@ def format_channel_number(value, empty=""): return int(value) return value + +def resolve_channel_by_provider_stream_id(provider_stream_id): + """Find a Channel + Stream by the XC provider's stream_id. + + XC clients address channels via the provider's `stream_id` (stored in + `Stream.custom_properties["stream_id"]`), not Dispatcharr's internal + `Channel.id`. Returns `(Channel, Stream)` on hit, `(None, None)` on miss. + """ + from apps.channels.models import Stream + + stream = ( + Stream.objects.filter( + custom_properties__stream_id=str(provider_stream_id), + m3u_account__account_type="XC", + ) + .select_related("m3u_account") + .first() + ) + if stream is None: + return None, None + channel = stream.channels.first() + if channel is None: + return None, None + return channel, stream + + +def get_channel_catchup_info(channel): + """Return catch-up info for a Channel, or None if no stream has tv_archive. + + Walks `channel.streams` in `channelstream__order`, returns a dict for the + first archive-enabled stream: `{stream, props, provider_stream_id, + tv_archive_duration}`. `tv_archive` may be stored as 1, "1", True or + "True" depending on the M3U importer that wrote it. + """ + for stream in channel.streams.order_by("channelstream__order"): + props = stream.custom_properties or {} + if str(props.get("tv_archive")) not in ("1", "True"): + continue + provider_stream_id = props.get("stream_id") + if not provider_stream_id: + continue + try: + archive_days = int(props.get("tv_archive_duration", 7) or 7) + except (TypeError, ValueError): + archive_days = 7 + return { + "stream": stream, + "props": props, + "provider_stream_id": str(provider_stream_id), + "tv_archive_duration": archive_days, + } + return None + + def increment_stream_count(account): with lock: current_usage = active_streams_map.get(account.id, 0) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index db6b1b6e..b48d3c48 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -951,6 +951,16 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, account_type='XC', stream_id=provider_stream_id ) + # Derive denormalized catch-up fields from the XC + # API response at import time so output views can + # read them as zero-cost column reads. + _tv_archive = str(stream.get("tv_archive", "0")) + _is_catchup = _tv_archive in ("1", "True") + try: + _catchup_days = int(stream.get("tv_archive_duration", 0) or 0) + except (TypeError, ValueError): + _catchup_days = 0 + stream_props = { "name": name, "url": url, @@ -964,6 +974,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "is_stale": False, "stream_id": provider_stream_id, "stream_chno": stream_chno, + "is_catchup": _is_catchup, + "catchup_days": _catchup_days, } if stream_hash not in stream_hashes: @@ -1030,7 +1042,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): # Simplified bulk update for better performance Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'is_catchup', 'catchup_days'], batch_size=150 # Smaller batch size for XC processing ) @@ -1176,6 +1188,16 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): account_type=account_type_for_hash, stream_id=provider_stream_id ) + # Derive catch-up fields from stream attributes (M3U files + # may carry tv_archive via custom attributes). + _attrs = stream_info["attributes"] + _tv_archive_m3u = str(_attrs.get("tv_archive", "0")) + _is_catchup_m3u = _tv_archive_m3u in ("1", "True") + try: + _catchup_days_m3u = int(_attrs.get("tv_archive_duration", 0) or 0) + except (TypeError, ValueError): + _catchup_days_m3u = 0 + stream_props = { "name": name, "url": url, @@ -1184,11 +1206,13 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "m3u_account": account, "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, - "custom_properties": {**stream_info["attributes"], "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else stream_info["attributes"], - "is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)), + "custom_properties": {**_attrs, "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else _attrs, + "is_adult": parse_is_adult(_attrs.get("is_adult", 0)), "is_stale": False, "stream_id": provider_stream_id, "stream_chno": channel_num, + "is_catchup": _is_catchup_m3u, + "catchup_days": _catchup_days_m3u, } if stream_hash not in stream_hashes: @@ -1254,7 +1278,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): # Update all streams in a single bulk operation Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'is_catchup', 'catchup_days'], batch_size=200 ) except Exception as e: @@ -2550,6 +2574,33 @@ def sync_auto_channels(account_id, scan_start_time=None): batch_size=500, ) + # Roll up denormalized catch-up fields from streams to + # channels. bulk_create bypasses Django signals, so this + # explicit SQL is the only path that fires during import. + from django.db import connection as _conn + with _conn.cursor() as _cur: + _cur.execute(""" + UPDATE dispatcharr_channels_channel c SET + is_catchup = EXISTS ( + SELECT 1 FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), + catchup_days = COALESCE(( + SELECT MAX(s.catchup_days) + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ), 0), + catchup_provider_stream_id = COALESCE(( + SELECT s.custom_properties->>'stream_id' + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ORDER BY cs."order" LIMIT 1 + ), '') + """) + if profiles_to_assign: ChannelProfileMembership.objects.bulk_create( [ diff --git a/apps/output/views.py b/apps/output/views.py index 1a73923d..131d98a4 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -2,7 +2,10 @@ from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidd import json from django.urls import reverse from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream -from apps.channels.utils import format_channel_number +from apps.channels.utils import ( + format_channel_number, + resolve_channel_by_provider_stream_id, +) from django.db.models import Prefetch from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -11,11 +14,12 @@ from apps.accounts.models import User from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone as dt_timezone +from zoneinfo import ZoneInfo import html import time from tzlocal import get_localzone -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse import base64 import logging from django.db.models.functions import Lower @@ -23,6 +27,7 @@ import os from apps.m3u.utils import calculate_tuner_count from apps.proxy.utils import get_user_active_connections import regex +from core.models import CoreSettings from core.utils import log_system_event import hashlib @@ -1315,11 +1320,31 @@ def generate_epg(request, profile_name=None, user=None): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - try: - prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) - prev_days = max(0, min(prev_days, 30)) - except (ValueError, TypeError): - prev_days = 0 + # Timeshift: prev_days resolution order: + # 1. URL ?prev_days= (explicit, even 0 means "no past") + # 2. user.custom_properties.epg_prev_days + # 3. CoreSettings.timeshift_settings.xmltv_prev_days_override (>0) + # 4. Auto-detect: max provider tv_archive_duration capped at 30 + url_prev = request.GET.get('prev_days') + user_prev = user_custom.get('epg_prev_days') if user_custom else None + if url_prev is not None: + try: + prev_days = max(0, min(int(url_prev), 30)) + except (ValueError, TypeError): + prev_days = 0 + elif user_prev not in (None, ""): + try: + prev_days = max(0, min(int(user_prev), 30)) + except (ValueError, TypeError): + prev_days = 0 + else: + from apps.timeshift.helpers import compute_provider_archive_days_capped + timeshift_settings = CoreSettings.get_timeshift_settings() + try: + override = int(timeshift_settings.get("xmltv_prev_days_override", 0) or 0) + except (TypeError, ValueError): + override = 0 + prev_days = max(0, min(override, 30)) if override > 0 else compute_provider_archive_days_capped() use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() cache_params = ( @@ -1975,6 +2000,34 @@ def _xc_allowed_output_formats(user): return ['ts', 'mp4'] +def _build_xc_server_info(request, hostname, port): + """Build the server_info dict for XC API responses. + + The timezone, time_now, and xc_get_epg start/end fields form a "timezone + triple" that MUST all use the same zone — XC clients (iPlayTV, TiviMate) + use server_info.timezone to interpret start/end strings and calculate seek + offsets into catch-up archives. A mismatch makes the wrong programme play. + Learned from plugin v1.1.4 → v1.2.6 (6 iterations of timezone bugs). + """ + tz_name = CoreSettings.get_timeshift_settings().get("default_timezone", "UTC") + try: + tz = ZoneInfo(tz_name) + except (KeyError, Exception): + # Invalid timezone in settings — fall back to UTC so XC clients + # can still connect instead of getting a 500 error. + tz = ZoneInfo("UTC") + tz_name = "UTC" + return { + "url": hostname, + "server_protocol": request.scheme, + "port": port, + "timezone": tz_name, + "timestamp_now": int(time.time()), + "time_now": datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S"), + "process": True, + } + + def xc_get_info(request, full=False): user = xc_get_user(request) @@ -2007,15 +2060,7 @@ def xc_get_info(request, full=False): "max_connections": str(max_connections), "allowed_output_formats": _xc_allowed_output_formats(user), }, - "server_info": { - "url": hostname, - "server_protocol": request.scheme, - "port": port, - "timezone": get_localzone().key, - "timestamp_now": int(time.time()), - "time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "process": True, - }, + "server_info": _build_xc_server_info(request, hostname, port), } if full == True: @@ -2141,7 +2186,70 @@ def xc_xmltv(request): ) return JsonResponse({'error': 'Unauthorized'}, status=401) - return generate_epg(request, None, user) + response = generate_epg(request, None, user) + return _convert_xmltv_to_local_timezone(response) + + +# Pre-compiled pattern for XMLTV timestamp attributes: `20251128143000 +0000`. +_XMLTV_TS_PATTERN = regex.compile(rb'(\d{14}) ([+-]\d{4})') + + +def _convert_xmltv_to_local_timezone(response): + """Rewrite UTC timestamps in an XMLTV streaming response to the configured + catch-up local time zone. + + Catch-up clients such as TiviMate display the wall-clock part of XMLTV + timestamps verbatim and ignore the trailing offset, so a programme + emitted as `start="20260512170000 +0000"` (correct UTC) ends up shown + as 17:00 even when the user is in Brussels and expects 19:00. Rewriting + the same instant as `start="20260512190000 +0200"` keeps the wall-clock + in the EPG aligned with the user's perception while the timestamp still + points at the same moment. + + The conversion runs only on the `/xmltv.php` catch-up endpoint — the + plain `/output/...epg.xml` consumers keep the UTC representation. + """ + timeshift_settings = CoreSettings.get_timeshift_settings() + tz_name = timeshift_settings.get('default_timezone', 'UTC') + try: + local_tz = ZoneInfo(tz_name) + except Exception: + return response + + # Use the builtin UTC, not ZoneInfo('UTC'), because the latter can pick + # up the host's mis-set /etc/timezone in some Docker setups. + # dt_timezone imported at module level + utc = dt_timezone.utc + + def _convert(match): + ts_bytes, _zone = match.group(1), match.group(2) + try: + ts_str = ts_bytes.decode('ascii') + utc_time = datetime.strptime(ts_str, '%Y%m%d%H%M%S').replace(tzinfo=utc) + local_time = utc_time.astimezone(local_tz) + return local_time.strftime('%Y%m%d%H%M%S %z').encode('ascii') + except Exception: + return match.group(0) + + # generate_epg may return either StreamingHttpResponse (large EPGs) or a + # plain HttpResponse (small EPGs or some error paths) — handle both. + if hasattr(response, 'streaming_content'): + source = response.streaming_content + else: + body = response.content + if isinstance(body, str): + body = body.encode('utf-8') + source = iter([body]) + + def _rewrite(): + for chunk in source: + if isinstance(chunk, str): + chunk = chunk.encode('utf-8') + if b'start="' in chunk or b'stop="' in chunk: + chunk = _XMLTV_TS_PATTERN.sub(_convert, chunk) + yield chunk + + return StreamingHttpResponse(_rewrite(), content_type=response.get('Content-Type', 'application/xml')) def xc_get_live_categories(user): @@ -2290,11 +2398,26 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url effective_logo = channel.effective_logo_obj effective_group = channel.effective_channel_group_obj group_id = effective_group.id if effective_group else _get_default_group_id() + + # Denormalized catch-up fields — populated at M3U/XC import time and + # rolled up via ChannelStream signal. Zero DB queries here. + if channel.is_catchup: + tv_archive = 1 + tv_archive_duration = channel.catchup_days + try: + stream_id_value = int(channel.catchup_provider_stream_id) + except (TypeError, ValueError): + stream_id_value = channel.id + else: + tv_archive = 0 + tv_archive_duration = 0 + stream_id_value = channel.id + return { "num": channel_num_int, "name": channel.effective_name, "stream_type": "live", - "stream_id": channel.id, + "stream_id": stream_id_value, "stream_icon": ( f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}" if effective_logo else None @@ -2304,10 +2427,10 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url "is_adult": int(channel.is_adult), "category_id": str(group_id), "category_ids": [group_id], - "custom_sid": None, - "tv_archive": 0, + "custom_sid": "", + "tv_archive": tv_archive, "direct_source": "", - "tv_archive_duration": 0, + "tv_archive_duration": tv_archive_duration, } @@ -2340,6 +2463,21 @@ def xc_get_epg(request, user, short=False): if not channel_id: raise Http404() + # Try internal Channel.id first (most requests). Fall back to provider + # stream_id lookup for catch-up channels whose xc_get_live_streams emits + # the provider's stream_id instead of the internal Channel.id. + resolved_channel_id = channel_id + try: + candidate = int(channel_id) + if Channel.objects.filter(id=candidate).exists(): + resolved_channel_id = candidate + else: + raise ValueError + except (TypeError, ValueError): + provider_channel, _ = resolve_channel_by_provider_stream_id(channel_id) + if provider_channel is not None: + resolved_channel_id = provider_channel.id + channel = None # Apply effective-value annotation + hidden-exclusion at every channel # resolution path so a single channel lookup honors the same visibility @@ -2354,7 +2492,7 @@ def xc_get_epg(request, user, short=False): if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level filters = { - "id": channel_id, + "id": resolved_channel_id, "user_level__lte": user.user_level } # Hide adult content if user preference is set @@ -2364,7 +2502,7 @@ def xc_get_epg(request, user, short=False): else: # User has specific limited profiles assigned filters = { - "id": channel_id, + "id": resolved_channel_id, "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() @@ -2377,7 +2515,7 @@ def xc_get_epg(request, user, short=False): if not channel: raise Http404() else: - channel = _annotate(Channel.objects.filter(id=channel_id).select_related('epg_data__epg_source')).first() + channel = _annotate(Channel.objects.filter(id=resolved_channel_id).select_related('epg_data__epg_source')).first() if not channel: raise Http404() @@ -2438,6 +2576,19 @@ def xc_get_epg(request, user, short=False): except (ValueError, TypeError): prev_days = 0 now = django_timezone.now() + + # When a channel supports catch-up, automatically include past programmes + # within the archive window even if the client did not request prev_days. + # XC clients (iPlayTV, TiviMate) call get_simple_data_table without + # prev_days and expect the response to already contain archived entries + # marked with has_archive=1 — they use that list to populate the catch-up + # programme menu. + # Use denormalized catch-up fields (zero DB queries). + _channel_is_catchup = getattr(channel, "is_catchup", False) + _channel_catchup_days = getattr(channel, "catchup_days", 0) + if _channel_is_catchup and prev_days == 0: + prev_days = _channel_catchup_days + lookback_cutoff = now - timedelta(days=prev_days) forward_cutoff = now + timedelta(days=num_days) if num_days > 0 else None effective_epg_data = channel.effective_epg_data_obj @@ -2483,6 +2634,26 @@ def xc_get_epg(request, user, short=False): output = {"epg_listings": []} + # Reuse the denormalized catch-up fields for the has_archive flag. + if _channel_is_catchup: + archive_window = timedelta(days=_channel_catchup_days) + else: + archive_window = None + + # start/end must be in the SAME timezone reported by server_info.timezone. + # XC clients display these strings verbatim AND use server_info.timezone + # to calculate seek offsets into catch-up archives. If these don't match, + # the wrong programme plays. Learned from plugin v1.1.4 → v1.2.6 (6 + # iterations of timezone bugs all converged on this rule). + # start_timestamp/stop_timestamp (epoch) stay in UTC — they're inherently + # timezone-agnostic and clients use them for their own conversions. + _ts_settings = CoreSettings.get_timeshift_settings() + _epg_tz_name = _ts_settings.get("default_timezone", "UTC") + try: + _epg_tz = ZoneInfo(_epg_tz_name) + except Exception: + _epg_tz = None + for program in programs: title = program['title'] if isinstance(program, dict) else program.title description = program['description'] if isinstance(program, dict) else program.description @@ -2507,8 +2678,8 @@ def xc_get_epg(request, user, short=False): "epg_id": epg_id, "title": base64.b64encode((title or "").encode()).decode(), "lang": "", - "start": start.strftime("%Y-%m-%d %H:%M:%S"), - "end": end.strftime("%Y-%m-%d %H:%M:%S"), + "start": start.astimezone(_epg_tz).strftime("%Y-%m-%d %H:%M:%S") if _epg_tz else start.strftime("%Y-%m-%d %H:%M:%S"), + "end": end.astimezone(_epg_tz).strftime("%Y-%m-%d %H:%M:%S") if _epg_tz else end.strftime("%Y-%m-%d %H:%M:%S"), "description": base64.b64encode((description or "").encode()).decode(), "channel_id": str(channel_num_int), "start_timestamp": str(int(start.timestamp())), @@ -2516,10 +2687,18 @@ def xc_get_epg(request, user, short=False): "stream_id": f"{channel_id}", } - if short == False: - program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 + # has_archive tells XC clients which past programmes are available + # for catch-up playback. Always emitted (not gated by short) because + # both get_simple_data_table and get_short_epg callers need it to + # populate the catch-up programme menu. + if archive_window is not None and end < now and end > now - archive_window: + program_output["has_archive"] = 1 + else: program_output["has_archive"] = 0 + if short == False: + program_output["now_playing"] = 1 if start <= now <= end else 0 + output['epg_listings'].append(program_output) return output diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 9bd1d284..300f3b0d 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -407,6 +407,20 @@ class ChannelStatus: if channel_name: info['channel_name'] = channel_name + info['is_timeshift'] = bool(metadata.get(ChannelMetadataField.IS_TIMESHIFT)) + + for key, field in ( + ('logo_id', ChannelMetadataField.LOGO_ID), + ('m3u_profile_id', ChannelMetadataField.M3U_PROFILE), + ): + raw = metadata.get(field) + if not raw: + continue + try: + info[key] = int(raw) + except (TypeError, ValueError): + pass + stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID) if stream_id_bytes: try: diff --git a/apps/proxy/live_proxy/constants.py b/apps/proxy/live_proxy/constants.py index b3839508..414e1a2f 100644 --- a/apps/proxy/live_proxy/constants.py +++ b/apps/proxy/live_proxy/constants.py @@ -54,6 +54,8 @@ class ChannelMetadataField: STREAM_ID = "stream_id" CHANNEL_NAME = "channel_name" STREAM_NAME = "stream_name" + IS_TIMESHIFT = "is_timeshift" + LOGO_ID = "logo_id" # Profile fields STREAM_PROFILE = "stream_profile" diff --git a/apps/proxy/live_proxy/input/http_streamer.py b/apps/proxy/live_proxy/input/http_streamer.py index 7efb5d33..da7f9894 100644 --- a/apps/proxy/live_proxy/input/http_streamer.py +++ b/apps/proxy/live_proxy/input/http_streamer.py @@ -1,161 +1,290 @@ """ HTTP Stream Reader - Thread-based HTTP stream reader that writes to a pipe. -This allows us to use the same fetch_chunk() path for both transcode and HTTP streams. +This allows us to use the same fetch_chunk() path for both transcode and HTTP +streams (live TV) and for timeshift/catch-up archives. + +When *response* is supplied the reader skips connection setup and streams the +already-open ``requests.Response`` directly. This is used by the timeshift +view which needs to cascade through multiple candidate URLs before handing +the winning response to the reader. + +When *strip_ts_preamble* is True the first bytes written to the pipe are +aligned to the first MPEG-TS sync chain (0x47 at 188-byte intervals). Some +XC servers emit PHP warnings or HTML error text before the binary stream; +strict demuxers (ExoPlayer / TiviMate) raise ``UnrecognizedInputFormat`` on +those bytes. The live path typically receives clean TS so the flag defaults +to False, but the timeshift path enables it. """ -import threading +import fcntl import os +import select as _select +import threading + import requests from requests.adapters import HTTPAdapter + from ..utils import get_logger logger = get_logger() +# MPEG-TS sync constants (shared with timeshift views) +_TS_PACKET_SIZE = 188 +_TS_SYNC_BYTE = 0x47 +_TS_SYNC_SEARCH_LIMIT = 65536 + +# Linux fcntl constant — increase pipe buffer beyond the 64 KB default. +_F_SETPIPE_SZ = 1031 +_PIPE_TARGET_SIZE = 1 << 20 # 1 MB + + +def find_ts_sync(buf): + """Offset of the first MPEG-TS sync chain in *buf*, or -1. + + A valid chain needs 0x47 at offsets i, i+188 and i+376 — three sync + bytes one packet apart (the standard demuxer probe). + """ + end = len(buf) - 2 * _TS_PACKET_SIZE + for i in range(0, end): + if ( + buf[i] == _TS_SYNC_BYTE + and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE + and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE + ): + return i + return -1 + class HTTPStreamReader: - """Thread-based HTTP stream reader that writes to a pipe""" + """Thread-based HTTP stream reader that writes to an OS pipe. - def __init__(self, url, user_agent=None, chunk_size=8192): + Parameters + ---------- + url : str + Provider URL (used when *response* is None to open a new connection). + user_agent : str, optional + User-Agent header for the upstream request. + chunk_size : int + Chunk size for ``iter_content`` and ``pipe_reader.read``. + response : requests.Response, optional + An already-open streaming response. When provided the reader skips + connection setup and streams this response directly. The caller is + responsible for having opened it with ``stream=True``. + extra_headers : dict, optional + Additional HTTP headers (e.g. Range) forwarded to the provider when + *response* is None. + strip_ts_preamble : bool + Strip non-MPEG-TS bytes before the first 0x47 sync chain. + """ + + def __init__( + self, + url, + user_agent=None, + chunk_size=8192, + *, + response=None, + extra_headers=None, + strip_ts_preamble=False, + ): self.url = url self.user_agent = user_agent self.chunk_size = chunk_size + self._provided_response = response + self._extra_headers = extra_headers or {} + self._strip_ts_preamble = strip_ts_preamble self.session = None - self.response = None + self.response = response # may be pre-set self.thread = None self.pipe_read = None self.pipe_write = None self.running = False def start(self): - """Start the HTTP stream reader thread""" + """Start the HTTP stream reader thread.""" self.pipe_read, self.pipe_write = os.pipe() - # Make the write end non-blocking so that os.write() raises BlockingIOError - # instead of stalling the OS thread when the pipe buffer is full. Without - # this, a full pipe blocks the entire gevent worker (all greenlets freeze) - # because gevent does not patch os.write() on pipes. - import fcntl - flags = fcntl.fcntl(self.pipe_write, fcntl.F_GETFL) - fcntl.fcntl(self.pipe_write, fcntl.F_SETFL, flags | os.O_NONBLOCK) + # Grow the kernel pipe buffer from 64 KB to 1 MB so the producer + # thread is not blocked while the consumer yields to uWSGI. This + # is the single biggest throughput improvement for the timeshift + # path: it turns the producer/consumer from ping-pong alternation + # into genuinely parallel operation. + try: + fcntl.fcntl(self.pipe_write, _F_SETPIPE_SZ, _PIPE_TARGET_SIZE) + except (OSError, ValueError): + pass # non-Linux or unprivileged — keep 64 KB default + + # Make the write end non-blocking so that os.write() raises + # BlockingIOError instead of stalling the OS thread when the pipe + # buffer is full. Without this, a full pipe blocks the entire gevent + # worker (all greenlets freeze) because gevent does not patch + # os.write() on pipes. + # Skip for pre-opened responses (timeshift path) — those run in a + # real OS thread where blocking writes are fine and the 1 MB pipe + # buffer provides sufficient decoupling. + if self._provided_response is None: + flags = fcntl.fcntl(self.pipe_write, fcntl.F_GETFL) + fcntl.fcntl(self.pipe_write, fcntl.F_SETFL, flags | os.O_NONBLOCK) self.running = True self.thread = threading.Thread(target=self._read_stream, daemon=True) self.thread.start() - logger.info(f"Started HTTP stream reader thread for {self.url}") + logger.info("Started HTTP stream reader thread for %s", self.url) return self.pipe_read + # ------------------------------------------------------------------ + # Non-blocking pipe write helper + # ------------------------------------------------------------------ + + def _pipe_write_all(self, data): + """Write *data* to the pipe, handling O_NONBLOCK + partial writes. + + Uses select() on the write fd (gevent-patched — yields to hub) to + wait for space when the pipe is full, instead of blocking the OS + thread. + """ + offset = 0 + while offset < len(data) and self.running: + try: + n = os.write(self.pipe_write, data[offset:]) + offset += n + except BlockingIOError: + _, writable, _ = _select.select([], [self.pipe_write], [], 1.0) + if not writable and not self.running: + return False + except (BrokenPipeError, OSError): + return False + return True + + # ------------------------------------------------------------------ + # Producer thread + # ------------------------------------------------------------------ + def _read_stream(self): - """Thread worker that reads HTTP stream and writes to pipe""" + """Thread worker: read HTTP stream → write to pipe.""" try: - # Build headers - headers = {} - if self.user_agent: - headers['User-Agent'] = self.user_agent + if self._provided_response is not None: + # Re-use the already-open response (timeshift cascade path). + self.response = self._provided_response + else: + # Open a fresh connection (live TV path). + headers = dict(self._extra_headers) + if self.user_agent: + headers["User-Agent"] = self.user_agent - logger.info(f"HTTP reader connecting to {self.url}") + logger.info("HTTP reader connecting to %s", self.url) - # Create session - self.session = requests.Session() + self.session = requests.Session() + adapter = HTTPAdapter( + max_retries=0, pool_connections=1, pool_maxsize=1, + ) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) - # Disable retries for faster failure detection - adapter = HTTPAdapter(max_retries=0, pool_connections=1, pool_maxsize=1) - self.session.mount('http://', adapter) - self.session.mount('https://', adapter) + self.response = self.session.get( + self.url, + headers=headers, + stream=True, + timeout=(5, 30), + ) - # Stream the URL - self.response = self.session.get( - self.url, - headers=headers, - stream=True, - timeout=(5, 30) # 5s connect, 30s read - ) + if self.response.status_code not in (200, 206): + logger.error( + "HTTP %d from %s", self.response.status_code, self.url, + ) + return - if self.response.status_code != 200: - logger.error(f"HTTP {self.response.status_code} from {self.url}") - return + logger.info("HTTP reader connected, streaming data…") - logger.info(f"HTTP reader connected successfully, streaming data...") + # Optional TS preamble stripping — find the first MPEG-TS sync + # chain and discard everything before it. + synced = not self._strip_ts_preamble + sync_buf = bytearray() if not synced else None - import select as _select - - # Stream chunks to pipe chunk_count = 0 for chunk in self.response.iter_content(chunk_size=self.chunk_size): if not self.running: break + if not chunk: + continue - if chunk: - # Write the chunk in a non-blocking loop. The pipe write end is - # set O_NONBLOCK in start(), so os.write() raises BlockingIOError - # instead of stalling the OS thread. We use select.select on the - # write fd (gevent-patched - yields to hub) to wait for space, - # then retry. Partial writes are handled by advancing the offset. - offset = 0 - write_error = False - while offset < len(chunk) and self.running: - try: - n = os.write(self.pipe_write, chunk[offset:]) - offset += n - except BlockingIOError: - _, writable, _ = _select.select([], [self.pipe_write], [], 1.0) - if not writable and not self.running: - write_error = True - break - except OSError as e: - logger.error(f"Pipe write error: {e}") - write_error = True - break - if write_error: - break + # ---- TS preamble strip logic ---- + if not synced: + sync_buf.extend(chunk) + if len(sync_buf) < 2 * _TS_PACKET_SIZE + 1: + continue + offset = find_ts_sync(sync_buf) + if offset >= 0: + chunk = bytes(sync_buf[offset:]) + synced = True + sync_buf = None + elif len(sync_buf) >= _TS_SYNC_SEARCH_LIMIT: + logger.warning( + "Upstream produced %d bytes without TS sync " + "chain; passing through unmodified", + len(sync_buf), + ) + chunk = bytes(sync_buf) + synced = True + sync_buf = None + else: + continue - chunk_count += 1 - if chunk_count % 1000 == 0: - logger.debug(f"HTTP reader streamed {chunk_count} chunks") + # ---- Non-blocking write to pipe ---- + if not self._pipe_write_all(chunk): + break + + chunk_count += 1 + if chunk_count % 1000 == 0: + logger.debug("HTTP reader streamed %d chunks", chunk_count) + + # Flush any remaining sync_buf that never found a chain. + if not synced and sync_buf: + self._pipe_write_all(bytes(sync_buf)) logger.info("HTTP stream ended") - except requests.exceptions.RequestException as e: - logger.error(f"HTTP reader request error: {e}") - except Exception as e: - logger.error(f"HTTP reader unexpected error: {e}", exc_info=True) + except requests.exceptions.RequestException as exc: + logger.error("HTTP reader request error: %s", exc) + except Exception as exc: + logger.error("HTTP reader unexpected error: %s", exc, exc_info=True) finally: self.running = False - # Close write end of pipe to signal EOF try: if self.pipe_write is not None: os.close(self.pipe_write) self.pipe_write = None - except: + except OSError: pass + # ------------------------------------------------------------------ + # Shutdown + # ------------------------------------------------------------------ + def stop(self): - """Stop the HTTP stream reader""" + """Stop the HTTP stream reader.""" logger.info("Stopping HTTP stream reader") self.running = False - # Close response if self.response: try: self.response.close() - except: + except Exception: pass - # Close session if self.session: try: self.session.close() - except: + except Exception: pass - # Close write end of pipe if self.pipe_write is not None: try: os.close(self.pipe_write) self.pipe_write = None - except: + except OSError: pass - # Wait for thread if self.thread and self.thread.is_alive(): self.thread.join(timeout=2.0) diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index 879de2ec..14157a9c 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -1807,6 +1807,15 @@ class ProxyServer: try: channel_id = key.split(':')[2] + # Skip channel ids that don't follow Dispatcharr's UUID + # convention. Those are managed by features outside of + # ProxyServer (e.g. timeshift catch-up sessions use a + # `timeshift___` virtual id and are + # owned by the WSGI request that opened them — they + # expire naturally via the clients-set TTL). + if channel_id.startswith("timeshift_"): + continue + # Get metadata first metadata = self.redis_client.hgetall(key) if not metadata: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index d882bfca..dc42845c 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -601,6 +601,25 @@ def stream_xc(request, username, password, channel_id): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) + # Resolve the channel id. Try the internal Channel.id first (most + # requests). Fall back to provider stream_id lookup for catch-up + # channels whose xc_get_live_streams emits the provider's stream_id. + from apps.channels.utils import resolve_channel_by_provider_stream_id + resolved_internal_id = None + try: + candidate = int(channel_id) + if Channel.objects.filter(id=candidate).exists(): + resolved_internal_id = candidate + except (TypeError, ValueError): + pass + + if resolved_internal_id is None: + provider_channel, _ = resolve_channel_by_provider_stream_id(str(channel_id)) + if provider_channel is not None: + resolved_internal_id = provider_channel.id + else: + return JsonResponse({"error": "Not found"}, status=404) + if user.user_level < 10: user_profile_count = user.channel_profiles.count() @@ -608,14 +627,14 @@ def stream_xc(request, username, password, channel_id): if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level filters = { - "id": int(channel_id), + "id": resolved_internal_id, "user_level__lte": user.user_level } channel = Channel.objects.filter(**filters).first() else: # User has specific limited profiles assigned filters = { - "id": int(channel_id), + "id": resolved_internal_id, "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() @@ -625,7 +644,7 @@ def stream_xc(request, username, password, channel_id): if not channel: return JsonResponse({"error": "Not found"}, status=404) else: - channel = get_object_or_404(Channel, id=channel_id) + channel = get_object_or_404(Channel, id=resolved_internal_id) if extension.lower() == '.mp4': force_format = 'fmp4' diff --git a/apps/timeshift/__init__.py b/apps/timeshift/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timeshift/apps.py b/apps/timeshift/apps.py new file mode 100644 index 00000000..e4ecbfba --- /dev/null +++ b/apps/timeshift/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TimeshiftConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.timeshift" + verbose_name = "Timeshift" diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py new file mode 100644 index 00000000..97d32b95 --- /dev/null +++ b/apps/timeshift/helpers.py @@ -0,0 +1,116 @@ +"""URL builders + timestamp conversion + archive-days probe for XC catch-up.""" + +import logging +from datetime import datetime + +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +DEFAULT_DURATION_MINUTES = 120 +DURATION_BUFFER_MINUTES = 5 +MAX_DURATION_MINUTES = 480 + +PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300 +MAX_AUTO_PREV_DAYS = 30 + + +def compute_provider_archive_days_capped(): + """Largest `tv_archive_duration` advertised by any XC stream (capped, cached). + + Returns 0 when no XC stream advertises catch-up. + """ + def _scan(): + from apps.channels.models import Stream + + max_days = 0 + for props in ( + Stream.objects.filter(m3u_account__account_type="XC") + .exclude(custom_properties__isnull=True) + .values_list("custom_properties", flat=True) + ): + try: + if not int((props or {}).get("tv_archive", 0) or 0): + continue + value = int((props or {}).get("tv_archive_duration", 0) or 0) + except (TypeError, ValueError): + continue + if value > max_days: + max_days = value + return min(max_days, MAX_AUTO_PREV_DAYS) + + return cache.get_or_set( + "timeshift:provider_archive_days_capped", + _scan, + PROVIDER_ARCHIVE_CACHE_TTL_SECONDS, + ) + + +def get_programme_duration(channel, timestamp_str): + """Duration in minutes of the EPG programme starting at `timestamp_str`. + + `timestamp_str` is `YYYY-MM-DD:HH-MM` in UTC (passed through from the + client URL unchanged — no timezone conversion). Falls back to a + 120-minute default if EPG lookup fails. + """ + try: + dt = datetime.strptime(timestamp_str, "%Y-%m-%d:%H-%M") + if not channel.epg_data: + return DEFAULT_DURATION_MINUTES + + programme = channel.epg_data.programs.filter( + start_time__lte=dt, end_time__gt=dt + ).first() + if not programme: + return DEFAULT_DURATION_MINUTES + + duration_seconds = (programme.end_time - programme.start_time).total_seconds() + duration_minutes = int(duration_seconds / 60) + DURATION_BUFFER_MINUTES + return min(duration_minutes, MAX_DURATION_MINUTES) + except Exception: + return DEFAULT_DURATION_MINUTES + + +def build_timeshift_url_format_a(m3u_account, stream_id, timestamp, duration_minutes): + """Format A: `/streaming/timeshift.php?username=&password=&stream=&start=&duration=`.""" + return ( + f"{m3u_account.server_url.rstrip('/')}/streaming/timeshift.php" + f"?username={m3u_account.username}" + f"&password={m3u_account.password}" + f"&stream={stream_id}" + f"&start={timestamp}" + f"&duration={duration_minutes}" + ) + + +def build_timeshift_url_format_b(m3u_account, stream_id, timestamp, duration_minutes): + """Format B: `/timeshift/{user}/{pass}/{duration}/{timestamp}/{stream_id}.ts`.""" + return ( + f"{m3u_account.server_url.rstrip('/')}/timeshift" + f"/{m3u_account.username}" + f"/{m3u_account.password}" + f"/{duration_minutes}" + f"/{timestamp}" + f"/{stream_id}.ts" + ) + + +def format_timestamp_as_sql_datetime(timestamp): + """Reshape `YYYY-MM-DD:HH-MM` to `YYYY-MM-DD HH:MM:SS` without any + timezone conversion. + + Some XC servers refuse the dash-only shape for archives whose recording + is still being finalised and only resolve the SQL-datetime shape. This + function changes only the format — the timestamp value stays in whatever + zone the caller supplied (typically UTC, since clients derive it from the + UTC epoch in the EPG data). + + Do NOT add timezone conversion here — that was the root cause of the + "wrong programme plays" bug (plugin v1.1.4 → v1.2.6 history). + """ + try: + dt = datetime.strptime(timestamp, "%Y-%m-%d:%H-%M") + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception as e: + logger.error("Timeshift SQL timestamp reshape failed for %r: %s", timestamp, e) + return timestamp diff --git a/apps/timeshift/tests/__init__.py b/apps/timeshift/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py new file mode 100644 index 00000000..13d3e0a9 --- /dev/null +++ b/apps/timeshift/tests/test_helpers.py @@ -0,0 +1,56 @@ +"""Tests for `apps.timeshift.helpers` — timestamp shape conversion and URL build.""" + +from django.test import TestCase + +from apps.timeshift.helpers import ( + build_timeshift_url_format_a, + build_timeshift_url_format_b, + format_timestamp_as_sql_datetime, +) + + +class _FakeAccount: + def __init__(self): + self.server_url = "http://example.test" + self.username = "user" + self.password = "pass" + + +class TimestampFormatTests(TestCase): + """The SQL-datetime shape is needed for some XC server parsers. The + reshape function changes format only — no timezone conversion.""" + + def test_format_sql_reshapes_without_tz_conversion(self): + # Must reshape only — no timezone shift. The value stays as-is. + self.assertEqual( + format_timestamp_as_sql_datetime("2026-05-12:17-00"), + "2026-05-12 17:00:00", + ) + + def test_format_sql_invalid_falls_back(self): + self.assertEqual(format_timestamp_as_sql_datetime("garbage"), "garbage") + + +class BuildTimeshiftUrlTests(TestCase): + def setUp(self): + self.account = _FakeAccount() + + def test_format_a_passes_dash_shape_unchanged(self): + url = build_timeshift_url_format_a( + self.account, "22372", "2026-05-12:19-00", 40 + ) + self.assertIn("start=2026-05-12:19-00", url) + self.assertIn("stream=22372", url) + self.assertIn("duration=40", url) + + def test_format_a_passes_sql_shape_unchanged(self): + url = build_timeshift_url_format_a( + self.account, "22372", "2026-05-12 19:00:00", 40 + ) + self.assertIn("start=2026-05-12 19:00:00", url) + + def test_format_b_path_with_dash_shape(self): + url = build_timeshift_url_format_b( + self.account, "22372", "2026-05-12:19-00", 40 + ) + self.assertIn("/40/2026-05-12:19-00/22372.ts", url) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py new file mode 100644 index 00000000..b8daf30a --- /dev/null +++ b/apps/timeshift/tests/test_views.py @@ -0,0 +1,177 @@ +"""Tests for the timeshift proxy view, focused on upstream status mapping.""" + +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase + +from apps.timeshift import views +from apps.proxy.live_proxy.input.http_streamer import find_ts_sync as _find_ts_sync, _TS_PACKET_SIZE + + +class FindTsSyncTests(TestCase): + """Locate the first MPEG-TS sync chain so a leading HTML/PHP preamble + can be skipped before the bytes reach a strict demuxer (ExoPlayer).""" + + def test_returns_zero_when_buffer_already_aligned(self): + buf = b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + self.assertEqual(_find_ts_sync(buf), 0) + + def test_returns_offset_of_first_chain_after_preamble(self): + preamble = b"
\nWarning" + aligned = b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + self.assertEqual(_find_ts_sync(preamble + aligned), len(preamble)) + + def test_returns_minus_one_when_no_chain_exists(self): + # Three lone 0x47 bytes that are NOT spaced at 188 — must not be + # mistaken for a sync chain. + self.assertEqual(_find_ts_sync(b"\x47\x00\x00\x47\x00\x00\x47" * 50), -1) + + def test_returns_minus_one_for_short_buffer(self): + self.assertEqual(_find_ts_sync(b"\x47" * 10), -1) + + +class _FakeReader: + """Stand-in for HTTPStreamReader that yields EOF immediately via a pipe.""" + + def __init__(self, *args, **kwargs): + self.pipe_read = None + self.pipe_write = None + + def start(self): + import os + self.pipe_read, self.pipe_write = os.pipe() + os.close(self.pipe_write) # EOF immediately + self.pipe_write = None + return self.pipe_read + + def stop(self): + pass + + +def _fake_upstream(status_code, *, content_type="video/mp2t", body=b""): + resp = MagicMock() + resp.status_code = status_code + resp.headers = {"Content-Type": content_type} + resp.iter_content = MagicMock(return_value=iter([body] if body else [])) + resp.close = MagicMock() + return resp + + +class StreamFromProviderStatusMappingTests(TestCase): + """`_stream_from_provider` must translate upstream HTTP status codes into + semantically correct Django responses so downstream IPTV clients react + the right way (notably: stop retrying on 404).""" + + def setUp(self): + self.factory = RequestFactory() + self.kwargs = dict( + candidate_urls=[ + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12:17-00", + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12 17:00:00", + "http://example.test/timeshift/u/p/60/2026-05-12:17-00/1.ts", + ], + user_agent="test-agent", + range_header=None, + virtual_channel_id="timeshift_1_2026-05-12-17-00_1", + client_id="timeshift_test123", + client_ip="127.0.0.1", + user=None, + channel_display_name="Test", + timestamp_utc="2026-05-12:17-00", + channel_logo_id=None, + m3u_profile_id=None, + debug=False, + ) + + @patch.object(views, "_open_upstream") + def test_all_candidates_404_returns_404(self, mocked_open): + mocked_open.return_value = _fake_upstream(404) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 404) + # Every candidate is attempted before giving up. + self.assertEqual(mocked_open.call_count, 3) + + @patch.object(views, "_open_upstream") + def test_upstream_403_short_circuits_loop(self, mocked_open): + # 403 is decisive (auth) — no retry of further candidates. + mocked_open.return_value = _fake_upstream(403) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 403) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_upstream_500_short_circuits_loop(self, mocked_open): + mocked_open.return_value = _fake_upstream(500) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_first_candidate_succeeds(self, mocked_open): + mocked_open.side_effect = [_fake_upstream(200, body=b"\x47" * 188)] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "HTTPStreamReader", _FakeReader): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_second_candidate_succeeds_after_404(self, mocked_open): + # Primary 404 → second candidate 200 → streams successfully. + mocked_open.side_effect = [ + _fake_upstream(404), + _fake_upstream(200, body=b"\x47" * 188), + ] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "HTTPStreamReader", _FakeReader): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_third_candidate_succeeds_after_400_then_404(self, mocked_open): + mocked_open.side_effect = [ + _fake_upstream(400), + _fake_upstream(404), + _fake_upstream(200, body=b"\x47" * 188), + ] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "HTTPStreamReader", _FakeReader): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 3) + + @patch.object(views, "_open_upstream") + def test_cache_promotes_winning_index_to_first(self, mocked_open): + """Once a candidate succeeds for an account, the next request reorders + the list so the cached winner is tried first — saving cascade + overhead on fast-forward.""" + # First request: candidate index 1 wins after index 0 returns 404. + mocked_open.side_effect = [ + _fake_upstream(404), + _fake_upstream(200, body=b"\x47" * 188), + ] + kwargs = dict(self.kwargs, account_id=999) + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "HTTPStreamReader", _FakeReader): + r1 = views._stream_from_provider(**kwargs) + self.assertEqual(r1.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + + # Second request: cached winner (index 1) is tried first, succeeds + # immediately — no cascade. + mocked_open.reset_mock() + mocked_open.side_effect = [_fake_upstream(200, body=b"\x47" * 188)] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "HTTPStreamReader", _FakeReader): + r2 = views._stream_from_provider(**kwargs) + self.assertEqual(r2.status_code, 200) + self.assertEqual(mocked_open.call_count, 1) + # Confirm the URL used is the SQL-datetime candidate (index 1 in the + # original list set up in setUp), not the dash-only one (index 0). + self.assertIn("17:00:00", mocked_open.call_args_list[0][0][0]) diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py new file mode 100644 index 00000000..87345164 --- /dev/null +++ b/apps/timeshift/views.py @@ -0,0 +1,512 @@ +"""XC catch-up (timeshift) HTTP view — proxies the provider's +`/streaming/timeshift.php` endpoint to clients like iPlayTV and TiviMate. + +URL parameter quirk matching what iPlayTV / TiviMate emit: + Position "stream_id" -> EPG channel number, IGNORED here. + Position "duration" -> actually the provider's stream_id. +""" + +import hmac +import logging +import os +import threading +import time +import uuid + +import requests +from django.http import ( + Http404, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + StreamingHttpResponse, +) + +from apps.accounts.models import User +from apps.channels.utils import ( + get_channel_catchup_info, + resolve_channel_by_provider_stream_id, +) +from apps.proxy.live_proxy.config_helper import ConfigHelper +from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState +from apps.proxy.live_proxy.redis_keys import RedisKeys +from apps.proxy.live_proxy.utils import get_client_ip +from apps.proxy.live_proxy.input.http_streamer import HTTPStreamReader +from core.models import CoreSettings +from core.utils import RedisClient + +from .helpers import ( + build_timeshift_url_format_a, + build_timeshift_url_format_b, + format_timestamp_as_sql_datetime, + get_programme_duration, +) + +logger = logging.getLogger(__name__) + +CLIENT_TTL_SECONDS = 60 + + +def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id + # The "duration" URL slot is the provider's stream_id — see module docstring. + provider_stream_id = duration[:-3] if duration.endswith(".ts") else duration + + user = _authenticate_user(username, password) + if user is None: + return HttpResponseForbidden("Invalid credentials") + + channel, _ = resolve_channel_by_provider_stream_id(provider_stream_id) + if channel is None: + raise Http404("Channel not found") + + if not _user_can_access_channel(user, channel): + return HttpResponseForbidden("Access denied") + + catchup = get_channel_catchup_info(channel) + if catchup is None: + return HttpResponseBadRequest("Timeshift not supported for this channel") + + catchup_stream = catchup["stream"] + props = catchup["props"] + m3u_account = catchup_stream.m3u_account + if m3u_account is None or m3u_account.account_type != "XC": + return HttpResponseBadRequest("Channel not from Xtream Codes provider") + + timeshift_settings = CoreSettings.get_timeshift_settings() + debug = bool(timeshift_settings.get("debug_logging", False)) + + # The client-supplied timestamp is already in UTC (derived from the + # start_timestamp epoch in the EPG data). Pass it through to the + # provider as-is — no timezone conversion. Converting here was the + # root cause of the "wrong programme plays" bug: the provider indexes + # archives in UTC, so shifting to Brussels meant requesting a programme + # 1-2 hours later than intended. + # Learned from plugin history v1.1.4 → v1.2.6: 6 iterations of timezone + # bugs all converged on "never convert the provider URL timestamp". + local_timestamp = timestamp + sql_timestamp = format_timestamp_as_sql_datetime(timestamp) + duration_minutes = get_programme_duration(channel, local_timestamp) + stream_id_value = (props or {}).get("stream_id") + if stream_id_value is None: + return HttpResponseBadRequest("Cannot build timeshift URL") + + # Build a small set of upstream candidates. Different XC servers (or even + # different code paths inside the same server) accept different timestamp + # shapes and different URL layouts: the dash-only shape covers most + # finalised archives, the SQL-datetime shape unlocks a separate path that + # some servers use for archives still being indexed, and the path layout + # (Format B) is the only one some Stalker-style portals expose. We try + # them in order until one accepts the request — see `_stream_from_provider` + # for the iteration logic. + candidate_urls = [ + build_timeshift_url_format_a(m3u_account, stream_id_value, local_timestamp, duration_minutes), + build_timeshift_url_format_a(m3u_account, stream_id_value, sql_timestamp, duration_minutes), + build_timeshift_url_format_b(m3u_account, stream_id_value, local_timestamp, duration_minutes), + ] + + try: + user_agent = m3u_account.get_user_agent().user_agent + except AttributeError: + user_agent = "" + + safe_ts = timestamp.replace(":", "-").replace("/", "-") + virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{provider_stream_id}" + client_id = f"timeshift_{uuid.uuid4().hex[:16]}" + client_ip = get_client_ip(request) + range_header = request.META.get("HTTP_RANGE") + + # Resolve channel logo + M3U default profile so the Stats card can render + # the same logo + M3U profile name as for live streams. + channel_logo_id = getattr(channel, "logo_id", None) + default_profile = m3u_account.profiles.filter(is_default=True).first() + m3u_profile_id = default_profile.id if default_profile is not None else None + + # If the user is switching from live TV to timeshift on the same channel, + # the live stream still holds the provider connection (~1s teardown). + # Force-stop it synchronously before opening the timeshift upstream so + # the provider slot is free. No-op if no live stream is active. + from apps.proxy.live_proxy.services.channel_service import ChannelService + ChannelService.stop_channel(str(channel.uuid)) + + if debug: + logger.info( + "Timeshift request: channel=%s ts=%s provider_sid=%s vid=%s client=%s range=%s", + channel.name, + local_timestamp, + provider_stream_id, + virtual_channel_id, + client_id, + range_header or "(none)", + ) + + return _stream_from_provider( + candidate_urls=candidate_urls, + user_agent=user_agent, + range_header=range_header, + virtual_channel_id=virtual_channel_id, + client_id=client_id, + client_ip=client_ip, + user=user, + channel_display_name=channel.name, + timestamp_utc=timestamp, + channel_logo_id=channel_logo_id, + m3u_profile_id=m3u_profile_id, + debug=debug, + account_id=m3u_account.id, + ) + + +# --------------------------------------------------------------------------- +# Authentication, lookup, access control +# --------------------------------------------------------------------------- + + +def _authenticate_user(username, password): + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return None + expected = (user.custom_properties or {}).get("xc_password") + if not expected: + return None + if not hmac.compare_digest(str(expected), str(password)): + return None + return user + + +def _user_can_access_channel(user, channel): + if user.user_level < channel.user_level: + return False + if user.user_level >= User.UserLevel.ADMIN: + return True + profile_count = user.channel_profiles.count() + if profile_count == 0: + return True + return ( + type(channel).objects.filter( + id=channel.id, + channelprofilemembership__enabled=True, + channelprofilemembership__channel_profile__in=user.channel_profiles.all(), + ) + .exists() + ) + + +# --------------------------------------------------------------------------- +# Stats integration (direct Redis writes, no ClientManager instance) +# --------------------------------------------------------------------------- + + +def _register_stats_client( + redis_client, + virtual_channel_id, + client_id, + client_ip, + user_agent, + user, + *, + channel_display_name, + timestamp_utc, + primary_url, + channel_logo_id=None, + m3u_profile_id=None, +): + """Write the same Redis keys the live proxy's ClientManager writes so the + catch-up viewer appears on `/stats`. `is_timeshift=1` toggles the badge.""" + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + now = str(time.time()) + client_payload = { + "user_agent": user_agent or "unknown", + "ip_address": client_ip, + "connected_at": now, + "last_active": now, + "user_id": str(user.id) if user is not None else "0", + "username": user.username if user is not None else "unknown", + } + metadata_payload = { + ChannelMetadataField.STATE: ChannelState.ACTIVE, + ChannelMetadataField.INIT_TIME: now, + ChannelMetadataField.OWNER: "timeshift", + ChannelMetadataField.CHANNEL_NAME: channel_display_name or "Timeshift", + ChannelMetadataField.STREAM_NAME: f"Catch-up @ {timestamp_utc} UTC" if timestamp_utc else "Catch-up", + ChannelMetadataField.URL: _redact_url(primary_url) if primary_url else "", + ChannelMetadataField.IS_TIMESHIFT: "1", + } + if channel_logo_id is not None: + metadata_payload[ChannelMetadataField.LOGO_ID] = str(channel_logo_id) + if m3u_profile_id is not None: + metadata_payload[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, mapping=client_payload) + pipe.expire(client_key, CLIENT_TTL_SECONDS) + pipe.sadd(client_set_key, client_id) + pipe.expire(client_set_key, CLIENT_TTL_SECONDS) + pipe.hset(metadata_key, mapping=metadata_payload) + pipe.expire(metadata_key, CLIENT_TTL_SECONDS) + pipe.execute() + except Exception as exc: + logger.warning("Timeshift stats register failed: %s", exc) + + +def _heartbeat_stats_client(redis_client, virtual_channel_id, client_id, bytes_delta=0): + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, "last_active", str(time.time())) + pipe.expire(client_key, CLIENT_TTL_SECONDS) + pipe.expire(client_set_key, CLIENT_TTL_SECONDS) + if bytes_delta > 0: + pipe.hincrby(metadata_key, ChannelMetadataField.TOTAL_BYTES, bytes_delta) + pipe.expire(metadata_key, CLIENT_TTL_SECONDS) + pipe.execute() + except Exception: + pass + + +def _unregister_stats_client(redis_client, virtual_channel_id, client_id): + if redis_client is None: + return + client_set_key = RedisKeys.clients(virtual_channel_id) + client_key = RedisKeys.client_metadata(virtual_channel_id, client_id) + metadata_key = RedisKeys.channel_metadata(virtual_channel_id) + try: + redis_client.srem(client_set_key, client_id) + redis_client.delete(client_key) + if (redis_client.scard(client_set_key) or 0) == 0: + redis_client.delete(client_set_key) + redis_client.delete(metadata_key) + except Exception as exc: + logger.warning("Timeshift stats unregister failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Provider streaming +# --------------------------------------------------------------------------- + + + +def _open_upstream(url, user_agent, range_header): + """Open the upstream HTTP request (status + headers known synchronously).""" + headers = {} + if user_agent: + headers["User-Agent"] = user_agent + if range_header: + headers["Range"] = range_header + return requests.get( + url, + headers=headers, + stream=True, + timeout=ConfigHelper.connection_timeout(), + ) + + +_format_cache_lock = threading.Lock() +_format_cache = {} + + +def _get_cached_format_index(account_id): + """Return the index of the URL shape that last worked for this account, + or None if we haven't seen one succeed yet. + + Caching the winning shape lets a fast-forward session — where the player + reissues GETs at one-second intervals as the user scrubs — skip the + cascade after the first successful candidate and go straight to the + working URL on every subsequent request. + """ + if account_id is None: + return None + with _format_cache_lock: + return _format_cache.get(account_id) + + +def _set_cached_format_index(account_id, index): + if account_id is None: + return + with _format_cache_lock: + _format_cache[account_id] = index + + +def _stream_from_provider( + *, + candidate_urls, + user_agent, + range_header, + virtual_channel_id, + client_id, + client_ip, + user, + channel_display_name, + timestamp_utc, + channel_logo_id, + m3u_profile_id, + debug, + account_id=None, +): + # Use 256 KB chunks: amortises per-yield uWSGI/gevent overhead. + chunk_size = max(ConfigHelper.chunk_size(), 262144) + + # Reorder the candidates so the cached winning shape is tried first. + # The cache is per-account; within a single fast-forward session every + # seek lands in the same archive entry and therefore the same parser + # path, so the first attempt almost always succeeds. + cached_index = _get_cached_format_index(account_id) + if cached_index is not None and 0 <= cached_index < len(candidate_urls): + ordered_urls = [candidate_urls[cached_index]] + [ + u for i, u in enumerate(candidate_urls) if i != cached_index + ] + original_indices = [cached_index] + [ + i for i in range(len(candidate_urls)) if i != cached_index + ] + else: + ordered_urls = list(candidate_urls) + original_indices = list(range(len(candidate_urls))) + + # Try each candidate URL until one returns a streamable status. We keep + # going on 400/404 (the two refusal modes XC servers use when the URL + # shape doesn't match their parser); a 403, 5xx or connection error is + # decisive and short-circuits the loop. + upstream = None + last_status = None + last_url = ordered_urls[0] + winning_index = None + for url, orig_idx in zip(ordered_urls, original_indices): + try: + response = _open_upstream(url, user_agent, range_header) + except requests.exceptions.RequestException as exc: + logger.error("Timeshift provider unreachable: %s", exc) + return HttpResponseBadRequest("Provider connection error") + last_status = response.status_code + last_url = url + if response.status_code in (200, 206): + upstream = response + winning_index = orig_idx + break + response.close() + if response.status_code not in (400, 404): + # Decisive failure (403, 5xx, …) — no point trying alternative URLs. + break + + if winning_index is not None: + _set_cached_format_index(account_id, winning_index) + + if upstream is None: + logger.error("Timeshift upstream rejected: status=%s url=%s", + last_status, _redact_url(last_url)) + # Mirror semantically meaningful upstream statuses so clients react + # correctly: 404 stops retry loops (catch-up not yet indexed), 403 + # surfaces auth problems. Everything else stays a generic 400. + if last_status == 404: + return HttpResponseNotFound("Catch-up not available yet") + if last_status == 403: + return HttpResponseForbidden("Provider denied access") + return HttpResponseBadRequest(f"Provider error: {last_status}") + + content_type = upstream.headers.get("Content-Type", "video/mp2t") + content_range = upstream.headers.get("Content-Range", "") + status = upstream.status_code + + redis_client = RedisClient.get_client() + _register_stats_client( + redis_client, + virtual_channel_id, + client_id, + client_ip, + user_agent, + user, + channel_display_name=channel_display_name, + timestamp_utc=timestamp_utc, + primary_url=last_url, + channel_logo_id=channel_logo_id, + m3u_profile_id=m3u_profile_id, + ) + + # Use HTTPStreamReader (same component as live TV) with a 1 MB pipe + # buffer and TS preamble stripping. The producer thread reads from the + # provider and writes to the pipe; the WSGI greenlet reads from the + # pipe and yields to the client. + reader = HTTPStreamReader( + url=last_url, + chunk_size=chunk_size, + response=upstream, + strip_ts_preamble=True, + ) + pipe_read_fd = reader.start() + pipe_reader = os.fdopen(pipe_read_fd, "rb", buffering=0) + + def stream_generator(): + last_heartbeat = time.time() + bytes_since_heartbeat = 0 + total_yielded = 0 + loop_start = time.time() + try: + while True: + data = pipe_reader.read(chunk_size) + if not data: + break + yield data + bytes_since_heartbeat += len(data) + total_yielded += len(data) + + now = time.time() + if now - last_heartbeat >= 5: + _heartbeat_stats_client( + redis_client, virtual_channel_id, client_id, + bytes_delta=bytes_since_heartbeat, + ) + last_heartbeat = now + bytes_since_heartbeat = 0 + except GeneratorExit: + pass + except Exception: + logger.exception("Timeshift stream loop error") + finally: + elapsed = time.time() - loop_start + if debug and total_yielded > 0: + mbps = (total_yielded * 8) / elapsed / 1_000_000 if elapsed > 0 else 0 + logger.info( + "Timeshift disconnect: vid=%s client=%s yielded=%d bytes in %.1fs (%.2f Mbps avg)", + virtual_channel_id, client_id, total_yielded, elapsed, mbps, + ) + reader.stop() + try: + pipe_reader.close() + except Exception: + pass + _unregister_stats_client(redis_client, virtual_channel_id, client_id) + + response = StreamingHttpResponse( + stream_generator(), + content_type=content_type, + status=status, + ) + # Tell nginx not to buffer this streaming response. Without this, the + # default uwsgi_buffering=on under `location /` throttles the response + # roughly to half the upstream rate (back-pressure from buffer flushes + # propagates back into the generator). + response["X-Accel-Buffering"] = "no" + # Forward Content-Range so iPlayTV / TiviMate seek cursor stays correct. + # Content-Length is intentionally NOT forwarded: chunked transfer is the + # safer interchange format for long-lived streaming. + if content_range: + response["Content-Range"] = content_range + response["Accept-Ranges"] = "bytes" + return response + + +def _redact_url(url): + """Strip credentials from a URL for safe logging.""" + if not url or "://" not in url: + return url + scheme, rest = url.split("://", 1) + if "@" in rest: + rest = rest.split("@", 1)[1] + return f"{scheme}://{rest.split('?')[0]}/..." diff --git a/core/models.py b/core/models.py index 0ec4efda..40c17a7f 100644 --- a/core/models.py +++ b/core/models.py @@ -195,6 +195,14 @@ NETWORK_ACCESS_KEY = "network_access" SYSTEM_SETTINGS_KEY = "system_settings" EPG_SETTINGS_KEY = "epg_settings" USER_LIMITS_SETTINGS_KEY = "user_limit_settings" +TIMESHIFT_SETTINGS_KEY = "timeshift_settings" + +TIMESHIFT_DEFAULTS = { + "default_timezone": "UTC", + "default_language": "en", + "xmltv_prev_days_override": 0, + "debug_logging": False, +} class CoreSettings(models.Model): @@ -436,6 +444,22 @@ class CoreSettings(models.Model): "terminate_oldest": True, }) + # Timeshift Settings (XC catch-up) + @classmethod + def get_timeshift_settings(cls): + """Return all timeshift-related settings, falling back to neutral defaults.""" + stored = cls._get_group(TIMESHIFT_SETTINGS_KEY, {}) or {} + merged = dict(TIMESHIFT_DEFAULTS) + for key, value in stored.items(): + if key in merged and value is not None: + merged[key] = value + return merged + + @classmethod + def update_timeshift_settings(cls, updates): + clean = {k: v for k, v in (updates or {}).items() if k in TIMESHIFT_DEFAULTS} + return cls._update_group(TIMESHIFT_SETTINGS_KEY, "Timeshift Settings", clean) + class SystemEvent(models.Model): """ diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index e8818108..e95f91cd 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -97,6 +97,7 @@ INSTALLED_APPS = [ "django_filters", "django_celery_beat", "apps.plugins", + "apps.timeshift.apps.TimeshiftConfig", ] # EPG Processing optimization settings diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 9123f3da..d8e462a5 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -7,6 +7,7 @@ from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.live_proxy.views import stream_xc from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode +from apps.timeshift.views import timeshift_proxy urlpatterns = [ # API Routes @@ -41,6 +42,13 @@ urlpatterns = [ stream_xc, name="xc_stream_endpoint", ), + # XC catch-up (timeshift). The "duration" slot is actually the provider's + # stream_id — matches what iPlayTV / TiviMate emit (see apps/timeshift/views). + path( + "timeshift/////", + timeshift_proxy, + name="timeshift_proxy", + ), # XC VOD endpoints path( "movie///.", diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 30509a86..794045f4 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -26,6 +26,7 @@ import { HardDriveDownload, HardDriveUpload, Radio, + Rewind, SquareX, Timer, Users, @@ -496,7 +497,7 @@ const StreamConnectionCard = ({ ); }, - mantineExpandButtonProps: ({ row, table }) => ({ + mantineExpandButtonProps: ({ row }) => ({ size: 'xs', style: { transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', @@ -533,7 +534,10 @@ const StreamConnectionCard = ({ }, [channel.name, channel.stream_id]); const channelName = - channel.name || previewedStream?.name || 'Unnamed Channel'; + channel.name || + channel.channel_name || + previewedStream?.name || + 'Unnamed Channel'; const uptime = channel.uptime || 0; const bitrates = channel.bitrates || []; const totalBytes = channel.total_bytes || 0; @@ -745,6 +749,18 @@ const StreamConnectionCard = ({ {/* Add stream information badges */} + {channel.is_timeshift && ( + + } + > + TIMESHIFT + + + )} {channel.resolution && ( diff --git a/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx b/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx new file mode 100644 index 00000000..59db1e62 --- /dev/null +++ b/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx @@ -0,0 +1,116 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState, useMemo } from 'react'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { + Button, + NumberInput, + Select, + Stack, + Switch, + Text, + TextInput, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { buildTimeZoneOptions } from '../../../utils/dateTimeUtils.js'; +import { + getTimeshiftSettingsFormInitialValues, + getTimeshiftSettingsFormValidation, +} from '../../../utils/forms/settings/TimeshiftSettingsFormUtils.js'; + +const TimeshiftSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + const [saved, setSaved] = useState(false); + + const form = useForm({ + mode: 'controlled', + initialValues: getTimeshiftSettingsFormInitialValues(), + validate: getTimeshiftSettingsFormValidation(), + }); + + const tzOptions = useMemo( + () => + buildTimeZoneOptions( + form.getValues().timeshift_default_timezone || 'UTC' + ), + [] + ); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const parsed = parseSettings(settings); + // Fall back explicitly to the neutral defaults so the Select shows + // "UTC (now UTC+00:00)" when no setting has been saved yet. + form.setValues({ + timeshift_default_timezone: parsed.timeshift_default_timezone || 'UTC', + timeshift_default_language: parsed.timeshift_default_language || 'en', + xmltv_prev_days_override: parsed.xmltv_prev_days_override ?? 0, + timeshift_debug_logging: !!parsed.timeshift_debug_logging, + }); + } + }, [settings]); + + const onSubmit = async () => { + setSaved(false); + const changed = getChangedSettings(form.getValues(), settings); + try { + await saveChangedSettings(settings, changed); + setSaved(true); + } catch (error) { + console.error('Error saving timeshift settings:', error); + } + }; + + return ( +
+ + + XC catch-up uses these settings to convert UTC EPG timestamps into the + provider's local time and to set the XMLTV lookback window. The + defaults are neutral (UTC, en, off) — adjust to match your provider. + + - { step={1} {...form.getInputProps('xmltv_prev_days_override')} /> - diff --git a/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js b/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js index 3b5813f5..880c32b0 100644 --- a/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js @@ -1,17 +1,9 @@ export const getTimeshiftSettingsFormInitialValues = () => { return { - timeshift_default_timezone: 'UTC', - timeshift_default_language: 'en', xmltv_prev_days_override: 0, - timeshift_debug_logging: false, }; }; export const getTimeshiftSettingsFormValidation = () => { - return { - timeshift_default_language: (value) => - value && !/^[a-z]{2}$/i.test(value) - ? 'Must be a 2-letter ISO 639-1 code' - : null, - }; + return {}; }; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index 28a5764c..e796af96 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -70,12 +70,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'auto_import_mapped_files', 'enable_ip_lookup', ]; - const timeshiftFields = [ - 'timeshift_default_timezone', - 'timeshift_default_language', - 'xmltv_prev_days_override', - 'timeshift_debug_logging', - ]; + const timeshiftFields = ['xmltv_prev_days_override']; for (const formKey in changedSettings) { let value = changedSettings[formKey]; @@ -142,23 +137,12 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files', - 'timeshift_debug_logging', 'enable_ip_lookup', ]; if (booleanFields.includes(formKey) && value != null) { value = typeof value === 'boolean' ? value : Boolean(value); } - // Map UI form keys (with timeshift_ prefix) to the storage subkeys used in - // CoreSettings.value. The prefix lets us share form keys across groups - // without ambiguity but the JSON storage uses short keys. - const timeshiftFormKeyToStorage = { - timeshift_default_timezone: 'default_timezone', - timeshift_default_language: 'default_language', - xmltv_prev_days_override: 'xmltv_prev_days_override', - timeshift_debug_logging: 'debug_logging', - }; - // Route to appropriate group if (streamFields.includes(formKey)) { groupedChanges.stream_settings[formKey] = value; @@ -171,8 +155,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { } else if (systemFields.includes(formKey)) { groupedChanges.system_settings[formKey] = value; } else if (timeshiftFields.includes(formKey)) { - const storageKey = timeshiftFormKeyToStorage[formKey]; - groupedChanges.timeshift_settings[storageKey] = value; + groupedChanges.timeshift_settings[formKey] = value; } } @@ -388,26 +371,16 @@ export const parseSettings = (settings) => { : true; } - // Timeshift settings - direct mapping with timeshift_ prefixed form keys + // Timeshift settings - direct key mapping. + // The only key is the XMLTV lookback override: the XC API surface is strictly + // UTC (no timezone setting) and verbose logging follows the standard logger level. const timeshiftSettings = settings['timeshift_settings']?.value; - parsed.timeshift_default_timezone = - timeshiftSettings && timeshiftSettings.default_timezone - ? String(timeshiftSettings.default_timezone) - : 'UTC'; - parsed.timeshift_default_language = - timeshiftSettings && timeshiftSettings.default_language - ? String(timeshiftSettings.default_language) - : 'en'; parsed.xmltv_prev_days_override = timeshiftSettings && timeshiftSettings.xmltv_prev_days_override != null ? typeof timeshiftSettings.xmltv_prev_days_override === 'number' ? timeshiftSettings.xmltv_prev_days_override : parseInt(timeshiftSettings.xmltv_prev_days_override, 10) || 0 : 0; - parsed.timeshift_debug_logging = - timeshiftSettings && timeshiftSettings.debug_logging != null - ? Boolean(timeshiftSettings.debug_logging) - : false; // Proxy and network access are already grouped objects if (settings['proxy_settings']?.value) { From 2909c9b339b6b41dfec1b296055b1dff161fb42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Marcoux?= Date: Wed, 10 Jun 2026 22:05:01 +0200 Subject: [PATCH 10/56] chore(timeshift): pre-review hardening and dead-code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - http_streamer.py: restore HTTPStreamReader to its upstream form and keep only the find_ts_sync() addition. The response=/extra_headers=/ strip_ts_preamble= extensions had no remaining callers since the timeshift view moved to direct iter_content streaming (delta shrinks from +235/-83 lines to +28). - Unify the catchup_days semantics everywhere: a channel's archive depth is MAX(catchup_days) over its CATCH-UP streams only. The SQL rollup now uses a FILTER (WHERE s.is_catchup) aggregate and the ChannelStream signal uses the same MAX aggregation (it previously took the first stream by order, and the rollup aggregated over all streams including non-catchup ones). - Migration backfill: also accept the lowercase 'true' that ->> extraction yields for JSON booleans. - get_channel_catchup_info(): drop the tv_archive_duration key — its only caller never used it. - Document why timeshift termination fails closed when Redis is unavailable (denying the new stream is what protects the provider connection limit), and update the stale stop-key comment (5 s cadence, not 100 chunks). --- .../migrations/0038_add_catchup_fields.py | 4 + apps/channels/signals.py | 15 +- apps/channels/utils.py | 1 - apps/m3u/tasks.py | 4 +- apps/proxy/live_proxy/input/http_streamer.py | 431 +++++++----------- apps/proxy/utils.py | 8 +- 6 files changed, 183 insertions(+), 280 deletions(-) diff --git a/apps/channels/migrations/0038_add_catchup_fields.py b/apps/channels/migrations/0038_add_catchup_fields.py index 50e1ad01..d6cdc335 100644 --- a/apps/channels/migrations/0038_add_catchup_fields.py +++ b/apps/channels/migrations/0038_add_catchup_fields.py @@ -26,6 +26,10 @@ def backfill_stream_catchup(apps, schema_editor): AND custom_properties != 'null'::jsonb AND ( custom_properties->>'tv_archive' = '1' + -- JSON booleans extract as lowercase 'true' via ->>; the + -- 'True' spelling covers Python-str values stored by + -- older import code. + OR custom_properties->>'tv_archive' = 'true' OR custom_properties->>'tv_archive' = 'True' ) """) diff --git a/apps/channels/signals.py b/apps/channels/signals.py index f1f3f0ca..c896bd13 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -382,16 +382,19 @@ def update_channel_catchup_fields(sender, instance, **kwargs): Covers the UI/API path (admin adds/removes a stream from a channel). The bulk-import path bypasses signals and uses explicit SQL instead — - see apps/m3u/tasks.py after ChannelStream.objects.bulk_create(). + see rollup_channel_catchup_fields() in apps/m3u/tasks.py. Same semantics + as that rollup: is_catchup = any catch-up stream, catchup_days = MAX of + the catch-up streams' archive depth. """ + from django.db.models import Max + channel = instance.channel - best = ( + max_days = ( channel.streams .filter(is_catchup=True) - .order_by("channelstream__order") - .first() + .aggregate(max_days=Max("catchup_days"))["max_days"] ) Channel.objects.filter(pk=channel.pk).update( - is_catchup=best is not None, - catchup_days=best.catchup_days if best else 0, + is_catchup=max_days is not None, + catchup_days=max_days or 0, ) diff --git a/apps/channels/utils.py b/apps/channels/utils.py index d5dea651..043a8a36 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -41,7 +41,6 @@ def get_channel_catchup_info(channel): return { "stream": stream, "props": stream.custom_properties or {}, - "tv_archive_duration": channel.catchup_days or 7, } diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 2e3cde6d..7ebe2ad0 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1845,7 +1845,9 @@ def rollup_channel_catchup_fields(account_id): SELECT cs.channel_id, bool_or(s.is_catchup) AS any_catchup, - MAX(s.catchup_days) AS max_days + -- Only catch-up streams contribute their archive depth: + -- a non-catchup stream may carry a stale catchup_days value. + MAX(s.catchup_days) FILTER (WHERE s.is_catchup) AS max_days FROM dispatcharr_channels_channelstream cs JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id WHERE cs.channel_id IN ( diff --git a/apps/proxy/live_proxy/input/http_streamer.py b/apps/proxy/live_proxy/input/http_streamer.py index cedb09d9..d90297c5 100644 --- a/apps/proxy/live_proxy/input/http_streamer.py +++ b/apps/proxy/live_proxy/input/http_streamer.py @@ -1,48 +1,182 @@ """ HTTP Stream Reader - Thread-based HTTP stream reader that writes to a pipe. -This allows us to use the same fetch_chunk() path for both transcode and HTTP -streams (live TV) and for timeshift/catch-up archives. - -When *response* is supplied the reader skips connection setup and streams the -already-open ``requests.Response`` directly. This is used by the timeshift -view which needs to cascade through multiple candidate URLs before handing -the winning response to the reader. - -When *strip_ts_preamble* is True the first bytes written to the pipe are -aligned to the first MPEG-TS sync chain (0x47 at 188-byte intervals). Some -XC servers emit PHP warnings or HTML error text before the binary stream; -strict demuxers (ExoPlayer / TiviMate) raise ``UnrecognizedInputFormat`` on -those bytes. The live path typically receives clean TS so the flag defaults -to False, but the timeshift path enables it. +This allows us to use the same fetch_chunk() path for both transcode and HTTP streams. """ -import fcntl -import os -import select as _select import threading - +import os import requests from requests.adapters import HTTPAdapter - from ..utils import get_logger logger = get_logger() -# MPEG-TS sync constants (shared with timeshift views) + +class HTTPStreamReader: + """Thread-based HTTP stream reader that writes to a pipe""" + + def __init__(self, url, user_agent=None, chunk_size=8192): + self.url = url + self.user_agent = user_agent + self.chunk_size = chunk_size + self.session = None + self.response = None + self.thread = None + self.pipe_read = None + self.pipe_write = None + self.running = False + + def start(self): + """Start the HTTP stream reader thread""" + self.pipe_read, self.pipe_write = os.pipe() + + # Make the write end non-blocking so that os.write() raises BlockingIOError + # instead of stalling the OS thread when the pipe buffer is full. Without + # this, a full pipe blocks the entire gevent worker (all greenlets freeze) + # because gevent does not patch os.write() on pipes. + import fcntl + flags = fcntl.fcntl(self.pipe_write, fcntl.F_GETFL) + fcntl.fcntl(self.pipe_write, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + self.running = True + self.thread = threading.Thread(target=self._read_stream, daemon=True) + self.thread.start() + + logger.info(f"Started HTTP stream reader thread for {self.url}") + return self.pipe_read + + def _read_stream(self): + """Thread worker that reads HTTP stream and writes to pipe""" + try: + # Build headers + headers = {} + if self.user_agent: + headers['User-Agent'] = self.user_agent + + logger.info(f"HTTP reader connecting to {self.url}") + + # Create session + self.session = requests.Session() + + # Disable retries for faster failure detection + adapter = HTTPAdapter(max_retries=0, pool_connections=1, pool_maxsize=1) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + # Stream the URL + self.response = self.session.get( + self.url, + headers=headers, + stream=True, + timeout=(5, 30) # 5s connect, 30s read + ) + + if self.response.status_code != 200: + logger.error(f"HTTP {self.response.status_code} from {self.url}") + return + + logger.info(f"HTTP reader connected successfully, streaming data...") + + import select as _select + + # Stream chunks to pipe + chunk_count = 0 + for chunk in self.response.iter_content(chunk_size=self.chunk_size): + if not self.running: + break + + if chunk: + # Write the chunk in a non-blocking loop. The pipe write end is + # set O_NONBLOCK in start(), so os.write() raises BlockingIOError + # instead of stalling the OS thread. We use select.select on the + # write fd (gevent-patched - yields to hub) to wait for space, + # then retry. Partial writes are handled by advancing the offset. + offset = 0 + write_error = False + while offset < len(chunk) and self.running: + try: + n = os.write(self.pipe_write, chunk[offset:]) + offset += n + except BlockingIOError: + _, writable, _ = _select.select([], [self.pipe_write], [], 1.0) + if not writable and not self.running: + write_error = True + break + except OSError as e: + logger.error(f"Pipe write error: {e}") + write_error = True + break + if write_error: + break + + chunk_count += 1 + if chunk_count % 1000 == 0: + logger.debug(f"HTTP reader streamed {chunk_count} chunks") + + logger.info("HTTP stream ended") + + except requests.exceptions.RequestException as e: + logger.error(f"HTTP reader request error: {e}") + except Exception as e: + logger.error(f"HTTP reader unexpected error: {e}", exc_info=True) + finally: + self.running = False + # Close write end of pipe to signal EOF + try: + if self.pipe_write is not None: + os.close(self.pipe_write) + self.pipe_write = None + except: + pass + + def stop(self): + """Stop the HTTP stream reader""" + logger.info("Stopping HTTP stream reader") + self.running = False + + # Close response + if self.response: + try: + self.response.close() + except: + pass + + # Close session + if self.session: + try: + self.session.close() + except: + pass + + # Close write end of pipe + if self.pipe_write is not None: + try: + os.close(self.pipe_write) + self.pipe_write = None + except: + pass + + # Wait for thread + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=2.0) + + +# --------------------------------------------------------------------------- +# MPEG-TS sync detection (used by the timeshift catch-up proxy) +# --------------------------------------------------------------------------- + _TS_PACKET_SIZE = 188 _TS_SYNC_BYTE = 0x47 -_TS_SYNC_SEARCH_LIMIT = 65536 - -# Linux fcntl constant — increase pipe buffer beyond the 64 KB default. -_F_SETPIPE_SZ = 1031 -_PIPE_TARGET_SIZE = 1 << 20 # 1 MB def find_ts_sync(buf): """Offset of the first MPEG-TS sync chain in *buf*, or -1. A valid chain needs 0x47 at offsets i, i+188 and i+376 — three sync - bytes one packet apart (the standard demuxer probe). + bytes one packet apart (the standard demuxer probe). The timeshift + proxy peeks at the first upstream bytes with this to reject HTTP-200 + PHP error pages and to strip any pre-sync preamble before the bytes + reach a strict demuxer (ExoPlayer). """ end = len(buf) - 2 * _TS_PACKET_SIZE for i in range(0, end): @@ -53,248 +187,3 @@ def find_ts_sync(buf): ): return i return -1 - - -class HTTPStreamReader: - """Thread-based HTTP stream reader that writes to an OS pipe. - - Parameters - ---------- - url : str - Provider URL (used when *response* is None to open a new connection). - user_agent : str, optional - User-Agent header for the upstream request. - chunk_size : int - Chunk size for ``iter_content`` and ``pipe_reader.read``. - response : requests.Response, optional - An already-open streaming response. When provided the reader skips - connection setup and streams this response directly. The caller is - responsible for having opened it with ``stream=True``. - extra_headers : dict, optional - Additional HTTP headers (e.g. Range) forwarded to the provider when - *response* is None. - strip_ts_preamble : bool - Strip non-MPEG-TS bytes before the first 0x47 sync chain. - """ - - def __init__( - self, - url, - user_agent=None, - chunk_size=8192, - *, - response=None, - extra_headers=None, - strip_ts_preamble=False, - ): - self.url = url - self.user_agent = user_agent - self.chunk_size = chunk_size - self._provided_response = response - self._extra_headers = extra_headers or {} - self._strip_ts_preamble = strip_ts_preamble - self.session = None - self.response = response # may be pre-set - self.thread = None - self.pipe_read = None - self.pipe_write = None - self.running = False - - def start(self): - """Start the HTTP stream reader thread.""" - self.pipe_read, self.pipe_write = os.pipe() - - # Grow the kernel pipe buffer from 64 KB to 1 MB so the producer - # thread is not blocked while the consumer yields to uWSGI. This - # is the single biggest throughput improvement for the timeshift - # path: it turns the producer/consumer from ping-pong alternation - # into genuinely parallel operation. - try: - fcntl.fcntl(self.pipe_write, _F_SETPIPE_SZ, _PIPE_TARGET_SIZE) - except (OSError, ValueError): - pass # non-Linux or unprivileged — keep 64 KB default - - # Make the write end non-blocking so that os.write() raises - # BlockingIOError instead of stalling the OS thread when the pipe - # buffer is full. Without this, a full pipe blocks the entire gevent - # worker (all greenlets freeze) because gevent does not patch - # os.write() on pipes. - # Skip for pre-opened responses (timeshift path) — those run in a - # real OS thread where blocking writes are fine and the 1 MB pipe - # buffer provides sufficient decoupling. - if self._provided_response is None: - flags = fcntl.fcntl(self.pipe_write, fcntl.F_GETFL) - fcntl.fcntl(self.pipe_write, fcntl.F_SETFL, flags | os.O_NONBLOCK) - - self.running = True - self.thread = threading.Thread(target=self._read_stream, daemon=True) - self.thread.start() - - logger.info("Started HTTP stream reader thread for %s", self.url) - return self.pipe_read - - # ------------------------------------------------------------------ - # Non-blocking pipe write helper - # ------------------------------------------------------------------ - - def _pipe_write_all(self, data): - """Write *data* to the pipe, handling O_NONBLOCK + partial writes. - - Uses select() on the write fd (gevent-patched — yields to hub) to - wait for space when the pipe is full, instead of blocking the OS - thread. - """ - offset = 0 - while offset < len(data) and self.running: - try: - n = os.write(self.pipe_write, data[offset:]) - offset += n - except BlockingIOError: - _, writable, _ = _select.select([], [self.pipe_write], [], 1.0) - if not writable and not self.running: - return False - except (BrokenPipeError, OSError): - return False - return True - - # ------------------------------------------------------------------ - # Producer thread - # ------------------------------------------------------------------ - - def _read_stream(self): - """Thread worker: read HTTP stream → write to pipe.""" - try: - if self._provided_response is not None: - # Re-use the already-open response (timeshift cascade path). - self.response = self._provided_response - else: - # Open a fresh connection (live TV path). - headers = dict(self._extra_headers) - if self.user_agent: - headers["User-Agent"] = self.user_agent - - logger.info("HTTP reader connecting to %s", self.url) - - self.session = requests.Session() - adapter = HTTPAdapter( - max_retries=0, pool_connections=1, pool_maxsize=1, - ) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - - self.response = self.session.get( - self.url, - headers=headers, - stream=True, - timeout=(5, 30), - ) - - if self.response.status_code not in (200, 206): - logger.error( - "HTTP %d from %s", self.response.status_code, self.url, - ) - return - - logger.info("HTTP reader connected, streaming data…") - - # Optional TS preamble stripping — find the first MPEG-TS sync - # chain and discard everything before it. - synced = not self._strip_ts_preamble - sync_buf = bytearray() if not synced else None - - # If the caller peeked at the response body (e.g. to validate TS - # sync before accepting the response), prepend those bytes so they - # aren't lost. They've already been consumed from the raw socket. - peek_data = getattr(self.response, "_peek_data", None) - - chunk_count = 0 - chunks_iter = self.response.iter_content(chunk_size=self.chunk_size) - if peek_data: - import itertools - chunks_iter = itertools.chain([peek_data], chunks_iter) - - for chunk in chunks_iter: - if not self.running: - break - if not chunk: - continue - - # ---- TS preamble strip logic ---- - if not synced: - sync_buf.extend(chunk) - if len(sync_buf) < 2 * _TS_PACKET_SIZE + 1: - continue - offset = find_ts_sync(sync_buf) - if offset >= 0: - chunk = bytes(sync_buf[offset:]) - synced = True - sync_buf = None - elif len(sync_buf) >= _TS_SYNC_SEARCH_LIMIT: - logger.warning( - "Upstream produced %d bytes without TS sync " - "chain; passing through unmodified", - len(sync_buf), - ) - chunk = bytes(sync_buf) - synced = True - sync_buf = None - else: - continue - - # ---- Non-blocking write to pipe ---- - if not self._pipe_write_all(chunk): - break - - chunk_count += 1 - if chunk_count % 1000 == 0: - logger.debug("HTTP reader streamed %d chunks", chunk_count) - - # Flush any remaining sync_buf that never found a chain. - if not synced and sync_buf: - self._pipe_write_all(bytes(sync_buf)) - - logger.info("HTTP stream ended") - - except requests.exceptions.RequestException as exc: - logger.error("HTTP reader request error: %s", exc) - except Exception as exc: - logger.error("HTTP reader unexpected error: %s", exc, exc_info=True) - finally: - self.running = False - try: - if self.pipe_write is not None: - os.close(self.pipe_write) - self.pipe_write = None - except OSError: - pass - - # ------------------------------------------------------------------ - # Shutdown - # ------------------------------------------------------------------ - - def stop(self): - """Stop the HTTP stream reader.""" - logger.info("Stopping HTTP stream reader") - self.running = False - - if self.response: - try: - self.response.close() - except Exception: - pass - - if self.session: - try: - self.session.close() - except Exception: - pass - - if self.pipe_write is not None: - try: - os.close(self.pipe_write) - self.pipe_write = None - except OSError: - pass - - if self.thread and self.thread.is_alive(): - self.thread.join(timeout=2.0) diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index 5c54c7b1..32e2b674 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -65,9 +65,15 @@ def attempt_stream_termination(user_id, requesting_client_id, active_connections elif t['type'] == 'timeshift': # Timeshift uses the same Redis key pattern as live # (RedisKeys.client_stop). The stream_generator in - # apps/timeshift/views.py checks this key every 100 chunks. + # apps/timeshift/views.py polls this key on its 5-second + # heartbeat cadence. redis_client = RedisClient.get_client() if not redis_client: + # Without Redis the stop key cannot be set, so the old + # stream cannot be terminated. Failing the whole attempt + # is the safe outcome: the caller then denies the NEW + # stream instead of letting the user exceed the provider's + # connection limit (which escalates to an IP ban). return False stop_key = RedisKeys.client_stop(t['media_id'], t['client_id']) redis_client.setex(stop_key, 60, "true") From 1703d6a70371f1cab202276766c4ff068ac3f3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Marcoux?= Date: Thu, 11 Jun 2026 20:08:16 +0200 Subject: [PATCH 11/56] =?UTF-8?q?feat(timeshift):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20network=20gate,=20catch-up=20failover,=20live=5Fpro?= =?UTF-8?q?xy=20revert,=20setting=20moved=20to=20proxy=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all four points from the latest review, plus hardening from a pre-submission audit pass. 1. Access control: timeshift_proxy now enforces network_access_allowed(request, "STREAMS", user) — same key and placement as the live XC stream endpoint. 2. Catch-up failover: the proxy walks the channel's catch-up streams in channelstream order (get_channel_catchup_streams), mirroring live playback. Each attempt carries its own provider context: account credentials, provider stream id, reported server_info timezone (the UTC->provider conversion is recomputed per attempt), user-agent, and the per-account URL-format cache. The first streamable response wins; if all providers fail the last failure is returned. Ban-safety is per account: a decisive auth/ban-class failure (401/403/406) marks the account and skips its remaining streams (e.g. FHD/HD variants of the same channel) instead of hammering a banning provider, while other accounts — different hosts — are still tried. Streams from disabled M3U accounts are excluded, same as live dispatch. Redirects stay enabled on purpose (XC providers legitimately 302 to load-balanced streaming nodes); the 3xx decisive branch is kept as defense-in-depth and documented as such. 3. apps/proxy/live_proxy/views.py restored byte-identical to upstream — the leftover channel-id wrapper from the removed provider-stream-id fallback is gone (zero-line diff). 4. Single remaining setting relocated: xmltv_prev_days_override now lives in proxy_settings (backend default in get_proxy_settings, consumed by the XMLTV prev_days resolution). The timeshift_settings group, TIMESHIFT_DEFAULTS, get_timeshift_settings, the Settings → Timeshift form and tab are all removed; the field appears under Settings → Proxy Settings (0 = auto-detect, capped at 30). Audit hardening in the same pass: - Updated the proxy-settings defaults unit test for the new key (would have failed CI otherwise). - Migration backfills use schema_editor.connection instead of the global connection (multi-database correctness). - CHANGELOG and module docstring brought in line with the final architecture (PATH-first cascade, failover, setting under Proxy Settings). - Tests grown to 69 backend tests: failover success/exhaustion/skip semantics, decisive-account skip vs soft-failure retry, per-stream timezone conversion (different zones per provider), 406/connection-error cascade paths, stream-limit and no-eligible-stream outcomes, network-gate 403, server_info strict-UTC guarantee, EPG duration window resolution, and DB-backed coverage of xc_password auth, user_level access and the failover stream ordering (catch-up-only, active accounts, channelstream order). The format-cache test now runs on an isolated locmem cache. --- CHANGELOG.md | 3 +- .../migrations/0038_add_catchup_fields.py | 8 +- apps/channels/utils.py | 26 +- apps/output/views.py | 6 +- apps/proxy/live_proxy/views.py | 13 +- apps/timeshift/tests/test_helpers.py | 50 +++ apps/timeshift/tests/test_views.py | 413 +++++++++++++++++- apps/timeshift/views.py | 240 +++++----- core/models.py | 29 +- .../components/cards/StreamConnectionCard.jsx | 2 +- .../forms/settings/ProxySettingsForm.jsx | 5 +- .../forms/settings/TimeshiftSettingsForm.jsx | 74 ---- frontend/src/constants.js | 5 + frontend/src/pages/Settings.jsx | 16 - .../forms/settings/ProxySettingsFormUtils.js | 1 + .../settings/TimeshiftSettingsFormUtils.js | 9 - .../__tests__/ProxySettingsFormUtils.test.js | 2 + frontend/src/utils/pages/SettingsUtils.js | 17 +- 18 files changed, 624 insertions(+), 295 deletions(-) delete mode 100644 frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx delete mode 100644 frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc264f7..1b26cd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,7 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries. - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - - **`Settings → Timeshift` panel** with a single knob: `xmltv_prev_days_override` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. + - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. + - **Single setting**, `xmltv_prev_days_override`, under `Settings → Proxy Settings` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: diff --git a/apps/channels/migrations/0038_add_catchup_fields.py b/apps/channels/migrations/0038_add_catchup_fields.py index d6cdc335..2ec76333 100644 --- a/apps/channels/migrations/0038_add_catchup_fields.py +++ b/apps/channels/migrations/0038_add_catchup_fields.py @@ -10,9 +10,7 @@ from django.db import migrations, models def backfill_stream_catchup(apps, schema_editor): """Derive is_catchup/catchup_days from Stream.custom_properties JSON.""" - from django.db import connection - - with connection.cursor() as cursor: + with schema_editor.connection.cursor() as cursor: cursor.execute(""" UPDATE dispatcharr_channels_stream SET is_catchup = TRUE, @@ -37,9 +35,7 @@ def backfill_stream_catchup(apps, schema_editor): def backfill_channel_catchup(apps, schema_editor): """Roll up catch-up fields from streams to channels.""" - from django.db import connection - - with connection.cursor() as cursor: + with schema_editor.connection.cursor() as cursor: cursor.execute(""" UPDATE dispatcharr_channels_channel c SET is_catchup = EXISTS ( diff --git a/apps/channels/utils.py b/apps/channels/utils.py index 043a8a36..2efcef62 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -24,24 +24,22 @@ def format_channel_number(value, empty=""): return value -def get_channel_catchup_info(channel): - """Return catch-up info for a Channel, or None if catch-up is unavailable.""" - if not getattr(channel, "is_catchup", False): - return None +def get_channel_catchup_streams(channel): + """Ordered catch-up streams for a Channel (empty list if unavailable). - stream = ( - channel.streams.filter(is_catchup=True) + Returned in ``channelstream__order`` so the timeshift proxy can fail over: + it tries each stream's provider in turn until one serves the archive — + mirroring how live playback walks a channel's stream list. Streams from + disabled M3U accounts are excluded, same as live dispatch. + """ + if not getattr(channel, "is_catchup", False): + return [] + + return list( + channel.streams.filter(is_catchup=True, m3u_account__is_active=True) .order_by("channelstream__order") .select_related("m3u_account") - .first() ) - if stream is None: - return None - - return { - "stream": stream, - "props": stream.custom_properties or {}, - } def increment_stream_count(account): diff --git a/apps/output/views.py b/apps/output/views.py index 0cc20e3f..6ba06e9f 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1323,7 +1323,7 @@ def generate_epg(request, profile_name=None, user=None): # Timeshift: prev_days resolution order: # 1. URL ?prev_days= (explicit, even 0 means "no past") # 2. user.custom_properties.epg_prev_days - # 3. CoreSettings.timeshift_settings.xmltv_prev_days_override (>0) + # 3. CoreSettings.proxy_settings.xmltv_prev_days_override (>0) # 4. Auto-detect: max provider tv_archive_duration capped at 30 url_prev = request.GET.get('prev_days') user_prev = user_custom.get('epg_prev_days') if user_custom else None @@ -1339,9 +1339,9 @@ def generate_epg(request, profile_name=None, user=None): prev_days = 0 else: from apps.timeshift.helpers import compute_provider_archive_days_capped - timeshift_settings = CoreSettings.get_timeshift_settings() + proxy_settings = CoreSettings.get_proxy_settings() try: - override = int(timeshift_settings.get("xmltv_prev_days_override", 0) or 0) + override = int(proxy_settings.get("xmltv_prev_days_override", 0) or 0) except (TypeError, ValueError): override = 0 prev_days = max(0, min(override, 30)) if override > 0 else compute_provider_archive_days_capped() diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 15a4a8ab..d60d9703 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -609,13 +609,6 @@ def stream_xc(request, username, password, channel_id): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) - # Clients always receive channel.id from get_live_streams — straightforward - # int lookup, no provider stream_id fallback needed. - try: - resolved_internal_id = int(channel_id) - except (TypeError, ValueError): - return JsonResponse({"error": "Not found"}, status=404) - if user.user_level < 10: user_profile_count = user.channel_profiles.count() @@ -623,14 +616,14 @@ def stream_xc(request, username, password, channel_id): if user_profile_count == 0: # No profile filtering - user sees all channels based on user_level filters = { - "id": resolved_internal_id, + "id": int(channel_id), "user_level__lte": user.user_level } channel = Channel.objects.filter(**filters).first() else: # User has specific limited profiles assigned filters = { - "id": resolved_internal_id, + "id": int(channel_id), "channelprofilemembership__enabled": True, "user_level__lte": user.user_level, "channelprofilemembership__channel_profile__in": user.channel_profiles.all() @@ -640,7 +633,7 @@ def stream_xc(request, username, password, channel_id): if not channel: return JsonResponse({"error": "Not found"}, status=404) else: - channel = get_object_or_404(Channel, id=resolved_internal_id) + channel = get_object_or_404(Channel, id=channel_id) if extension.lower() == '.mp4': force_format = 'fmp4' diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index 3f4b10ea..abd3089f 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -171,3 +171,53 @@ class ConvertTimestampToProviderTzTests(TestCase): convert_timestamp_to_provider_tz("garbage", "Europe/Brussels"), "garbage", ) + + +class GetProgrammeDurationTests(TestCase): + """Duration window resolution: programme length + buffer, capped, with a + safe default whenever the EPG lookup cannot resolve.""" + + def _channel_with_programme(self, minutes): + from datetime import datetime, timedelta, timezone as dt_timezone + from unittest.mock import MagicMock + + start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc) + programme = MagicMock( + start_time=start, end_time=start + timedelta(minutes=minutes) + ) + channel = MagicMock() + channel.epg_data.programs.filter.return_value.first.return_value = programme + return channel + + def test_duration_is_programme_length_plus_buffer(self): + from apps.timeshift.helpers import get_programme_duration + # 40-minute programme + 5-minute buffer. + self.assertEqual( + get_programme_duration(self._channel_with_programme(40), "2026-06-08:17-00"), + 45, + ) + + def test_duration_capped_at_max(self): + from apps.timeshift.helpers import get_programme_duration + self.assertEqual( + get_programme_duration(self._channel_with_programme(1000), "2026-06-08:17-00"), + 480, + ) + + def test_no_epg_data_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + channel = MagicMock(epg_data=None) + self.assertEqual(get_programme_duration(channel, "2026-06-08:17-00"), 120) + + def test_no_matching_programme_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + channel = MagicMock() + channel.epg_data.programs.filter.return_value.first.return_value = None + self.assertEqual(get_programme_duration(channel, "2026-06-08:17-00"), 120) + + def test_garbage_timestamp_falls_back_to_default(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import get_programme_duration + self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 4db30a66..2f2b6fbb 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from apps.timeshift import views from apps.proxy.live_proxy.input.http_streamer import find_ts_sync as _find_ts_sync @@ -171,14 +171,16 @@ class StreamFromProviderStatusMappingTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(mocked_open.call_count, 3) + @override_settings(CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} + }) @patch.object(views, "_open_upstream") def test_cache_promotes_winning_index_to_first(self, mocked_open): """Once a candidate succeeds for an account, the next request reorders the list so the cached winner is tried first — saving cascade overhead on fast-forward.""" - # The format cache lives in django.core.cache (Redis-backed), which - # persists across test runs — clear this account's key so the first - # request starts from the unordered candidate list. + # locmem cache: isolates this test from the shared Redis-backed django + # cache (which persists across runs and parallel test sessions). from django.core.cache import cache as django_cache django_cache.delete(views._FORMAT_CACHE_KEY.format(999)) @@ -252,6 +254,22 @@ class RedactUrlTests(TestCase): self.assertIsNone(views._redact_url(None)) +def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9, + stream_id="22372", account_type="XC", profile_id=31): + """Build a mocked catch-up Stream with its own provider context.""" + profile = MagicMock() + profile.id = profile_id + profile.custom_properties = {"server_info": {"timezone": provider_tz}} + m3u_account = MagicMock() + m3u_account.account_type = account_type + m3u_account.id = account_id + m3u_account.profiles.filter.return_value.first.return_value = profile + stream = MagicMock() + stream.m3u_account = m3u_account + stream.custom_properties = {"stream_id": stream_id} if stream_id else {} + return stream + + class TimeshiftProxyTimestampWiringTests(TestCase): """`timeshift_proxy` must convert the client's UTC timestamp to the serving provider's zone for the upstream URL, while keeping the ORIGINAL @@ -261,26 +279,15 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def setUp(self): self.factory = RequestFactory() - def _make_catchup(self, provider_tz): - profile = MagicMock() - profile.id = 31 - profile.custom_properties = {"server_info": {"timezone": provider_tz}} - m3u_account = MagicMock() - m3u_account.account_type = "XC" - m3u_account.id = 9 - m3u_account.profiles.filter.return_value.first.return_value = profile - stream = MagicMock() - stream.m3u_account = m3u_account - return {"stream": stream, "props": {"stream_id": "22372"}} - def _call(self, timestamp, provider_tz="Europe/Brussels"): request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts") - sentinel = MagicMock() + sentinel = MagicMock(status_code=200) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ - patch.object(views, "get_channel_catchup_info", - return_value=self._make_catchup(provider_tz)), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream(provider_tz)]), \ patch.object(views, "get_programme_duration", return_value=40) as duration_mock, \ patch.object(views, "build_timeshift_candidate_urls", return_value=["http://example.test/x.ts"]) as build_mock, \ @@ -309,12 +316,378 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def test_invalid_timestamp_rejected_before_upstream(self): request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ - patch.object(views, "get_channel_catchup_info") as catchup_mock, \ + patch.object(views, "get_channel_catchup_streams") as catchup_mock, \ patch.object(views, "_stream_from_provider") as stream_mock: channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy(request, "u", "p", "8", "garbage", "8.ts") self.assertEqual(response.status_code, 400) catchup_mock.assert_not_called() stream_mock.assert_not_called() + + def test_network_access_denied_returns_403(self): + # Same network-level gate as the live XC endpoint: when the request's + # network is not allowed for STREAMS, nothing else runs. + request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=False) as gate, \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_stream_from_provider") as stream_mock: + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(gate.call_args[0][1], "STREAMS") + channel_cls.objects.get.assert_not_called() + stream_mock.assert_not_called() + + +class TimeshiftProxyFailoverTests(TestCase): + """When the first catch-up stream's provider cannot serve the archive, + the proxy must fail over to the channel's next catch-up stream — each + attempt with its own provider context.""" + + def setUp(self): + self.factory = RequestFactory() + + def _call(self, streams, provider_responses): + request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=True) as limits_mock, \ + patch.object(views, "_stream_from_provider", + side_effect=provider_responses) as stream_mock: + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + return response, stream_mock, build_mock, limits_mock + + def test_second_stream_serves_after_first_fails(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + ok = MagicMock(status_code=200) + response, stream_mock, build_mock, _ = self._call( + streams, [MagicMock(status_code=404), ok] + ) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + # Each attempt used its own provider context. + self.assertEqual( + [c.args[0] for c in build_mock.call_args_list], + [streams[0].m3u_account, streams[1].m3u_account], + ) + self.assertEqual( + [c.args[1] for c in build_mock.call_args_list], ["111", "222"] + ) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2] + ) + + def test_all_streams_fail_returns_last_failure(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + last = MagicMock(status_code=404) + response, stream_mock, _, _ = self._call( + streams, [MagicMock(status_code=400), last] + ) + self.assertIs(response, last) + self.assertEqual(stream_mock.call_count, 2) + + def test_non_xc_and_missing_stream_id_are_skipped(self): + streams = [ + _make_catchup_stream(account_id=1, account_type="M3U"), + _make_catchup_stream(account_id=2, stream_id=None), + _make_catchup_stream(account_id=3, stream_id="333"), + ] + ok = MagicMock(status_code=200) + response, stream_mock, _, _ = self._call(streams, [ok]) + self.assertIs(response, ok) + # Only the eligible third stream produced an upstream attempt. + self.assertEqual(stream_mock.call_count, 1) + self.assertEqual(stream_mock.call_args.kwargs["account_id"], 3) + + def test_stream_limits_checked_once_for_the_request(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + _, _, _, limits_mock = self._call( + streams, [MagicMock(status_code=404), MagicMock(status_code=200)] + ) + self.assertEqual(limits_mock.call_count, 1) + + +class TimeshiftProxyFailoverHardeningTests(TestCase): + """Ban-safety and per-provider context guarantees of the failover loop.""" + + def setUp(self): + self.factory = RequestFactory() + + def _call(self, streams, provider_responses, limits=True): + request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=limits), \ + patch.object(views, "_stream_from_provider", + side_effect=provider_responses) as stream_mock: + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + return response, stream_mock, build_mock + + def test_decisive_failure_skips_same_accounts_other_streams(self): + # Account 1 carries two variants (e.g. FHD + HD). A decisive + # (auth/ban-class) failure on the first must NOT retry account 1's + # second stream — that would hammer a banning provider — but a + # DIFFERENT account stays fair game. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=1, stream_id="112"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + decisive = MagicMock(status_code=403, timeshift_decisive=True) + ok = MagicMock(status_code=200) + response, stream_mock, _ = self._call(streams, [decisive, ok]) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2] + ) + + def test_soft_failure_still_tries_same_accounts_other_streams(self): + # A soft failure (404: this stream's archive missing) is stream- + # specific — the same account's other variant may still have it. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=1, stream_id="112"), + ] + soft = MagicMock(status_code=404, timeshift_decisive=False) + ok = MagicMock(status_code=200) + response, stream_mock, _ = self._call(streams, [soft, ok]) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 1] + ) + + def test_each_stream_uses_its_own_provider_timezone(self): + # June: 17:00 UTC = 19:00 Brussels (CEST) but 13:00 New York (EDT). + # The converted timestamp must be recomputed per attempt. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", + provider_tz="Europe/Brussels"), + _make_catchup_stream(account_id=2, stream_id="222", + provider_tz="America/New_York"), + ] + response, _, build_mock = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False), + MagicMock(status_code=200)], + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + [c.args[2] for c in build_mock.call_args_list], + ["2026-06-08:19-00", "2026-06-08:13-00"], + ) + + def test_stream_limit_exceeded_returns_403_before_upstream(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111")] + response, stream_mock, _ = self._call(streams, [], limits=False) + self.assertEqual(response.status_code, 403) + stream_mock.assert_not_called() + + def test_no_catchup_streams_returns_400(self): + response, stream_mock, _ = self._call([], []) + self.assertEqual(response.status_code, 400) + stream_mock.assert_not_called() + + def test_all_streams_ineligible_returns_400(self): + streams = [ + _make_catchup_stream(account_id=1, account_type="M3U"), + _make_catchup_stream(account_id=2, stream_id=None), + ] + response, stream_mock, _ = self._call(streams, []) + self.assertEqual(response.status_code, 400) + stream_mock.assert_not_called() + + +class XcServerInfoUtcTests(TestCase): + """The XC server_info 'timezone triple' guarantee the timeshift chain + relies on: server_info.timezone is always UTC and time_now is UTC + wall-clock. (Tested here because catch-up seek correctness depends on + it: clients build the timeshift URL from this declared zone.)""" + + def test_server_info_is_strictly_utc(self): + from datetime import datetime, timezone as dt_timezone + from apps.output.views import _build_xc_server_info + + request = MagicMock(scheme="http") + info = _build_xc_server_info(request, "example.test", "9191") + self.assertEqual(info["timezone"], "UTC") + reported = datetime.strptime(info["time_now"], "%Y-%m-%d %H:%M:%S") + now_utc = datetime.now(dt_timezone.utc).replace(tzinfo=None) + self.assertLess(abs((now_utc - reported).total_seconds()), 60) + self.assertIsInstance(info["timestamp_now"], int) + + +class StreamFromProviderDecisiveEdgeTests(TestCase): + """Remaining decisive-status and transport-error paths of the cascade.""" + + def setUp(self): + self.kwargs = dict( + candidate_urls=[ + "http://example.test/timeshift/u/p/60/2026-05-12:17-00/1.ts", + "http://example.test/streaming/timeshift.php?stream=1&start=2026-05-12_17-00", + ], + user_agent="test-agent", + range_header=None, + virtual_channel_id="timeshift_1_2026-05-12-17-00_1", + client_id="timeshift_test456", + client_ip="127.0.0.1", + user=None, + channel_display_name="Test", + timestamp_utc="2026-05-12:17-00", + channel_logo_id=None, + m3u_profile_id=None, + debug=False, + ) + + @patch.object(views, "_open_upstream") + def test_406_is_decisive_and_marks_response(self, mocked_open): + # 406 = IP-wide block in the XC ban escalation — single attempt, + # generic 400 to the client, and the failover loop must see the + # decisive marker so it skips this account's other streams. + mocked_open.return_value = _fake_upstream(406) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + self.assertTrue(response.timeshift_decisive) + + @patch.object(views, "_open_upstream") + def test_404_failure_is_not_decisive(self, mocked_open): + mocked_open.return_value = _fake_upstream(404) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 404) + self.assertFalse(response.timeshift_decisive) + + @patch.object(views, "_open_upstream") + def test_connection_error_returns_400_after_single_attempt(self, mocked_open): + import requests as _requests + mocked_open.side_effect = _requests.exceptions.ConnectionError("boom") + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + # Transport errors are host-level, not auth/ban-class: the failover + # loop may still try a different account. + self.assertFalse(getattr(response, "timeshift_decisive", False)) + + +class CatchupStreamsDbTests(TestCase): + """get_channel_catchup_streams: the function that defines the failover + order — channelstream order, catch-up streams only, active accounts only.""" + + @classmethod + def setUpTestData(cls): + from apps.channels.models import Channel, ChannelStream, Stream + from apps.m3u.models import M3UAccount + + cls.active = M3UAccount.objects.create( + name="ts-test-active", server_url="http://example.test", + account_type="XC", is_active=True, + ) + cls.inactive = M3UAccount.objects.create( + name="ts-test-inactive", server_url="http://example.test", + account_type="XC", is_active=False, + ) + cls.channel = Channel.objects.create(name="ts-test-channel", is_catchup=True) + + def add(name, account, *, catchup, order): + s = Stream.objects.create( + name=name, url=f"http://example.test/{name}", + m3u_account=account, is_catchup=catchup, + ) + ChannelStream.objects.create(channel=cls.channel, stream=s, order=order) + return s + + cls.s_inactive = add("s-inactive", cls.inactive, catchup=True, order=0) + cls.s_second = add("s-second", cls.active, catchup=True, order=2) + cls.s_first = add("s-first", cls.active, catchup=True, order=1) + cls.s_live_only = add("s-live-only", cls.active, catchup=False, order=3) + + def test_ordered_active_catchup_streams_only(self): + from apps.channels.utils import get_channel_catchup_streams + + result = get_channel_catchup_streams(self.channel) + # Inactive-account and non-catchup streams excluded; channelstream order. + self.assertEqual([s.id for s in result], [self.s_first.id, self.s_second.id]) + + def test_channel_without_catchup_flag_returns_empty(self): + from apps.channels.models import Channel + from apps.channels.utils import get_channel_catchup_streams + + ch = Channel.objects.create(name="ts-test-nocatchup", is_catchup=False) + self.assertEqual(get_channel_catchup_streams(ch), []) + + +class AuthHelpersDbTests(TestCase): + """_authenticate_user (xc_password custom property) and + _user_can_access_channel (user_level gate) — exercised against real models + instead of being mocked away.""" + + @classmethod + def setUpTestData(cls): + from apps.accounts.models import User + from apps.channels.models import Channel + + cls.viewer = User.objects.create( + username="ts-test-viewer", user_level=0, + custom_properties={"xc_password": "right-pass"}, + ) + cls.no_xc = User.objects.create( + username="ts-test-noxc", user_level=10, + custom_properties={}, + ) + cls.basic_channel = Channel.objects.create(name="ts-test-basic", user_level=0) + cls.admin_channel = Channel.objects.create(name="ts-test-adult", user_level=10) + + def test_valid_xc_password_authenticates(self): + user = views._authenticate_user("ts-test-viewer", "right-pass") + self.assertIsNotNone(user) + self.assertEqual(user.id, self.viewer.id) + + def test_wrong_xc_password_rejected(self): + self.assertIsNone(views._authenticate_user("ts-test-viewer", "wrong")) + + def test_user_without_xc_password_rejected(self): + # Accounts with no xc_password set (e.g. admins) must be denied even + # if the caller guesses any string — there is nothing to compare to. + self.assertIsNone(views._authenticate_user("ts-test-noxc", "")) + self.assertIsNone(views._authenticate_user("ts-test-noxc", "anything")) + + def test_unknown_username_rejected(self): + self.assertIsNone(views._authenticate_user("ts-test-ghost", "x")) + + def test_user_level_gate(self): + # Level-0 viewer with no profiles: allowed on level-0, denied on level-10. + self.assertTrue(views._user_can_access_channel(self.viewer, self.basic_channel)) + self.assertFalse(views._user_can_access_channel(self.viewer, self.admin_channel)) diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 43e0e80a..651ede15 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -1,5 +1,7 @@ -"""XC catch-up (timeshift) HTTP view — proxies the provider's -`/streaming/timeshift.php` endpoint to clients like iPlayTV and TiviMate. +"""XC catch-up (timeshift) HTTP view — proxies the provider's catch-up +archive (PATH `/timeshift/.../{id}.ts` form first, `/streaming/timeshift.php` +query form as fallback) to clients like iPlayTV and TiviMate, with +multi-provider failover across the channel's catch-up streams. URL parameter quirk matching what iPlayTV / TiviMate emit: Position "stream_id" -> EPG channel number, IGNORED here. @@ -25,7 +27,7 @@ from django.http import ( from apps.accounts.models import User from apps.channels.models import Channel -from apps.channels.utils import get_channel_catchup_info +from apps.channels.utils import get_channel_catchup_streams from apps.proxy.live_proxy.config_helper import ConfigHelper from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState from apps.proxy.live_proxy.redis_keys import RedisKeys @@ -33,6 +35,7 @@ from apps.proxy.live_proxy.utils import get_client_ip from apps.proxy.live_proxy.input.http_streamer import find_ts_sync from apps.proxy.utils import check_user_stream_limits from core.utils import RedisClient +from dispatcharr.utils import network_access_allowed from .helpers import ( build_timeshift_candidate_urls, @@ -56,6 +59,10 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if user is None: return HttpResponseForbidden("Invalid credentials") + # Same network-level gate as the live XC stream endpoint (stream_xc). + if not network_access_allowed(request, "STREAMS", user): + return HttpResponseForbidden("Access denied") + try: channel = Channel.objects.get(id=int(raw_id)) except (Channel.DoesNotExist, ValueError, TypeError): @@ -71,115 +78,132 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if parse_catchup_timestamp(timestamp) is None: return HttpResponseBadRequest("Invalid timestamp") - catchup = get_channel_catchup_info(channel) - if catchup is None: + catchup_streams = get_channel_catchup_streams(channel) + if not catchup_streams: return HttpResponseBadRequest("Timeshift not supported for this channel") - catchup_stream = catchup["stream"] - props = catchup["props"] - m3u_account = catchup_stream.m3u_account - if m3u_account is None or m3u_account.account_type != "XC": - return HttpResponseBadRequest("Channel not from Xtream Codes provider") - # Verbose timeshift logging follows the standard logger level (set via # DISPATCHARR_LOG_LEVEL / the logging config), not a per-feature toggle. debug = logger.isEnabledFor(logging.DEBUG) - stream_id_value = (props or {}).get("stream_id") - if stream_id_value is None: - return HttpResponseBadRequest("Cannot build timeshift URL") - # The client supplies the catch-up timestamp in UTC — Dispatcharr's XC API # surface is strictly UTC (server_info.timezone="UTC", EPG start/end in UTC), - # and the client builds the URL from those UTC strings. Real XC providers, - # however, index their archive in their OWN local zone (the - # server_info.timezone they report on auth). So convert the UTC instant to - # the serving provider's zone before building the upstream URL — empirically - # a provider reads `17-00` as 17:00 LOCAL, not UTC, so skipping this would - # seek ~1-2h off. The EPG duration lookup stays on the original UTC timestamp - # (the EPG is UTC too). This is the ONLY timezone conversion in the chain. + # and the client builds the URL from those UTC strings. The EPG duration + # lookup therefore uses the original UTC value (programmes are stored in + # UTC); the per-provider conversion happens inside the failover loop below. duration_minutes = get_programme_duration(channel, timestamp) - # Default profile carries the provider's server_info (its reported timezone) - # and is reused below for the Stats card's M3U profile name. - default_profile = m3u_account.profiles.filter(is_default=True).first() - provider_tz_name = None - if default_profile is not None: - _server_info = (default_profile.custom_properties or {}).get("server_info") or {} - if isinstance(_server_info, dict): - provider_tz_name = _server_info.get("timezone") - provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) - if debug and provider_timestamp != timestamp: - logger.debug( - "Timeshift tz convert: %s UTC -> %s (provider tz=%s)", - timestamp, provider_timestamp, provider_tz_name, - ) - - # Ordered upstream candidates, PATH form first — see - # build_timeshift_candidate_urls() for the full rationale (the QUERY form is - # a last-resort fallback because some providers return LIVE on it, ignoring - # the requested timestamp). We try them in order until one returns a - # streamable MPEG-TS response — see `_stream_from_provider`. - candidate_urls = build_timeshift_candidate_urls( - m3u_account, stream_id_value, provider_timestamp, duration_minutes - ) - - try: - user_agent = m3u_account.get_user_agent().user_agent - except AttributeError: - user_agent = "" - safe_ts = timestamp.replace(":", "-").replace("/", "-") - virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" client_id = f"timeshift_{uuid.uuid4().hex[:16]}" client_ip = get_client_ip(request) range_header = request.META.get("HTTP_RANGE") - - # Channel logo + M3U default-profile id for the Stats card (default_profile - # was already resolved above for the provider timezone). channel_logo_id = getattr(channel, "logo_id", None) - m3u_profile_id = default_profile.id if default_profile is not None else None - # Free the provider slot before connecting upstream. - # 1. Enforce user stream limits — this terminates the oldest active stream - # (live, timeshift, or VOD) via the Redis stop-key mechanism, same as - # the live and VOD proxy entry points. - if not check_user_stream_limits(user, client_id, media_id=virtual_channel_id): + # Enforce user stream limits once, before connecting upstream — this + # terminates the oldest active stream (live, timeshift, or VOD) via the + # Redis stop-key mechanism, same as the live and VOD proxy entry points. + # (No explicit ChannelService.stop_channel(): that would kill other users' + # live streams on the same channel. The media_id only matters for the + # live same-channel exemption, so a channel-scoped id is enough.) + if not check_user_stream_limits( + user, client_id, media_id=f"timeshift_{channel.id}_{safe_ts}" + ): return HttpResponseForbidden("Stream limit exceeded") - # Note: no explicit ChannelService.stop_channel() here. The - # check_user_stream_limits() call above already terminates the oldest - # active stream (live, timeshift, or VOD) via the Redis stop-key - # mechanism — same pattern as the live and VOD proxy entry points. - # Calling stop_channel() unconditionally would kill other users' live - # streams on the same channel. + # Failover: try each catch-up stream in channelstream order — mirroring how + # live playback walks a channel's stream list. Every attempt carries ITS + # OWN provider context (account, provider stream id, reported timezone, + # user-agent, per-account format cache). Ban safety is per ACCOUNT: when an + # account fails decisively (401/403/406 — auth or ban-class status), its + # other catch-up streams (e.g. FHD/HD variants of the same channel) are + # skipped too, so the cascade never hammers a banning provider; a DIFFERENT + # account is a different host and remains safe to try. + last_response = None + decisive_accounts = set() + for catchup_stream in catchup_streams: + m3u_account = catchup_stream.m3u_account + if m3u_account is None or m3u_account.account_type != "XC": + continue + if m3u_account.id in decisive_accounts: + continue - if debug: - logger.debug( - "Timeshift request: channel=%s ts=%s provider_sid=%s vid=%s client=%s range=%s", - channel.name, - timestamp, - stream_id_value, - virtual_channel_id, - client_id, - range_header or "(none)", + stream_id_value = (catchup_stream.custom_properties or {}).get("stream_id") + if stream_id_value is None: + continue + + # Real XC providers index their archive in their OWN local zone (the + # server_info.timezone they report on auth — stored on the default + # profile). Convert the UTC instant to that zone for the upstream URL — + # empirically a provider reads `17-00` as 17:00 LOCAL, not UTC, so + # skipping this would seek 1-2h off. This is the ONLY timezone + # conversion in the chain, and it is per-provider. + default_profile = m3u_account.profiles.filter(is_default=True).first() + provider_tz_name = None + if default_profile is not None: + _server_info = (default_profile.custom_properties or {}).get("server_info") or {} + if isinstance(_server_info, dict): + provider_tz_name = _server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) + + # Ordered upstream candidates, PATH form first — see + # build_timeshift_candidate_urls() for the full rationale (the QUERY + # form is a last-resort fallback because some providers return LIVE on + # it, ignoring the requested timestamp). + candidate_urls = build_timeshift_candidate_urls( + m3u_account, stream_id_value, provider_timestamp, duration_minutes ) - return _stream_from_provider( - candidate_urls=candidate_urls, - user_agent=user_agent, - range_header=range_header, - virtual_channel_id=virtual_channel_id, - client_id=client_id, - client_ip=client_ip, - user=user, - channel_display_name=channel.name, - timestamp_utc=timestamp, - channel_logo_id=channel_logo_id, - m3u_profile_id=m3u_profile_id, - debug=debug, - account_id=m3u_account.id, - ) + try: + user_agent = m3u_account.get_user_agent().user_agent + except AttributeError: + user_agent = "" + + virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" + m3u_profile_id = default_profile.id if default_profile is not None else None + + if debug: + logger.debug( + "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " + "account=%s provider_sid=%s vid=%s client=%s range=%s", + channel.name, timestamp, provider_tz_name, provider_timestamp, + m3u_account.id, stream_id_value, virtual_channel_id, client_id, + range_header or "(none)", + ) + + response = _stream_from_provider( + candidate_urls=candidate_urls, + user_agent=user_agent, + range_header=range_header, + virtual_channel_id=virtual_channel_id, + client_id=client_id, + client_ip=client_ip, + user=user, + channel_display_name=channel.name, + timestamp_utc=timestamp, + channel_logo_id=channel_logo_id, + m3u_profile_id=m3u_profile_id, + debug=debug, + account_id=m3u_account.id, + ) + if response.status_code < 400: + return response + + last_response = response + if getattr(response, "timeshift_decisive", False): + decisive_accounts.add(m3u_account.id) + logger.warning( + "Timeshift attempt failed (HTTP %d%s) on account %s for channel %s — " + "trying next catch-up stream", + response.status_code, + ", decisive: skipping this account's other streams" + if m3u_account.id in decisive_accounts else "", + m3u_account.id, channel.name, + ) + + if last_response is not None: + return last_response + # Streams existed but none was usable (non-XC accounts / missing stream_id). + return HttpResponseBadRequest("Cannot build timeshift URL") # --------------------------------------------------------------------------- @@ -320,7 +344,15 @@ def _unregister_stats_client(redis_client, virtual_channel_id, client_id): def _open_upstream(url, user_agent, range_header): - """Open the upstream HTTP request (status + headers known synchronously).""" + """Open the upstream HTTP request (status + headers known synchronously). + + Redirects are followed on purpose: XC providers routinely 302 from the + main host to a load-balanced streaming node carrying a session token, so + disabling redirects would break normal catch-up. The cascade therefore + observes the FINAL status; a ban-onset 302 that redirects somewhere + non-streamable surfaces as a failed TS-sync peek (soft reject) or an + error status. + """ # identity: the TS-sync peek reads raw bytes (response.raw), which are NOT # transparently decompressed — a gzip-encoded body would fail the sync check. headers = {"Accept-Encoding": "identity"} @@ -402,6 +434,7 @@ def _stream_from_provider( last_status = None last_url = ordered_urls[0] winning_index = None + decisive_failure = False for url, orig_idx in zip(ordered_urls, original_indices): try: response = _open_upstream(url, user_agent, range_header) @@ -451,8 +484,12 @@ def _stream_from_provider( continue response.close() # Decisive statuses where trying other URL shapes can't help and may - # escalate an IP ban: auth failures (401/403), IP-level block (406), and - # any redirect (3xx) — for XC providers a 302 is the first sign of a ban. + # escalate an IP ban: auth failures (401/403) and IP-level block (406). + # The 3xx case is defense-in-depth only: requests follows redirects + # transparently (XC providers legitimately 302 to load-balanced + # streaming nodes, so redirects MUST be followed), meaning a 3xx can + # only surface here if that ever changes — but if one does, it is the + # documented first sign of an IP ban and must stop the cascade. # A 5xx is different: it is usually format-specific — some XC servers run # PHP with display_errors off, so the "Undefined array key" warning that # another server emits as 200+text becomes a hard 500. The very next @@ -460,6 +497,7 @@ def _stream_from_provider( # giving up (this is why catch-up appeared broken on providers that 500). code = response.status_code if code in (401, 403, 406) or 300 <= code < 400: + decisive_failure = True break if winning_index is not None: @@ -472,10 +510,16 @@ def _stream_from_provider( # correctly: 404 stops retry loops (catch-up not yet indexed), 403 # surfaces auth problems. Everything else stays a generic 400. if last_status == 404: - return HttpResponseNotFound("Catch-up not available yet") - if last_status == 403: - return HttpResponseForbidden("Provider denied access") - return HttpResponseBadRequest("Provider error") + failure = HttpResponseNotFound("Catch-up not available yet") + elif last_status == 403: + failure = HttpResponseForbidden("Provider denied access") + else: + failure = HttpResponseBadRequest("Provider error") + # Tell the failover loop whether this account failed DECISIVELY + # (auth/ban-class status): its other catch-up streams must be skipped, + # while a different provider remains safe to try. + failure.timeshift_decisive = decisive_failure + return failure content_type = upstream.headers.get("Content-Type", "video/mp2t") content_range = upstream.headers.get("Content-Range", "") diff --git a/core/models.py b/core/models.py index 922a1e3e..ffb5d2c7 100644 --- a/core/models.py +++ b/core/models.py @@ -195,16 +195,6 @@ NETWORK_ACCESS_KEY = "network_access" SYSTEM_SETTINGS_KEY = "system_settings" EPG_SETTINGS_KEY = "epg_settings" USER_LIMITS_SETTINGS_KEY = "user_limit_settings" -TIMESHIFT_SETTINGS_KEY = "timeshift_settings" - -# The XC API surface is strictly UTC, so there is no timezone setting here: -# server_info.timezone, the EPG start/end strings and time_now are all UTC, and -# any provider-local catch-up conversion happens at proxy time against the -# serving provider's own server_info.timezone (apps/timeshift/views). -# Verbose timeshift logging follows the standard logger DEBUG level, not a toggle. -TIMESHIFT_DEFAULTS = { - "xmltv_prev_days_override": 0, -} class CoreSettings(models.Model): @@ -406,6 +396,9 @@ class CoreSettings(models.Model): "channel_shutdown_delay": 0, "channel_init_grace_period": 5, "new_client_behind_seconds": 5, + # XC catch-up: forced XMLTV lookback window in days (0 = auto-detect + # from the providers' largest tv_archive_duration, capped at 30). + "xmltv_prev_days_override": 0, }) # System Settings @@ -447,22 +440,6 @@ class CoreSettings(models.Model): "terminate_oldest": True, }) - # Timeshift Settings (XC catch-up) - @classmethod - def get_timeshift_settings(cls): - """Return all timeshift-related settings, falling back to neutral defaults. - - Keys not in TIMESHIFT_DEFAULTS are ignored, so stale stored values from - removed settings are filtered out automatically. Writes go through the - generic settings API (same path as every other settings group). - """ - stored = cls._get_group(TIMESHIFT_SETTINGS_KEY, {}) or {} - merged = dict(TIMESHIFT_DEFAULTS) - for key, value in stored.items(): - if key in merged and value is not None: - merged[key] = value - return merged - class SystemEvent(models.Model): """ diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index f2ae0b8f..ae85b410 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -450,7 +450,7 @@ const StreamConnectionCard = ({ ); }, - mantineExpandButtonProps: ({ row }) => ({ + mantineExpandButtonProps: ({ row, table }) => ({ size: 'xs', style: { transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index 52440769..9b2c2c62 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -25,6 +25,7 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { 'channel_shutdown_delay', 'channel_init_grace_period', 'new_client_behind_seconds', + 'xmltv_prev_days_override', ].includes(key); }; const isFloatField = (key) => { @@ -39,7 +40,9 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { ? 300 : key === 'new_client_behind_seconds' ? 120 - : 60; + : key === 'xmltv_prev_days_override' + ? 30 + : 60; }; return ( <> diff --git a/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx b/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx deleted file mode 100644 index 93d0652c..00000000 --- a/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import useSettingsStore from '../../../store/settings.jsx'; -import React, { useEffect, useState } from 'react'; -import { - getChangedSettings, - parseSettings, - saveChangedSettings, -} from '../../../utils/pages/SettingsUtils.js'; -import { Button, NumberInput, Stack, Text } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { - getTimeshiftSettingsFormInitialValues, - getTimeshiftSettingsFormValidation, -} from '../../../utils/forms/settings/TimeshiftSettingsFormUtils.js'; - -const TimeshiftSettingsForm = React.memo(({ active }) => { - const settings = useSettingsStore((s) => s.settings); - const [saved, setSaved] = useState(false); - - const form = useForm({ - mode: 'controlled', - initialValues: getTimeshiftSettingsFormInitialValues(), - validate: getTimeshiftSettingsFormValidation(), - }); - - useEffect(() => { - if (!active) setSaved(false); - }, [active]); - - useEffect(() => { - if (settings) { - const parsed = parseSettings(settings); - form.setValues({ - xmltv_prev_days_override: parsed.xmltv_prev_days_override ?? 0, - }); - } - }, [settings]); - - const onSubmit = async () => { - setSaved(false); - const changed = getChangedSettings(form.getValues(), settings); - try { - await saveChangedSettings(settings, changed); - setSaved(true); - } catch (error) { - console.error('Error saving timeshift settings:', error); - } - }; - - return ( - - - - XC catch-up serves EPG and archive timestamps in UTC. Each provider's - own timezone is applied automatically at request time, so there is no - timezone to configure here. The only setting is the XMLTV lookback - window. - - - - - - ); -}); - -export default TimeshiftSettingsForm; diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 92aba531..32aa4bed 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -60,6 +60,11 @@ export const PROXY_SETTINGS_OPTIONS = { description: 'Seconds of received buffer to start behind live when a new client connects (0 = start at live). Note: this is chunk receive time, not video duration.', }, + xmltv_prev_days_override: { + label: 'XMLTV prev_days Override (catch-up)', + description: + 'Days of past programmes in the XC EPG output. 0 = auto-detect from the providers’ tv_archive_duration (capped at 30).', + }, }; export const USER_LIMITS_OPTIONS = { diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index ab4792e4..29d46dd8 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -48,9 +48,6 @@ const SystemSettingsForm = React.lazy( const NavOrderForm = React.lazy( () => import('../components/forms/settings/NavOrderForm.jsx') ); -const TimeshiftSettingsForm = React.lazy( - () => import('../components/forms/settings/TimeshiftSettingsForm.jsx') -); const SettingsPage = () => { const authUser = useAuthStore((s) => s.user); @@ -235,19 +232,6 @@ const SettingsPage = () => { - - - Timeshift - - - }> - - - - - )} diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js index eddbba91..93cccc9b 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -15,5 +15,6 @@ export const getProxySettingDefaults = () => { channel_shutdown_delay: 0, channel_init_grace_period: 5, new_client_behind_seconds: 5, + xmltv_prev_days_override: 0, }; }; diff --git a/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js b/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js deleted file mode 100644 index 880c32b0..00000000 --- a/frontend/src/utils/forms/settings/TimeshiftSettingsFormUtils.js +++ /dev/null @@ -1,9 +0,0 @@ -export const getTimeshiftSettingsFormInitialValues = () => { - return { - xmltv_prev_days_override: 0, - }; -}; - -export const getTimeshiftSettingsFormValidation = () => { - return {}; -}; diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js index 21c48269..c1fd0908 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -62,6 +62,7 @@ describe('ProxySettingsFormUtils', () => { channel_shutdown_delay: 0, channel_init_grace_period: 5, new_client_behind_seconds: 5, + xmltv_prev_days_override: 0, }); }); @@ -82,6 +83,7 @@ describe('ProxySettingsFormUtils', () => { expect(typeof result.channel_shutdown_delay).toBe('number'); expect(typeof result.channel_init_grace_period).toBe('number'); expect(typeof result.new_client_behind_seconds).toBe('number'); + expect(typeof result.xmltv_prev_days_override).toBe('number'); }); }); }); diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index e796af96..acdc6cae 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -24,7 +24,6 @@ export const saveChangedSettings = async (settings, changedSettings) => { dvr_settings: {}, backup_settings: {}, system_settings: {}, - timeshift_settings: {}, }; // Map of field prefixes to their groups @@ -70,7 +69,6 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'auto_import_mapped_files', 'enable_ip_lookup', ]; - const timeshiftFields = ['xmltv_prev_days_override']; for (const formKey in changedSettings) { let value = changedSettings[formKey]; @@ -127,7 +125,6 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'retention_count', 'schedule_day_of_week', 'max_system_events', - 'xmltv_prev_days_override', ]; if (numericFields.includes(formKey) && value != null) { value = typeof value === 'number' ? value : parseInt(value, 10); @@ -154,8 +151,6 @@ export const saveChangedSettings = async (settings, changedSettings) => { groupedChanges.backup_settings[formKey] = value; } else if (systemFields.includes(formKey)) { groupedChanges.system_settings[formKey] = value; - } else if (timeshiftFields.includes(formKey)) { - groupedChanges.timeshift_settings[formKey] = value; } } @@ -371,18 +366,8 @@ export const parseSettings = (settings) => { : true; } - // Timeshift settings - direct key mapping. - // The only key is the XMLTV lookback override: the XC API surface is strictly - // UTC (no timezone setting) and verbose logging follows the standard logger level. - const timeshiftSettings = settings['timeshift_settings']?.value; - parsed.xmltv_prev_days_override = - timeshiftSettings && timeshiftSettings.xmltv_prev_days_override != null - ? typeof timeshiftSettings.xmltv_prev_days_override === 'number' - ? timeshiftSettings.xmltv_prev_days_override - : parseInt(timeshiftSettings.xmltv_prev_days_override, 10) || 0 - : 0; - // Proxy and network access are already grouped objects + // (xmltv_prev_days_override lives inside proxy_settings) if (settings['proxy_settings']?.value) { parsed.proxy_settings = settings['proxy_settings'].value; } From c91f4f9a6e606e9676728d257f20d0181ac3df94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Marcoux?= Date: Fri, 12 Jun 2026 16:36:02 +0200 Subject: [PATCH 12/56] feat(timeshift): provider pool accounting, per-channel session takeover, rollup self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns catch-up with how live and VOD manage provider capacity: - Reserve a provider profile slot (connection_pool) before every upstream connect, walking the account's active profiles default-first when the default is at capacity (profile_full/credential_full are transient and never mark the account decisive). Credentials for the reserved profile are resolved via get_transformed_credentials — the same credential extraction live playback uses — so pool accounting and real upstream usage always agree. All eligible streams blocked on capacity alone returns 503 (the VOD pool-exhausted precedent). - Release exactly once via a one-shot Redis ownership token consumed with a transactional GET+DEL: the generator finally, the response-close wrapper, failed failover attempts and session takeover all share it, so no path can double-decrement and a client disconnecting before the first chunk still releases (Django registers the iterator's close() as a resource closer). Failures between reservation and the streaming response owning the slot release before propagating; an ownership-token write failure releases directly and reports transient unavailability. - One catch-up session per user and channel: a new request (programme jump or seek) displaces the user's previous session on that channel — its slot is released synchronously, its stats are unregistered, and its generator is stopped through the standard stop-key mechanism — so rapid seeking cannot stack upstream provider connections. - rollup_channel_catchup_fields self-heals channels left flagged is_catchup with no remaining catch-up stream (outside the account-scoped CTE), covering bulk removals on manual/multi-provider channels regardless of how the link rows disappeared. A regression test locks the ChannelStream post_delete signal firing on queryset bulk deletes. Backend test suite grows to 94 (slot reservation/release on every failover outcome, profile walk, mixed capacity-vs-upstream precedence, exception-path release, token exactly-once semantics, takeover scoping and ordering, never-started-generator release, rollup self-heal). --- CHANGELOG.md | 4 +- apps/m3u/tasks.py | 25 +- apps/timeshift/helpers.py | 39 +- apps/timeshift/tests/test_helpers.py | 26 +- apps/timeshift/tests/test_views.py | 533 ++++++++++++++++++++++++++- apps/timeshift/views.py | 347 ++++++++++++++--- 6 files changed, 893 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c070643b..4e66fcf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,9 +159,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container. - **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container. - **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. - - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries. + - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; the per-account rollup also self-heals channels left flagged with no remaining catch-up stream (e.g. after stale-stream cleanup on manual/multi-provider channels). - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. + - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it exactly once when the session ends (one-shot Redis token, covering disconnects before the first chunk). When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. + - **One catch-up session per user and channel.** A seek or programme jump displaces the user's previous session on the same channel: its provider slot is released synchronously and the old stream is stopped through the standard stop-key mechanism, so rapid seeking can't stack upstream provider connections. - **Single setting**, `xmltv_prev_days_override`, under `Settings → Proxy Settings` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 7eb6443b..8dcc8b68 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1818,7 +1818,8 @@ def rollup_channel_catchup_fields(account_id): Updates ``is_catchup`` and ``catchup_days`` on every Channel that has at least one Stream belonging to *account_id* (auto-created or manually - assigned). + assigned), then self-heals any channel left flagged with no catch-up + stream at all. Called from the account-refresh pipeline after streams have been written so that both new and existing channels reflect the current @@ -1859,6 +1860,28 @@ def rollup_channel_catchup_fields(account_id): WHERE c.id = agg.channel_id """, [account_id]) + # Self-heal: reset channels still flagged as catch-up that no longer + # have ANY catch-up stream. The CTE above only reaches channels that + # still hold at least one stream from this account, so a channel whose + # last stream was just removed (stale cleanup, manual unassignment) + # falls outside its scope. The ChannelStream post_delete signal + # normally resets those rows already — this pass guarantees the + # invariant regardless of how the link rows disappeared (raw SQL, + # signal-less bulk operations, historic staleness). Idempotent and + # cheap: ``is_catchup`` is indexed and TRUE on a small minority of + # channels. + cur.execute(""" + UPDATE dispatcharr_channels_channel c + SET is_catchup = FALSE, catchup_days = 0 + WHERE c.is_catchup = TRUE + AND NOT EXISTS ( + SELECT 1 + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + ) + """) + @shared_task def sync_auto_channels(account_id, scan_start_time=None): diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 041b6ce5..1075946a 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -1,6 +1,7 @@ """URL builders + timestamp conversion + archive-days probe for XC catch-up.""" import logging +from collections import namedtuple from datetime import datetime, timezone from urllib.parse import quote from zoneinfo import ZoneInfo @@ -9,6 +10,16 @@ from django.core.cache import cache logger = logging.getLogger(__name__) +#: Resolved upstream credentials for one catch-up attempt. Produced from +#: ``get_transformed_credentials(account, reserved_profile)`` so the URL is +#: built with the credentials of the profile whose pool slot was actually +#: reserved — alternate profiles express different logins as URL regex +#: transforms, and using the raw account fields for them would desynchronize +#: pool accounting from real upstream usage. +TimeshiftCredentials = namedtuple( + "TimeshiftCredentials", ("server_url", "username", "password") +) + DEFAULT_DURATION_MINUTES = 120 DURATION_BUFFER_MINUTES = 5 MAX_DURATION_MINUTES = 480 @@ -129,33 +140,33 @@ def get_programme_duration(channel, timestamp_str): return DEFAULT_DURATION_MINUTES -def build_timeshift_url_format_a(m3u_account, stream_id, timestamp, duration_minutes): +def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes): """Format A: `/streaming/timeshift.php?username=&password=&stream=&start=&duration=`.""" # Credentials are URL-encoded: a `&`, `/` or `#` in the password would # otherwise corrupt the URL structure. return ( - f"{m3u_account.server_url.rstrip('/')}/streaming/timeshift.php" - f"?username={quote(str(m3u_account.username), safe='')}" - f"&password={quote(str(m3u_account.password), safe='')}" + f"{creds.server_url.rstrip('/')}/streaming/timeshift.php" + f"?username={quote(str(creds.username), safe='')}" + f"&password={quote(str(creds.password), safe='')}" f"&stream={stream_id}" f"&start={timestamp}" f"&duration={duration_minutes}" ) -def build_timeshift_url_format_b(m3u_account, stream_id, timestamp, duration_minutes): +def build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes): """Format B: `/timeshift/{user}/{pass}/{duration}/{timestamp}/{stream_id}.ts`.""" return ( - f"{m3u_account.server_url.rstrip('/')}/timeshift" - f"/{quote(str(m3u_account.username), safe='')}" - f"/{quote(str(m3u_account.password), safe='')}" + f"{creds.server_url.rstrip('/')}/timeshift" + f"/{quote(str(creds.username), safe='')}" + f"/{quote(str(creds.password), safe='')}" f"/{duration_minutes}" f"/{timestamp}" f"/{stream_id}.ts" ) -def build_timeshift_candidate_urls(m3u_account, stream_id, timestamp, duration_minutes): +def build_timeshift_candidate_urls(creds, stream_id, timestamp, duration_minutes): """Ordered upstream catch-up candidates — PATH form first. Two URL layouts exist on XC servers and they do NOT behave the same: @@ -180,12 +191,12 @@ def build_timeshift_candidate_urls(m3u_account, stream_id, timestamp, duration_m sql_ts = format_timestamp_as_sql_datetime(timestamp) return [ # PATH form first — it seeks the archive correctly. - build_timeshift_url_format_b(m3u_account, stream_id, timestamp, duration_minutes), - build_timeshift_url_format_b(m3u_account, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, underscore_ts, duration_minutes), # QUERY form fallback — may return LIVE on some providers (see above). - build_timeshift_url_format_a(m3u_account, stream_id, underscore_ts, duration_minutes), - build_timeshift_url_format_a(m3u_account, stream_id, sql_ts, duration_minutes), - build_timeshift_url_format_a(m3u_account, stream_id, timestamp, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, sql_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes), ] diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index abd3089f..2c29e337 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -3,6 +3,7 @@ from django.test import TestCase from apps.timeshift.helpers import ( + TimeshiftCredentials, build_timeshift_candidate_urls, build_timeshift_url_format_a, build_timeshift_url_format_b, @@ -12,11 +13,10 @@ from apps.timeshift.helpers import ( ) -class _FakeAccount: - def __init__(self): - self.server_url = "http://example.test" - self.username = "user" - self.password = "pass" +def _make_creds(): + # The builders consume resolved per-profile credentials, never an account + # object — get_transformed_credentials() produces these in the view. + return TimeshiftCredentials("http://example.test", "user", "pass") class TimestampFormatTests(TestCase): @@ -56,11 +56,11 @@ class TimestampFormatTests(TestCase): class BuildTimeshiftUrlTests(TestCase): def setUp(self): - self.account = _FakeAccount() + self.creds = _make_creds() def test_format_a_passes_dash_shape_unchanged(self): url = build_timeshift_url_format_a( - self.account, "22372", "2026-05-12:19-00", 40 + self.creds, "22372", "2026-05-12:19-00", 40 ) self.assertIn("start=2026-05-12:19-00", url) self.assertIn("stream=22372", url) @@ -68,13 +68,13 @@ class BuildTimeshiftUrlTests(TestCase): def test_format_a_passes_sql_shape_unchanged(self): url = build_timeshift_url_format_a( - self.account, "22372", "2026-05-12 19:00:00", 40 + self.creds, "22372", "2026-05-12 19:00:00", 40 ) self.assertIn("start=2026-05-12 19:00:00", url) def test_format_b_path_with_dash_shape(self): url = build_timeshift_url_format_b( - self.account, "22372", "2026-05-12:19-00", 40 + self.creds, "22372", "2026-05-12:19-00", 40 ) self.assertIn("/40/2026-05-12:19-00/22372.ts", url) @@ -86,7 +86,7 @@ class CandidateOrderingTests(TestCase): "catch-up plays the live stream instead of the requested programme" bug.""" def setUp(self): - self.account = _FakeAccount() + self.creds = _make_creds() def _is_path_form(self, url): return "/timeshift/" in url and url.endswith(".ts") and "timeshift.php" not in url @@ -95,7 +95,7 @@ class CandidateOrderingTests(TestCase): return "timeshift.php?" in url def test_every_path_candidate_precedes_every_query_candidate(self): - urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12:19-00", 40) + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12:19-00", 40) path_indices = [i for i, u in enumerate(urls) if self._is_path_form(u)] query_indices = [i for i, u in enumerate(urls) if self._is_query_form(u)] # Each URL is classified as exactly one form. @@ -105,14 +105,14 @@ class CandidateOrderingTests(TestCase): self.assertLess(max(path_indices), min(query_indices)) def test_first_candidate_is_path_form_with_canonical_dash_timestamp(self): - urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12:19-00", 40) + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12:19-00", 40) self.assertTrue(self._is_path_form(urls[0])) # Canonical colon-dash timestamp, passed through unchanged. self.assertIn("/40/2026-05-12:19-00/22372.ts", urls[0]) def test_accepts_underscore_input_timestamp(self): # Client may send the underscore shape; PATH form still leads. - urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12_19-00", 40) + urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12_19-00", 40) self.assertTrue(self._is_path_form(urls[0])) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 2f2b6fbb..59b8fd0b 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -255,21 +255,91 @@ class RedactUrlTests(TestCase): def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9, - stream_id="22372", account_type="XC", profile_id=31): - """Build a mocked catch-up Stream with its own provider context.""" + stream_id="22372", account_type="XC", profile_id=31, + extra_profiles=()): + """Build a mocked catch-up Stream with its own provider context. + + The default (tz-bearing) profile leads the active-profile list the view + walks; ``extra_profiles`` appends alternate (non-default) profiles for + capacity-walk tests. + """ profile = MagicMock() profile.id = profile_id + profile.is_default = True profile.custom_properties = {"server_info": {"timezone": provider_tz}} m3u_account = MagicMock() m3u_account.account_type = account_type m3u_account.id = account_id - m3u_account.profiles.filter.return_value.first.return_value = profile + m3u_account.profiles.filter.return_value = [profile, *extra_profiles] stream = MagicMock() stream.m3u_account = m3u_account stream.custom_properties = {"stream_id": stream_id} if stream_id else {} return stream +def _make_alt_profile(profile_id): + """A non-default active profile for the capacity walk.""" + profile = MagicMock() + profile.id = profile_id + profile.is_default = False + profile.custom_properties = {} + return profile + + +class _FakeRedis: + """Just enough of the redis-py surface for the slot-token protocol: + setex/get/delete plus a transactional pipeline doing GET+DEL.""" + + def __init__(self): + self.store = {} + + def setex(self, key, ttl, value): + self.store[key] = str(value) + + def set(self, key, value): + self.store[key] = str(value) + + def get(self, key): + return self.store.get(key) + + def delete(self, *keys): + return sum(1 for k in keys if self.store.pop(k, None) is not None) + + def exists(self, key): + return 1 if key in self.store else 0 + + def pipeline(self, transaction=False): + return _FakeRedisPipeline(self) + + +class _FakeRedisPipeline: + def __init__(self, redis): + self._redis = redis + self._ops = [] + + def get(self, key): + self._ops.append(("get", key)) + + def delete(self, key): + self._ops.append(("delete", key)) + + def execute(self): + results = [] + for op, key in self._ops: + if op == "get": + results.append(self._redis.get(key)) + else: + results.append(self._redis.delete(key)) + self._ops = [] + return results + + +def _fake_creds(acc, prof): + """Distinguishable per-account credentials, mirroring what + get_transformed_credentials returns for the reserved profile.""" + return (f"http://a{acc.id}.test", f"u{acc.id}", "p") + + class TimeshiftProxyTimestampWiringTests(TestCase): """`timeshift_proxy` must convert the client's UTC timestamp to the serving provider's zone for the upstream URL, while keeping the ORIGINAL @@ -292,7 +362,13 @@ class TimeshiftProxyTimestampWiringTests(TestCase): patch.object(views, "build_timeshift_candidate_urls", return_value=["http://example.test/x.ts"]) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ patch.object(views, "_stream_from_provider", return_value=sentinel) as stream_mock: + redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) response = views.timeshift_proxy(request, "u", "p", "8", timestamp, "8.ts") return response, sentinel, build_mock, duration_mock, stream_mock @@ -363,12 +439,20 @@ class TimeshiftProxyFailoverTests(TestCase): patch.object(views, "build_timeshift_candidate_urls", return_value=["http://example.test/x.ts"]) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=True) as limits_mock, \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", + side_effect=_fake_creds) as creds_mock, \ + patch.object(views, "get_user_active_connections", return_value=[]), \ patch.object(views, "_stream_from_provider", side_effect=provider_responses) as stream_mock: + redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) response = views.timeshift_proxy( request, "u", "p", "8", "2026-06-08:17-00", "8.ts" ) + self.creds_mock = creds_mock return response, stream_mock, build_mock, limits_mock def test_second_stream_serves_after_first_fails(self): @@ -382,9 +466,14 @@ class TimeshiftProxyFailoverTests(TestCase): ) self.assertIs(response, ok) self.assertEqual(stream_mock.call_count, 2) - # Each attempt used its own provider context. + # Each attempt used its own provider context: credentials resolved per + # account/profile (via get_transformed_credentials) and its stream id. self.assertEqual( [c.args[0] for c in build_mock.call_args_list], + [("http://a1.test", "u1", "p"), ("http://a2.test", "u2", "p")], + ) + self.assertEqual( + [c.args[0] for c in self.creds_mock.call_args_list], [streams[0].m3u_account, streams[1].m3u_account], ) self.assertEqual( @@ -430,14 +519,27 @@ class TimeshiftProxyFailoverTests(TestCase): self.assertEqual(limits_mock.call_count, 1) -class TimeshiftProxyFailoverHardeningTests(TestCase): - """Ban-safety and per-provider context guarantees of the failover loop.""" +class _ProxyLoopTestMixin: + """Shared driver for tests exercising the failover loop end to end — + pool reservation, credential resolution and Redis are all controlled.""" def setUp(self): self.factory = RequestFactory() - def _call(self, streams, provider_responses, limits=True): + def _call(self, streams, provider_responses, limits=True, reserve_results=None, + build_side_effect=None): request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + self.fake_redis = _FakeRedis() + reserve_kwargs = ( + {"side_effect": reserve_results} + if reserve_results is not None + else {"return_value": (True, 1, None)} + ) + build_kwargs = ( + {"side_effect": build_side_effect} + if build_side_effect is not None + else {"return_value": ["http://example.test/x.ts"]} + ) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ @@ -445,16 +547,32 @@ class TimeshiftProxyFailoverHardeningTests(TestCase): patch.object(views, "get_channel_catchup_streams", return_value=streams), \ patch.object(views, "get_programme_duration", return_value=40), \ patch.object(views, "build_timeshift_candidate_urls", - return_value=["http://example.test/x.ts"]) as build_mock, \ + **build_kwargs) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=limits), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", **reserve_kwargs) as reserve_mock, \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "get_transformed_credentials", + side_effect=_fake_creds) as creds_mock, \ + patch.object(views, "get_user_active_connections", return_value=[]), \ patch.object(views, "_stream_from_provider", side_effect=provider_responses) as stream_mock: + redis_cls.get_client.return_value = self.fake_redis channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + # Exposed before the call so raising tests can still assert on them. + self.reserve_mock = reserve_mock + self.release_mock = release_mock + self.creds_mock = creds_mock + self.stream_mock = stream_mock response = views.timeshift_proxy( request, "u", "p", "8", "2026-06-08:17-00", "8.ts" ) return response, stream_mock, build_mock + +class TimeshiftProxyFailoverHardeningTests(_ProxyLoopTestMixin, TestCase): + """Ban-safety and per-provider context guarantees of the failover loop.""" + def test_decisive_failure_skips_same_accounts_other_streams(self): # Account 1 carries two variants (e.g. FHD + HD). A decisive # (auth/ban-class) failure on the first must NOT retry account 1's @@ -691,3 +809,402 @@ class AuthHelpersDbTests(TestCase): # Level-0 viewer with no profiles: allowed on level-0, denied on level-10. self.assertTrue(views._user_can_access_channel(self.viewer, self.basic_channel)) self.assertFalse(views._user_can_access_channel(self.viewer, self.admin_channel)) + + +class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): + """Provider pool participation: a profile slot is reserved before any + upstream attempt and released exactly once afterwards — the same + accounting contract live (Channel.get_stream) and VOD follow.""" + + def _slot_token_keys(self): + return [k for k in self.fake_redis.store if k.startswith("timeshift_slot:")] + + def test_reserve_called_with_default_profile_before_upstream(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, stream_mock, _ = self._call(streams, [MagicMock(status_code=200)]) + self.assertEqual(response.status_code, 200) + self.reserve_mock.assert_called_once() + reserved_profile = self.reserve_mock.call_args.args[0] + self.assertEqual(reserved_profile.id, 31) + # The reserved profile's id is what reaches the stats metadata. + self.assertEqual(stream_mock.call_args.kwargs["m3u_profile_id"], 31) + + def test_slot_released_after_failed_attempt(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, _, _ = self._call( + streams, [MagicMock(status_code=404, timeshift_decisive=False)] + ) + self.assertEqual(response.status_code, 404) + # The one-shot token was consumed and the pool counter decremented. + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._slot_token_keys(), []) + + def test_slot_kept_on_success_for_the_streaming_session(self): + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + response, _, _ = self._call(streams, [MagicMock(status_code=200)]) + self.assertEqual(response.status_code, 200) + # Slot still owned by the (mocked) streaming session: token present, + # nothing released yet. + self.release_mock.assert_not_called() + self.assertEqual(len(self._slot_token_keys()), 1) + + def test_decisive_failure_releases_slot_and_skips_account(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), + ] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=403, timeshift_decisive=True)] + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(stream_mock.call_count, 1) + self.release_mock.assert_called_once_with(31, self.fake_redis) + # Decisive skip means the second stream never reserved a slot. + self.assertEqual(self.reserve_mock.call_count, 1) + + def test_profile_full_walks_to_next_profile_same_account(self): + alt = _make_alt_profile(32) + streams = [_make_catchup_stream( + account_id=1, stream_id="111", profile_id=31, extra_profiles=(alt,) + )] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=200)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 200) + # Default profile full -> alternate profile reserved and used. + self.assertEqual( + [c.args[0].id for c in self.reserve_mock.call_args_list], [31, 32] + ) + self.assertEqual(stream_mock.call_args.kwargs["m3u_profile_id"], 32) + # Credentials were resolved for the RESERVED (alternate) profile. + self.assertIs(self.creds_mock.call_args.args[1], alt) + + def test_all_profiles_full_returns_503_without_upstream_attempt(self): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, stream_mock, _ = self._call( + streams, [], + reserve_results=[ + (False, 1, "profile_full"), + (False, 1, "credential_full"), + ], + ) + # Pool capacity exhausted everywhere: 503 (VOD's pool-exhausted + # status), and crucially the provider was never contacted. + self.assertEqual(response.status_code, 503) + stream_mock.assert_not_called() + self.release_mock.assert_not_called() + + def test_capacity_failure_is_not_decisive_for_the_account(self): + # profile_full on account 1's first stream must NOT mark account 1 + # decisive — capacity is transient, unlike a ban-class status. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), + ] + response, stream_mock, _ = self._call( + streams, [MagicMock(status_code=200)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 200) + # Second stream of the SAME account still got its reservation attempt. + self.assertEqual(self.reserve_mock.call_count, 2) + self.assertEqual(stream_mock.call_count, 1) + + def test_account_without_active_default_profile_is_skipped(self): + # Mirrors live dispatch: no active default profile -> skip the account + # without reserving anything. + stream = _make_catchup_stream(account_id=1, stream_id="111") + stream.m3u_account.profiles.filter.return_value = [_make_alt_profile(32)] + response, stream_mock, _ = self._call([stream], []) + self.assertEqual(response.status_code, 400) + self.reserve_mock.assert_not_called() + stream_mock.assert_not_called() + + def test_exception_from_provider_releases_slot(self): + # An unexpected exception between reserve and response construction + # must release the slot before propagating — otherwise the counter + # (no TTL) leaks until the next Redis flush. + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with self.assertRaises(RuntimeError): + self._call(streams, RuntimeError("boom")) + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._slot_token_keys(), []) + + def test_exception_before_upstream_releases_slot(self): + # Same guarantee for failures BEFORE the upstream call (URL building, + # credential resolution, user-agent lookup) — the guarded window + # starts right after the reservation. + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with self.assertRaises(RuntimeError): + self._call(streams, [], build_side_effect=RuntimeError("boom")) + self.stream_mock.assert_not_called() + self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._slot_token_keys(), []) + + def test_token_store_failure_releases_directly(self): + # If the ownership token cannot be written, no release path could + # ever free the slot — the view must release it directly and report + # transient unavailability instead of streaming unaccounted. + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_store_slot_token", return_value=False): + response, stream_mock, _ = self._call(streams, []) + self.assertEqual(response.status_code, 503) + stream_mock.assert_not_called() + self.release_mock.assert_called_once_with(31, self.fake_redis) + + def test_mixed_capacity_then_upstream_failure_returns_failure(self): + # Mixed outcome: one stream capacity-blocked, another actually tried + # upstream and failed -> the REAL upstream failure wins over 503 + # (capacity was not the sole blocker). + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, _, _ = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False)], + reserve_results=[(False, 1, "profile_full"), (True, 1, None)], + ) + self.assertEqual(response.status_code, 404) + + def test_mixed_upstream_failure_then_capacity_returns_failure(self): + # Same in the opposite order. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + response, _, _ = self._call( + streams, + [MagicMock(status_code=404, timeshift_decisive=False)], + reserve_results=[(True, 1, None), (False, 1, "profile_full")], + ) + self.assertEqual(response.status_code, 404) + + +class TimeshiftSlotTokenTests(TestCase): + """The one-shot release token: exactly-once semantics across all release + paths (generator finally, response close, takeover, failed attempt).""" + + def setUp(self): + self.redis = _FakeRedis() + + def test_release_happens_exactly_once(self): + views._store_slot_token(self.redis, "timeshift_abc", 31) + with patch.object(views, "release_profile_slot") as release_mock: + self.assertTrue(views._release_slot_token(self.redis, "timeshift_abc")) + self.assertFalse(views._release_slot_token(self.redis, "timeshift_abc")) + release_mock.assert_called_once_with(31, self.redis) + + def test_release_without_token_is_noop(self): + with patch.object(views, "release_profile_slot") as release_mock: + self.assertFalse(views._release_slot_token(self.redis, "timeshift_ghost")) + release_mock.assert_not_called() + + def test_release_without_redis_is_noop(self): + with patch.object(views, "release_profile_slot") as release_mock: + self.assertFalse(views._release_slot_token(None, "timeshift_abc")) + release_mock.assert_not_called() + + def test_wrapper_close_releases_even_when_generator_never_started(self): + # The WSGI layer can close the response before the first chunk is + # pulled; closing a never-started generator runs NO body code, so the + # generator's own finally cannot be the only release point. + finally_ran = [] + + def gen(): + try: + yield b"x" + finally: + finally_ran.append(True) + + on_close = MagicMock() + wrapper = views._SlotReleasingStream(gen(), on_close) + wrapper.close() + on_close.assert_called_once() + self.assertEqual(finally_ran, []) # proves the leak this wrapper fixes + + def test_streaming_response_close_invokes_wrapper_close(self): + # Locks the Django contract the wrapper relies on: an iterator with a + # close() method is registered as a resource closer of the response. + from django.http import StreamingHttpResponse + + on_close = MagicMock() + wrapper = views._SlotReleasingStream(iter([b"x"]), on_close) + response = StreamingHttpResponse(wrapper, content_type="video/mp2t") + response.close() + on_close.assert_called_once() + + +class TimeshiftTakeoverTests(TestCase): + """One catch-up session per user+channel: a new request displaces the + user's previous session on the same channel — synchronous slot release + + stats unregister + stop key — and never touches other users, other + channels, or live sessions.""" + + def setUp(self): + self.redis = _FakeRedis() + self.user = MagicMock(id=5) + + def _conn(self, media_id, client_id, conn_type="timeshift"): + return { + "media_id": media_id, + "client_id": client_id, + "connected_at": 0.0, + "type": conn_type, + } + + def test_displaces_only_same_channel_timeshift_sessions(self): + views._store_slot_token(self.redis, "timeshift_old1", 31) + views._store_slot_token(self.redis, "timeshift_other", 41) + connections = [ + self._conn("timeshift_8_2026-06-08-17-00_111", "timeshift_old1"), + self._conn("timeshift_9_2026-06-08-17-00_222", "timeshift_other"), + self._conn("42", "live_client", conn_type="live"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections) as conns_mock, \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions(self.redis, self.user, 8) + conns_mock.assert_called_once_with(5) + # Channel 8's old session: slot released, stats dropped, stop key set. + release_mock.assert_called_once_with(31, self.redis) + unregister_mock.assert_called_once_with( + self.redis, "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" + ) + from apps.proxy.live_proxy.redis_keys import RedisKeys + stop_key = RedisKeys.client_stop( + "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" + ) + self.assertIn(stop_key, self.redis.store) + # Channel 9's session untouched: token still present, no stop key. + self.assertIn("timeshift_slot:timeshift_other", self.redis.store) + + def test_channel_id_prefix_cannot_match_other_channels(self): + # Channel 8 must not displace channel 80/81 sessions (prefix ends + # with an underscore). + connections = [ + self._conn("timeshift_80_2026-06-08-17-00_111", "timeshift_c80"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions(self.redis, self.user, 8) + release_mock.assert_not_called() + unregister_mock.assert_not_called() + + def test_noop_without_redis_or_user(self): + with patch.object(views, "get_user_active_connections") as conns_mock: + views._terminate_previous_timeshift_sessions(None, self.user, 8) + views._terminate_previous_timeshift_sessions(self.redis, None, 8) + conns_mock.assert_not_called() + + def test_proxy_runs_takeover_before_stream_limit_check(self): + # Order matters: with terminate_on_limit_exceeded=False a seek must + # displace its own predecessor BEFORE the limit check counts it, or + # the user's own seek gets denied. + call_order = [] + request = RequestFactory().get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "_terminate_previous_timeshift_sessions", + side_effect=lambda *a: call_order.append("takeover")) as takeover_mock, \ + patch.object(views, "check_user_stream_limits", + side_effect=lambda *a, **k: call_order.append("limits") or False): + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(call_order, ["takeover", "limits"]) + self.assertEqual(takeover_mock.call_args.args[2], 8) + + +class RollupSelfHealDbTests(TestCase): + """Catch-up flag consistency after stream removal — the review-#4 point 1 + guarantees: the ChannelStream signal handles bulk deletes (locked by a + regression test) and the rollup self-heals any channel left flagged with + no catch-up stream, regardless of how the link rows disappeared.""" + + @classmethod + def setUpTestData(cls): + from apps.m3u.models import M3UAccount + + cls.account = M3UAccount.objects.create( + name="ts-rollup-account", server_url="http://example.test", + account_type="XC", is_active=True, + ) + + def _make_channel_with_catchup_stream(self, name, days=5): + from apps.channels.models import Channel, ChannelStream, Stream + + channel = Channel.objects.create(name=name) + stream = Stream.objects.create( + name=f"{name}-stream", url=f"http://example.test/{name}", + m3u_account=self.account, is_catchup=True, catchup_days=days, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + return channel, stream + + def test_bulk_stream_delete_resets_channel_flags_via_signal(self): + # cleanup_streams() removes stale streams with a queryset bulk delete; + # the cascaded ChannelStream rows still fire post_delete (signal + # listeners disable Django's fast-delete path), which must reset the + # channel's denormalized catch-up fields. + from apps.channels.models import Stream + + channel, stream = self._make_channel_with_catchup_stream("ts-rollup-bulk") + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 5) + + Stream.objects.filter(id=stream.id).delete() + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_self_heals_stale_channel_without_streams(self): + # Simulate staleness no signal caught (raw SQL delete, historic data): + # a flagged channel with zero catch-up streams must be reset by the + # rollup even though no remaining stream ties it to the account. + from apps.channels.models import Channel + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel = Channel.objects.create(name="ts-rollup-stale") + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(self.account.id) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_keeps_and_corrects_channels_with_catchup_streams(self): + # The self-heal pass must not touch channels that legitimately have + # catch-up streams — and the account-scoped pass still corrects their + # values. + from apps.channels.models import Channel + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel, _ = self._make_channel_with_catchup_stream("ts-rollup-valid", days=7) + # Knock the denormalized values out of sync (bypasses signals). + Channel.objects.filter(pk=channel.pk).update(is_catchup=False, catchup_days=0) + + rollup_channel_catchup_fields(self.account.id) + + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 7) diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 651ede15..f158df4b 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -3,6 +3,13 @@ archive (PATH `/timeshift/.../{id}.ts` form first, `/streaming/timeshift.php` query form as fallback) to clients like iPlayTV and TiviMate, with multi-provider failover across the channel's catch-up streams. +Provider capacity accounting follows the same contract as live and VOD: a +profile slot is reserved through ``apps.m3u.connection_pool`` before any +upstream connect and released exactly once when the session ends (one-shot +Redis token), so Dispatcharr's pool counters always match real upstream usage. +A user gets ONE active catch-up session per channel — a seek displaces the +previous one. + URL parameter quirk matching what iPlayTV / TiviMate emit: Position "stream_id" -> EPG channel number, IGNORED here. Position "duration" -> Dispatcharr's Channel.id (the XC API emits @@ -19,6 +26,7 @@ import requests from django.core.cache import cache from django.http import ( Http404, + HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, @@ -28,16 +36,19 @@ from django.http import ( from apps.accounts.models import User from apps.channels.models import Channel from apps.channels.utils import get_channel_catchup_streams +from apps.m3u.connection_pool import release_profile_slot, reserve_profile_slot +from apps.m3u.tasks import get_transformed_credentials from apps.proxy.live_proxy.config_helper import ConfigHelper from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState from apps.proxy.live_proxy.redis_keys import RedisKeys from apps.proxy.live_proxy.utils import get_client_ip from apps.proxy.live_proxy.input.http_streamer import find_ts_sync -from apps.proxy.utils import check_user_stream_limits +from apps.proxy.utils import check_user_stream_limits, get_user_active_connections from core.utils import RedisClient from dispatcharr.utils import network_access_allowed from .helpers import ( + TimeshiftCredentials, build_timeshift_candidate_urls, convert_timestamp_to_provider_tz, get_programme_duration, @@ -99,6 +110,18 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) range_header = request.META.get("HTTP_RANGE") channel_logo_id = getattr(channel, "logo_id", None) + redis_client = RedisClient.get_client() + + # One active catch-up session per user+channel: a new request (programme + # jump, or a seek re-request on the same programme) DISPLACES the user's + # previous session on this channel. Its provider slot is released + # synchronously — so the reservation below cannot collide with the dying + # session on max_connections=1 providers — and its generator is stopped + # via the same Redis stop-key the stream-limit path uses. Runs BEFORE the + # stream-limit check so a seek displaces its own predecessor instead of + # being denied when terminate_on_limit_exceeded is off. + _terminate_previous_timeshift_sessions(redis_client, user, channel.id) + # Enforce user stream limits once, before connecting upstream — this # terminates the oldest active stream (live, timeshift, or VOD) via the # Redis stop-key mechanism, same as the live and VOD proxy entry points. @@ -120,6 +143,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) # account is a different host and remains safe to try. last_response = None decisive_accounts = set() + capacity_blocked = False for catchup_stream in catchup_streams: m3u_account = catchup_stream.m3u_account if m3u_account is None or m3u_account.account_type != "XC": @@ -131,63 +155,158 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if stream_id_value is None: continue + # Provider profile walk — same ordering as live's Channel.get_stream(): + # active profiles only, default first; an account without an active + # default profile is skipped entirely, mirroring live dispatch. + m3u_profiles = list(m3u_account.profiles.filter(is_active=True)) + default_profile = next((p for p in m3u_profiles if p.is_default), None) + if default_profile is None: + logger.debug( + "Timeshift: account %s has no active default profile, skipping", + m3u_account.id, + ) + continue + profile_walk = [default_profile] + [ + p for p in m3u_profiles if not p.is_default + ] + # Real XC providers index their archive in their OWN local zone (the # server_info.timezone they report on auth — stored on the default # profile). Convert the UTC instant to that zone for the upstream URL — # empirically a provider reads `17-00` as 17:00 LOCAL, not UTC, so # skipping this would seek 1-2h off. This is the ONLY timezone - # conversion in the chain, and it is per-provider. - default_profile = m3u_account.profiles.filter(is_default=True).first() + # conversion in the chain, and it is per-provider (account-level: all + # profiles log into the same server, so the default profile's zone + # applies to every profile of the walk). provider_tz_name = None - if default_profile is not None: - _server_info = (default_profile.custom_properties or {}).get("server_info") or {} - if isinstance(_server_info, dict): - provider_tz_name = _server_info.get("timezone") + _server_info = (default_profile.custom_properties or {}).get("server_info") or {} + if isinstance(_server_info, dict): + provider_tz_name = _server_info.get("timezone") provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) - # Ordered upstream candidates, PATH form first — see - # build_timeshift_candidate_urls() for the full rationale (the QUERY - # form is a last-resort fallback because some providers return LIVE on - # it, ignoring the requested timestamp). - candidate_urls = build_timeshift_candidate_urls( - m3u_account, stream_id_value, provider_timestamp, duration_minutes - ) + # Reserve a provider profile slot BEFORE connecting upstream — the + # same accounting contract live (Channel.get_stream) and VOD follow, + # so the Redis pool counters always match real upstream usage. Walk + # to the next profile only on RESERVATION failure (profile_full / + # credential_full — transient capacity, never ban-class); an upstream + # failure instead moves to the next catch-up STREAM, because probing + # the same server again through an alternate profile is wasteful and + # ban-adjacent. + reserved_profile = None + for profile in profile_walk: + if redis_client is None: + # Without Redis no accounting is possible — proceed unpooled + # rather than blocking playback (stats writes are equally + # fail-open). Deployments always run Redis; this is a dev-only + # path. + reserved_profile = profile + break + reserved, _count, reason = reserve_profile_slot(profile, redis_client) + if reserved: + reserved_profile = profile + break + logger.info( + "Timeshift: profile %s %s on account %s — trying next profile", + profile.id, reason or "unavailable", m3u_account.id, + ) + if reserved_profile is None: + capacity_blocked = True + logger.warning( + "Timeshift: all profiles at capacity on account %s for channel %s", + m3u_account.id, channel.name, + ) + continue + token_stored = False + if redis_client is not None: + token_stored = _store_slot_token( + redis_client, client_id, reserved_profile.id + ) + if not token_stored: + # Redis hiccup right after the INCR: without a token no + # release path could ever free this slot, so release it + # directly NOW (race-free: the session is not yet visible to + # the takeover scan) and report transient unavailability + # instead of streaming unaccounted. + try: + release_profile_slot(reserved_profile.id, redis_client) + except Exception as exc: + logger.error( + "Timeshift: could not release slot for profile %s " + "after token-store failure: %s", reserved_profile.id, exc, + ) + capacity_blocked = True + continue + + # From here until the streaming response owns the slot, ANY failure + # must release the reservation before propagating — otherwise the + # pool counter (which has no TTL) leaks until the next Redis flush. try: - user_agent = m3u_account.get_user_agent().user_agent - except AttributeError: - user_agent = "" + # Build the upstream URLs with the RESERVED profile's credentials, + # resolved the same way live playback does (credential extraction + # via the profile's URL transform). Using the raw account fields + # for a non-default profile would consume that profile's slot + # while authenticating with the default login — double-occupying + # the provider connection the pool thinks is free. + server_url, xc_username, xc_password = get_transformed_credentials( + m3u_account, reserved_profile + ) + creds = TimeshiftCredentials(server_url, xc_username, xc_password) - virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" - m3u_profile_id = default_profile.id if default_profile is not None else None - - if debug: - logger.debug( - "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " - "account=%s provider_sid=%s vid=%s client=%s range=%s", - channel.name, timestamp, provider_tz_name, provider_timestamp, - m3u_account.id, stream_id_value, virtual_channel_id, client_id, - range_header or "(none)", + # Ordered upstream candidates, PATH form first — see + # build_timeshift_candidate_urls() for the full rationale (the + # QUERY form is a last-resort fallback because some providers + # return LIVE on it, ignoring the requested timestamp). + candidate_urls = build_timeshift_candidate_urls( + creds, stream_id_value, provider_timestamp, duration_minutes ) - response = _stream_from_provider( - candidate_urls=candidate_urls, - user_agent=user_agent, - range_header=range_header, - virtual_channel_id=virtual_channel_id, - client_id=client_id, - client_ip=client_ip, - user=user, - channel_display_name=channel.name, - timestamp_utc=timestamp, - channel_logo_id=channel_logo_id, - m3u_profile_id=m3u_profile_id, - debug=debug, - account_id=m3u_account.id, - ) + try: + user_agent = m3u_account.get_user_agent().user_agent + except AttributeError: + user_agent = "" + + virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" + + if debug: + logger.debug( + "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " + "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s", + channel.name, timestamp, provider_tz_name, provider_timestamp, + m3u_account.id, reserved_profile.id, stream_id_value, + virtual_channel_id, client_id, range_header or "(none)", + ) + + response = _stream_from_provider( + candidate_urls=candidate_urls, + user_agent=user_agent, + range_header=range_header, + virtual_channel_id=virtual_channel_id, + client_id=client_id, + client_ip=client_ip, + user=user, + channel_display_name=channel.name, + timestamp_utc=timestamp, + channel_logo_id=channel_logo_id, + m3u_profile_id=reserved_profile.id, + debug=debug, + account_id=m3u_account.id, + redis_client=redis_client, + ) + except Exception: + if token_stored: + # A False return here means someone else (a takeover) already + # consumed the token and released the slot — nothing to do. + _release_slot_token(redis_client, client_id) + raise if response.status_code < 400: + # The reserved slot is now owned by the streaming response: it is + # released (exactly once, via the one-shot token) when the + # generator finishes or the WSGI layer closes the response. return response + # Failed attempt: free the slot before trying the next stream. + _release_slot_token(redis_client, client_id) last_response = response if getattr(response, "timeshift_decisive", False): decisive_accounts.add(m3u_account.id) @@ -202,6 +321,11 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if last_response is not None: return last_response + if capacity_blocked: + # Every eligible stream failed on pool capacity alone (no upstream + # attempt was made) — mirror the VOD proxy's pool-exhausted status so + # clients back off instead of treating it as a permanent failure. + return HttpResponse("No available stream slot", status=503) # Streams existed but none was usable (non-XC accounts / missing stream_id). return HttpResponseBadRequest("Cannot build timeshift URL") @@ -242,6 +366,127 @@ def _user_can_access_channel(user, channel): ) +# --------------------------------------------------------------------------- +# Provider pool slot ownership (one-shot release token) + session takeover +# --------------------------------------------------------------------------- + +# Maps a timeshift client to the profile slot it reserved. Consumed (GET+DEL, +# atomically) by whichever release path runs first — the generator's finally, +# the response-close wrapper, a failed failover attempt, or a takeover by the +# user's next request on the same channel — so the slot is released exactly +# once even across uWSGI workers. Note the TTL does NOT recover the slot: if a +# worker dies mid-session the token eventually expires while the counter (no +# TTL) stays consumed — like the rest of the pool, the recovery for +# crash-leaked counters is the Redis flush at container start +# (scripts/wait_for_redis.py). +_SLOT_TOKEN_KEY = "timeshift_slot:{client_id}" +_SLOT_TOKEN_TTL = 24 * 3600 + + +def _store_slot_token(redis_client, client_id, profile_id): + """Record slot ownership; returns False when the token could not be + written (the caller must then release the reservation itself — without a + token no other path ever could).""" + if redis_client is None: + return False + try: + redis_client.setex( + _SLOT_TOKEN_KEY.format(client_id=client_id), + _SLOT_TOKEN_TTL, + str(profile_id), + ) + return True + except Exception as exc: + logger.warning("Timeshift slot token store failed for %s: %s", client_id, exc) + return False + + +def _release_slot_token(redis_client, client_id): + """Release the profile slot owned by *client_id*, exactly once. + + GET+DEL inside a MULTI/EXEC transaction: concurrent release attempts are + serialized by Redis, so only the one that actually deletes a live token + decrements the pool counters. Returns True when this call performed the + release. + """ + if redis_client is None: + return False + key = _SLOT_TOKEN_KEY.format(client_id=client_id) + try: + pipe = redis_client.pipeline(transaction=True) + pipe.get(key) + pipe.delete(key) + token_value, deleted = pipe.execute() + if not token_value or not deleted: + return False + release_profile_slot(int(token_value), redis_client) + return True + except Exception as exc: + logger.warning("Timeshift slot release failed for %s: %s", client_id, exc) + return False + + +def _terminate_previous_timeshift_sessions(redis_client, user, channel_id): + """Displace the user's previous catch-up session(s) on this channel. + + For each of the user's active timeshift sessions whose virtual channel id + belongs to *channel_id*: release its provider slot NOW (synchronously — + the dying generator's own release becomes a no-op thanks to the one-shot + token), drop its stats keys so the stream-limit count no longer includes + it, and set the stop key its generator polls on the 5-second heartbeat. + The accounting is therefore correct immediately; only the old TCP socket + closes lazily (≤ ~5 s). + """ + if redis_client is None or user is None: + return + prefix = f"timeshift_{channel_id}_" + try: + for conn in get_user_active_connections(user.id): + if conn.get("type") != "timeshift": + continue + media_id = str(conn.get("media_id") or "") + if not media_id.startswith(prefix): + continue + old_client_id = conn.get("client_id") + logger.info( + "Timeshift takeover: displacing session %s on %s", + old_client_id, media_id, + ) + _release_slot_token(redis_client, old_client_id) + _unregister_stats_client(redis_client, media_id, old_client_id) + stop_key = RedisKeys.client_stop(media_id, old_client_id) + redis_client.setex(stop_key, 60, "true") + except Exception as exc: + logger.warning("Timeshift takeover check failed: %s", exc) + + +class _SlotReleasingStream: + """Iterator wrapper whose close() always releases the reserved slot. + + Django registers ``streaming_content.close`` in the response's resource + closers, and the WSGI layer guarantees close() runs — including when the + client disconnects before the first chunk, in which case the generator + never starts and its ``finally`` would never execute. The one-shot token + makes the duplicate call from an already-finished generator a no-op. + """ + + def __init__(self, generator, on_close): + self._generator = generator + self._on_close = on_close + + def __iter__(self): + return self + + def __next__(self): + return next(self._generator) + + def close(self): + try: + self._generator.close() + finally: + self._on_close() + + # --------------------------------------------------------------------------- # Stats integration (direct Redis writes, no ClientManager instance) # --------------------------------------------------------------------------- @@ -406,6 +651,7 @@ def _stream_from_provider( m3u_profile_id, debug, account_id=None, + redis_client=None, ): # Use 256 KB chunks: amortises per-yield uWSGI/gevent overhead. chunk_size = max(ConfigHelper.chunk_size(), 262144) @@ -525,7 +771,6 @@ def _stream_from_provider( content_range = upstream.headers.get("Content-Range", "") status = upstream.status_code - redis_client = RedisClient.get_client() _register_stats_client( redis_client, virtual_channel_id, @@ -604,9 +849,23 @@ def _stream_from_provider( except Exception: pass _unregister_stats_client(redis_client, virtual_channel_id, client_id) + # Free the provider pool slot this session reserved. One-shot + # token: a no-op if a takeover (the user's next request on this + # channel) already released it. + _release_slot_token(redis_client, client_id) + # The wrapper guarantees session teardown even when the generator never + # starts (client gone before the first chunk — its finally would then + # never run): Django registers the iterator's close() as a resource + # closer, which WSGI always invokes. Both calls are idempotent, so the + # duplicate from a normally-finished generator is harmless. + def _close_session(): + _unregister_stats_client(redis_client, virtual_channel_id, client_id) + _release_slot_token(redis_client, client_id) + + stream_iter = _SlotReleasingStream(stream_generator(), _close_session) response = StreamingHttpResponse( - stream_generator(), + stream_iter, content_type=content_type, status=status, ) From 808fadfef2214fd29bf870cbf29a86142618ec55 Mon Sep 17 00:00:00 2001 From: Jacob Lasky Date: Mon, 22 Jun 2026 12:02:01 -0400 Subject: [PATCH 13/56] fix(m3u): abort XC refresh on empty provider fetch to prevent channel wipe When an Xtream Codes provider returns no live streams on a routine refresh (a transient upstream failure, a fetch exception, or no enabled category matching), collect_xc_streams() returns an empty list. The refresh then fell through to stale-marking and sync_auto_channels, which -- with nothing seen this refresh -- deletes the account's entire auto-created channel lineup via its per-group "no streams remaining" branch. Guard the XC branch of _refresh_single_m3u_account_impl: on an empty result, set the account to ERROR and return before stale-marking and auto-sync, mirroring the standard-path empty/failed-download guards already in the function. A transient empty fetch can no longer destroy channels. Adds apps/m3u/tests/test_xc_empty_fetch_guard.py covering both the abort (sync not called, channels preserved, streams not marked stale, status ERROR) and the healthy path (non-empty fetch still runs sync). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/m3u/tasks.py | 24 +++- apps/m3u/tests/test_xc_empty_fetch_guard.py | 131 ++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/m3u/tests/test_xc_empty_fetch_guard.py diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index be2a612f..64f16fd7 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -3495,7 +3495,29 @@ def _refresh_single_m3u_account_impl(account_id): all_xc_streams = collect_xc_streams(account_id, filtered_groups) if not all_xc_streams: - logger.warning("No streams collected from XC groups") + # collect_xc_streams() returns an empty list when the provider + # returned no live streams, the fetch raised, or no enabled + # category matched. Falling through would mark every existing + # stream stale and then let sync_auto_channels delete the + # account's entire auto-created channel lineup (its per-group + # "no streams remaining" branch). A routine refresh must not + # destroy channels because of a transient upstream failure, so + # abort here, before stale-marking and auto-sync, exactly as the + # standard-path guards above do for an empty/failed download. + logger.error( + f"No streams collected from XC provider for account " + f"{account_id}; aborting refresh to preserve the existing " + f"channel lineup." + ) + error_msg = "No streams returned from Xtream Codes provider" + _set_m3u_account_status( + account_id, + M3UAccount.Status.ERROR, + error_msg, + notify_error=True, + ws_error=error_msg, + ) + return "Failed to update m3u account, no streams returned from provider" else: # Now batch by stream count (like standard M3U processing) batches = [ diff --git a/apps/m3u/tests/test_xc_empty_fetch_guard.py b/apps/m3u/tests/test_xc_empty_fetch_guard.py new file mode 100644 index 00000000..55cf4576 --- /dev/null +++ b/apps/m3u/tests/test_xc_empty_fetch_guard.py @@ -0,0 +1,131 @@ +""" +Regression test for the Xtream Codes empty-fetch channel wipe. + +Bug: when an XC provider returns no live streams on a routine refresh (a +transient upstream failure, a fetch exception, or no enabled category +matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall +through to stale-marking and ``sync_auto_channels``. With nothing "seen" this +refresh, auto-sync deletes the account's entire auto-created channel lineup. + +Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when +``collect_xc_streams`` returns empty, setting the account to ERROR before any +stale-marking or auto-sync runs, mirroring the standard-path empty guards. +""" +from unittest.mock import MagicMock, patch + +from django.test import TransactionTestCase +from django.utils import timezone + +from apps.channels.models import ( + Channel, + ChannelGroup, + ChannelGroupM3UAccount, + Stream, +) +from apps.m3u.models import M3UAccount +from apps.m3u.tasks import _refresh_single_m3u_account_impl + + +class XCEmptyFetchGuardTests(TransactionTestCase): + def _setup_xc_account_with_auto_channel(self): + account = M3UAccount.objects.create( + name="Test XC Provider", + server_url="http://example.com", + username="user", + password="pass", + account_type=M3UAccount.Types.XC, + is_active=True, + ) + group = ChannelGroup.objects.create(name="Sports") + ChannelGroupM3UAccount.objects.create( + m3u_account=account, + channel_group=group, + enabled=True, + auto_channel_sync=True, + auto_sync_channel_start=100, + custom_properties={"xc_id": "123"}, + ) + # A pre-existing stream and the auto-created channel built from it on a + # prior healthy refresh -- this is exactly what the bug deletes. + stream = Stream.objects.create( + name="ESPN", + url="http://example.com/espn.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + is_stale=False, + ) + channel = Channel.objects.create( + channel_number=100, + name="ESPN", + channel_group=group, + auto_created=True, + auto_created_by=account, + ) + return account, group, stream, channel + + @patch("apps.m3u.tasks.sync_auto_channels") + @patch("apps.m3u.tasks.collect_xc_streams", return_value=[]) + @patch("apps.m3u.tasks.refresh_m3u_groups") + def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels( + self, mock_refresh_groups, _mock_collect, mock_sync + ): + account, group, stream, channel = self._setup_xc_account_with_auto_channel() + # XC refresh: empty extinf_data is normal, groups must be present. + mock_refresh_groups.return_value = ([], {"Sports": group.id}) + + result = _refresh_single_m3u_account_impl(account.id) + + # The refresh aborts, so auto channel sync never runs. + mock_sync.assert_not_called() + # The auto-created channel survives the empty fetch. + self.assertTrue(Channel.objects.filter(pk=channel.pk).exists()) + # The stream is not marked stale (stale-marking is skipped on abort). + stream.refresh_from_db() + self.assertFalse(stream.is_stale) + # The account is surfaced as errored, not silently "successful". + account.refresh_from_db() + self.assertEqual(account.status, M3UAccount.Status.ERROR) + self.assertIn("no streams returned from provider", result) + + @patch("apps.m3u.tasks.log_system_event") + @patch("apps.m3u.tasks.send_m3u_update") + @patch("apps.m3u.tasks.cleanup_stale_group_relationships") + @patch("apps.m3u.tasks.cleanup_streams", return_value=0) + @patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated") + @patch("apps.m3u.tasks.sync_auto_channels") + @patch("apps.m3u.tasks.refresh_m3u_groups") + def test_non_empty_xc_fetch_still_runs_sync( + self, + mock_refresh_groups, + mock_sync, + _mock_process, + _mock_cleanup_streams, + _mock_cleanup_groups, + _mock_ws, + _mock_log, + ): + # The guard must not fire on a healthy refresh: a non-empty fetch + # proceeds to auto channel sync as before. + account, group, _stream, _channel = self._setup_xc_account_with_auto_channel() + mock_refresh_groups.return_value = ([], {"Sports": group.id}) + mock_sync.return_value = { + "status": "ok", + "channels_created": 1, + "channels_updated": 0, + "channels_deleted": 0, + "channels_failed": 0, + "failed_stream_details": [], + } + xc_stream = { + "name": "ESPN", + "url": "http://example.com/espn.m3u8", + "attributes": {"group-title": "Sports", "stream_id": "1"}, + } + + with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]): + _refresh_single_m3u_account_impl(account.id) + + mock_sync.assert_called_once() + account.refresh_from_db() + self.assertEqual(account.status, M3UAccount.Status.SUCCESS) From 6b4453e9ca7d439730f20f1dea086a49c4b5e9c3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Jun 2026 21:51:24 -0500 Subject: [PATCH 14/56] fix: Readd prev_days logic after dev merge. --- apps/output/epg.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/output/epg.py b/apps/output/epg.py index 626b21aa..b017b8e5 100644 --- a/apps/output/epg.py +++ b/apps/output/epg.py @@ -1100,11 +1100,32 @@ def generate_epg(request, profile_name=None, user=None): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - try: - prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) - prev_days = max(0, min(prev_days, 30)) - except (ValueError, TypeError): - prev_days = 0 + # prev_days resolution order: + # 1. URL ?prev_days= (explicit, even 0 means "no past") + # 2. user.custom_properties.epg_prev_days + # 3. CoreSettings.proxy_settings.xmltv_prev_days_override (>0) + # 4. Auto-detect: max provider tv_archive_duration capped at 30 + url_prev = request.GET.get('prev_days') + user_prev = user_custom.get('epg_prev_days') if user_custom else None + if url_prev is not None: + try: + prev_days = max(0, min(int(url_prev), 30)) + except (ValueError, TypeError): + prev_days = 0 + elif user_prev not in (None, ""): + try: + prev_days = max(0, min(int(user_prev), 30)) + except (ValueError, TypeError): + prev_days = 0 + else: + from apps.timeshift.helpers import compute_provider_archive_days_capped + from core.models import CoreSettings + proxy_settings = CoreSettings.get_proxy_settings() + try: + override = int(proxy_settings.get("xmltv_prev_days_override", 0) or 0) + except (TypeError, ValueError): + override = 0 + prev_days = max(0, min(override, 30)) if override > 0 else compute_provider_archive_days_capped() use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() cache_params = ( From e596a508c71cb5dc750562889890c1d4970286b8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:39:54 -0500 Subject: [PATCH 15/56] refactor(models): Simplify comments for catch-up fields in Stream and Channel models, clarifying their population process. Update migration docstring for consistency. Remove outdated comments in ProxyServer and URLs related to timeshift handling. --- apps/channels/migrations/0038_add_catchup_fields.py | 7 +------ apps/channels/models.py | 8 ++------ apps/proxy/live_proxy/server.py | 6 ------ apps/timeshift/tests/test_helpers.py | 4 ++-- core/models.py | 3 +-- dispatcharr/urls.py | 3 --- 6 files changed, 6 insertions(+), 25 deletions(-) diff --git a/apps/channels/migrations/0038_add_catchup_fields.py b/apps/channels/migrations/0038_add_catchup_fields.py index 2ec76333..8f653a3c 100644 --- a/apps/channels/migrations/0038_add_catchup_fields.py +++ b/apps/channels/migrations/0038_add_catchup_fields.py @@ -1,9 +1,4 @@ -"""Add denormalized catch-up fields to Stream and Channel. - -Populated at M3U/XC import time so _xc_channel_entry() can read them as -zero-cost column reads instead of introspecting custom_properties JSON -per channel on every xc_get_live_streams call. -""" +"""Add denormalized catch-up fields to Stream and Channel.""" from django.db import migrations, models diff --git a/apps/channels/models.py b/apps/channels/models.py index 2ba10ad7..fb421080 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -135,9 +135,7 @@ class Stream(models.Model): db_index=True ) - # Denormalized catch-up fields — populated at M3U/XC import time from - # custom_properties["tv_archive"] and ["tv_archive_duration"]. Avoids - # per-channel JSON introspection on every xc_get_live_streams call. + # Populated at import from tv_archive / tv_archive_duration. is_catchup = models.BooleanField( default=False, db_index=True, @@ -377,9 +375,7 @@ class Channel(models.Model): help_text="The M3U account that auto-created this channel" ) - # Denormalized catch-up fields — rolled up from the channel's streams at - # import time and via post_save/post_delete signals on ChannelStream. - # Eliminates per-channel DB queries in _xc_channel_entry(). + # Populated at import; rolled up via ChannelStream signal / m3u refresh. is_catchup = models.BooleanField( default=False, db_index=True, diff --git a/apps/proxy/live_proxy/server.py b/apps/proxy/live_proxy/server.py index 4f3efc57..6d6a97bc 100644 --- a/apps/proxy/live_proxy/server.py +++ b/apps/proxy/live_proxy/server.py @@ -2079,12 +2079,6 @@ class ProxyServer: if not channel_id: continue - # Skip channel ids that don't follow Dispatcharr's UUID - # convention. Those are managed by features outside of - # ProxyServer (e.g. timeshift catch-up sessions use a - # `timeshift___` virtual id and are - # owned by the WSGI request that opened them — they - # expire naturally via the clients-set TTL). if channel_id.startswith("timeshift_"): continue diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index 2c29e337..20f922d9 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -1,4 +1,4 @@ -"""Tests for `apps.timeshift.helpers` — timestamp shape conversion and URL build.""" +"""Tests for `apps.timeshift.helpers`: timestamp shape conversion and URL build.""" from django.test import TestCase @@ -20,7 +20,7 @@ def _make_creds(): class TimestampFormatTests(TestCase): - """Timestamp reshape functions change format only — no timezone conversion.""" + """Timestamp reshape functions change format only; no timezone conversion.""" def test_format_sql_reshapes_without_tz_conversion(self): self.assertEqual( diff --git a/core/models.py b/core/models.py index ffb5d2c7..9c5a5795 100644 --- a/core/models.py +++ b/core/models.py @@ -396,8 +396,7 @@ class CoreSettings(models.Model): "channel_shutdown_delay": 0, "channel_init_grace_period": 5, "new_client_behind_seconds": 5, - # XC catch-up: forced XMLTV lookback window in days (0 = auto-detect - # from the providers' largest tv_archive_duration, capped at 30). + # XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30). "xmltv_prev_days_override": 0, }) diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 0816bfd1..cdedb0c4 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -42,9 +42,6 @@ urlpatterns = [ stream_xc, name="xc_stream_endpoint", ), - # XC catch-up (timeshift). The "duration" slot carries Dispatcharr's internal - # Channel.id (the XC API emits channel.id as stream_id to clients), see - # apps/timeshift/views. path( "timeshift/////", timeshift_proxy, From 8d990ebf9363506f9605e8fcda1ed89a3b4f305d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:40:57 -0500 Subject: [PATCH 16/56] refactor(signals): Simplify catch-up field update logic in ChannelStream signal, enhancing clarity and performance by filtering active streams directly. Updated docstring for improved understanding of the functionality. --- apps/channels/signals.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 521b9d66..1c3d00ef 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -373,23 +373,16 @@ def revoke_task_on_delete(sender, instance, **kwargs): @receiver([post_save, post_delete], sender=ChannelStream) def update_channel_catchup_fields(sender, instance, **kwargs): - """Roll up denormalized catch-up fields from streams to the channel. - - Covers the UI/API path (admin adds/removes a stream from a channel). - The bulk-import path bypasses signals and uses explicit SQL instead — - see rollup_channel_catchup_fields() in apps/m3u/tasks.py. Same semantics - as that rollup: is_catchup = any catch-up stream, catchup_days = MAX of - the catch-up streams' archive depth. - """ + """Roll up catch-up flags from active streams (UI path; import uses SQL rollup).""" from django.db.models import Max channel = instance.channel - max_days = ( - channel.streams - .filter(is_catchup=True) - .aggregate(max_days=Max("catchup_days"))["max_days"] + catchup_qs = channel.streams.filter( + is_catchup=True, + m3u_account__is_active=True, ) + max_days = catchup_qs.aggregate(max_days=Max("catchup_days"))["max_days"] Channel.objects.filter(pk=channel.pk).update( - is_catchup=max_days is not None, + is_catchup=catchup_qs.exists(), catchup_days=max_days or 0, ) From f2dfe6deb634c8c93139887b7cab1b955e375eb7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:45:13 -0500 Subject: [PATCH 17/56] feat(channels): Add caching and resolution logic for provider archive days in XC catch-up streams. Implemented `compute_provider_archive_days_capped` and `resolve_xc_epg_prev_days` functions to enhance performance and flexibility in determining catch-up days. Updated docstrings for clarity. --- apps/channels/utils.py | 84 +++++++++++++++++++-- apps/timeshift/helpers.py | 155 ++++++++++---------------------------- 2 files changed, 118 insertions(+), 121 deletions(-) diff --git a/apps/channels/utils.py b/apps/channels/utils.py index 2efcef62..b2cceab6 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -1,8 +1,13 @@ import logging import threading +from django.core.cache import cache + logger = logging.getLogger(__name__) +PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300 +MAX_AUTO_PREV_DAYS = 30 + # Bound memory/DB work per chunk for large libraries (20k+ channels). EPG_LOGO_APPLY_BATCH_SIZE = 500 EPG_LOGO_APPLY_MAX_ERRORS = 100 @@ -24,13 +29,80 @@ def format_channel_number(value, empty=""): return value -def get_channel_catchup_streams(channel): - """Ordered catch-up streams for a Channel (empty list if unavailable). +def compute_provider_archive_days_capped(): + """Max ``catchup_days`` across active XC catch-up streams (capped, cached). - Returned in ``channelstream__order`` so the timeshift proxy can fail over: - it tries each stream's provider in turn until one serves the archive — - mirroring how live playback walks a channel's stream list. Streams from - disabled M3U accounts are excluded, same as live dispatch. + Cached briefly so XC XMLTV exports without an explicit ``prev_days`` do not + repeat the aggregate query on every request. + """ + def _scan(): + from django.db.models import Max + + from apps.channels.models import Stream + + result = Stream.objects.filter( + m3u_account__account_type="XC", + m3u_account__is_active=True, + is_catchup=True, + ).aggregate(max_days=Max("catchup_days")) + return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS) + + return cache.get_or_set( + "channels:provider_archive_days_capped", + _scan, + PROVIDER_ARCHIVE_CACHE_TTL_SECONDS, + ) + + +def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): + """Resolve ``prev_days`` for XC XMLTV and player_api EPG. + + Args: + request: HTTP request (reads ``?prev_days=``). + user: Authenticated user (reads ``custom_properties.epg_prev_days``). + auto_detect_fallback: When True (XC XMLTV), fall back to the largest + provider archive depth. When False (per-channel EPG), return 0 so + ``xc_get_epg`` can expand to each channel's ``catchup_days``. + + Resolution order: + 1. URL ``?prev_days=`` (explicit; 0 means no past programmes) + 2. ``user.custom_properties.epg_prev_days`` + 3. ``CoreSettings.proxy_settings.xmltv_prev_days_override`` when > 0 + 4. Auto-detect (only when *auto_detect_fallback* is True) + """ + user_custom = (user.custom_properties or {}) if user else {} + url_prev = request.GET.get("prev_days") + user_prev = user_custom.get("epg_prev_days") if user_custom else None + + if url_prev is not None: + try: + return max(0, min(int(url_prev), MAX_AUTO_PREV_DAYS)) + except (ValueError, TypeError): + return 0 + if user_prev not in (None, ""): + try: + return max(0, min(int(user_prev), MAX_AUTO_PREV_DAYS)) + except (ValueError, TypeError): + return 0 + + from core.models import CoreSettings + + proxy_settings = CoreSettings.get_proxy_settings() + try: + override = int(proxy_settings.get("xmltv_prev_days_override", 0) or 0) + except (TypeError, ValueError): + override = 0 + if override > 0: + return max(0, min(override, MAX_AUTO_PREV_DAYS)) + if auto_detect_fallback: + return compute_provider_archive_days_capped() + return 0 + + +def get_channel_catchup_streams(channel): + """Active catch-up streams for a channel, in ``channelstream`` order. + + Inactive M3U accounts are excluded, matching live dispatch. """ if not getattr(channel, "is_catchup", False): return [] diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 1075946a..7f26cb73 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -1,4 +1,4 @@ -"""URL builders + timestamp conversion + archive-days probe for XC catch-up.""" +"""URL builders and timestamp helpers for XC catch-up.""" import logging from collections import namedtuple @@ -6,16 +6,9 @@ from datetime import datetime, timezone from urllib.parse import quote from zoneinfo import ZoneInfo -from django.core.cache import cache - logger = logging.getLogger(__name__) -#: Resolved upstream credentials for one catch-up attempt. Produced from -#: ``get_transformed_credentials(account, reserved_profile)`` so the URL is -#: built with the credentials of the profile whose pool slot was actually -#: reserved — alternate profiles express different logins as URL regex -#: transforms, and using the raw account fields for them would desynchronize -#: pool accounting from real upstream usage. +# Credentials for the profile whose pool slot was reserved (not raw account fields). TimeshiftCredentials = namedtuple( "TimeshiftCredentials", ("server_url", "username", "password") ) @@ -24,43 +17,16 @@ DEFAULT_DURATION_MINUTES = 120 DURATION_BUFFER_MINUTES = 5 MAX_DURATION_MINUTES = 480 -PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300 -MAX_AUTO_PREV_DAYS = 30 - - -def compute_provider_archive_days_capped(): - """Largest `catchup_days` across all XC streams with catch-up (capped, cached). - - Uses the denormalized ``Stream.catchup_days`` field instead of iterating - JSON blobs — one aggregate query, no Python loop. - Returns 0 when no XC stream advertises catch-up. - """ - def _scan(): - from apps.channels.models import Stream - from django.db.models import Max - - result = ( - Stream.objects.filter( - m3u_account__account_type="XC", - is_catchup=True, - ) - .aggregate(max_days=Max("catchup_days")) - ) - return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS) - - return cache.get_or_set( - "timeshift:provider_archive_days_capped", - _scan, - PROVIDER_ARCHIVE_CACHE_TTL_SECONDS, - ) - def parse_catchup_timestamp(timestamp_str): - """Parse a timestamp string into a datetime, accepting colon-dash or underscore shapes. + """Parse a catch-up timestamp string. - Accepts: ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate native), - ``YYYY-MM-DD_HH-MM`` (XC underscore shape). - Returns None on failure. + Args: + timestamp_str: ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate) or + ``YYYY-MM-DD_HH-MM`` (XC underscore form). + + Returns: + A naive datetime on success, or None. """ for fmt in ("%Y-%m-%d:%H-%M", "%Y-%m-%d_%H-%M"): try: @@ -71,21 +37,15 @@ def parse_catchup_timestamp(timestamp_str): def convert_timestamp_to_provider_tz(timestamp_str, provider_tz_name): - """Convert a UTC catch-up timestamp to the serving provider's local zone. + """Convert a UTC catch-up timestamp to the provider's local zone. - The XC API surface is strictly UTC, so the client builds the catch-up URL - from a UTC wall-clock. Real XC providers, however, index their archive in - their OWN local zone (the ``server_info.timezone`` they report on auth). This - shifts the UTC instant into that zone so the upstream seek lands on the right - hour — empirically a provider reading ``17-00`` as its local time returns the - 17:00-local programme, not 17:00 UTC. + Args: + timestamp_str: UTC wall-clock in ``YYYY-MM-DD:HH-MM`` or underscore form. + provider_tz_name: IANA zone from the provider's ``server_info.timezone`` + (e.g. ``Europe/Brussels``). Falsy, ``UTC``, or unknown: no conversion. - ``timestamp_str`` is ``YYYY-MM-DD:HH-MM`` or ``YYYY-MM-DD_HH-MM`` (UTC). - ``provider_tz_name`` is an IANA name (e.g. ``Europe/Brussels``). When it is - falsy, ``"UTC"``, or unknown, the timestamp is returned unchanged — providers - that index in UTC (or whose zone we don't know) need no shift. Returns the - colon-dash shape ``YYYY-MM-DD:HH-MM`` (the canonical PATH shape). On any parse - failure the input is returned unchanged. + Returns: + ``YYYY-MM-DD:HH-MM`` in the provider zone, or the input unchanged on skip/failure. """ if not provider_tz_name or provider_tz_name == "UTC": return timestamp_str @@ -96,33 +56,31 @@ def convert_timestamp_to_provider_tz(timestamp_str, provider_tz_name): target = ZoneInfo(provider_tz_name) except Exception: logger.warning( - "Timeshift: unknown provider timezone %r — no conversion applied", + "Timeshift: unknown provider timezone %r, no conversion applied", provider_tz_name, ) return timestamp_str - # datetime.timezone.utc (not ZoneInfo("UTC")) for the source side — immune to - # a mis-set host /etc/timezone. astimezone() is DST-correct for the date. + # timezone.utc, not ZoneInfo("UTC"): avoids mis-set Docker /etc/timezone. local_dt = dt.replace(tzinfo=timezone.utc).astimezone(target) return local_dt.strftime("%Y-%m-%d:%H-%M") def get_programme_duration(channel, timestamp_str): - """Duration in minutes of the EPG programme starting at `timestamp_str`. + """Look up catch-up duration in minutes from EPG. - `timestamp_str` is `YYYY-MM-DD:HH-MM` or `YYYY-MM-DD_HH-MM` in UTC — the - client's original value, built from the strictly-UTC EPG output. The - UTC→provider-zone conversion happens only for the upstream URL (in - ``timeshift_proxy``), never for this EPG lookup: programmes are stored in - UTC, so the lookup must stay in UTC too. - Falls back to a 120-minute default if EPG lookup fails. + Args: + channel: Channel with optional ``epg_data`` relation loaded. + timestamp_str: Programme start in UTC (same shape as the client URL). + + Returns: + Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``, + or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails. """ try: dt = parse_catchup_timestamp(timestamp_str) if dt is None: return DEFAULT_DURATION_MINUTES - # EPG start_time/end_time are timezone-aware (USE_TZ=True), so the - # parsed datetime must also be aware to avoid a TypeError in the ORM - # filter. + # EPG times are timezone-aware; parsed value must be too. dt = dt.replace(tzinfo=timezone.utc) if not channel.epg_data: return DEFAULT_DURATION_MINUTES @@ -141,9 +99,7 @@ def get_programme_duration(channel, timestamp_str): def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes): - """Format A: `/streaming/timeshift.php?username=&password=&stream=&start=&duration=`.""" - # Credentials are URL-encoded: a `&`, `/` or `#` in the password would - # otherwise corrupt the URL structure. + """QUERY layout: ``/streaming/timeshift.php?username=...&start=...``""" return ( f"{creds.server_url.rstrip('/')}/streaming/timeshift.php" f"?username={quote(str(creds.username), safe='')}" @@ -155,7 +111,7 @@ def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes): def build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes): - """Format B: `/timeshift/{user}/{pass}/{duration}/{timestamp}/{stream_id}.ts`.""" + """PATH layout: ``/timeshift/{user}/{pass}/{dur}/{start}/{id}.ts``""" return ( f"{creds.server_url.rstrip('/')}/timeshift" f"/{quote(str(creds.username), safe='')}" @@ -167,33 +123,23 @@ def build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes): def build_timeshift_candidate_urls(creds, stream_id, timestamp, duration_minutes): - """Ordered upstream catch-up candidates — PATH form first. + """Build ordered upstream URL candidates (PATH forms first, QUERY last). - Two URL layouts exist on XC servers and they do NOT behave the same: + Args: + creds: ``TimeshiftCredentials`` for the reserved profile. + stream_id: Provider stream id from the catch-up stream's custom properties. + timestamp: Already converted to the serving provider's local zone. + duration_minutes: Archive window length passed to the provider. - • Format B — PATH layout: ``/timeshift/{user}/{pass}/{dur}/{START}/{id}.ts`` - The canonical XC catch-up form (what TiviMate emits natively). It actually - SEEKS the requested archive instant. Tried FIRST. - • Format A — QUERY layout: ``/streaming/timeshift.php?...&start={START}`` - A non-standard variant. Some providers implement it incorrectly and return - the LIVE stream (HTTP 200, ignoring ``start``) — indistinguishable from a - real archive at the byte level, so it would masquerade as a successful - catch-up. Kept only as a fallback for providers that expose ONLY - timeshift.php, and therefore tried AFTER every PATH candidate. - - Within each layout we vary the timestamp shape, because different servers' - parsers accept different ones (colon-dash is the canonical PATH shape; - underscore and SQL-datetime cover other servers' parsers). No timezone - conversion happens here — the caller (``timeshift_proxy``) has already - converted the value to the serving provider's zone; only the *shape* varies. + Returns: + List of URL strings to try in order. QUERY forms are last because some + providers return live TV even when ``start`` is set. """ underscore_ts = format_timestamp_as_underscore(timestamp) sql_ts = format_timestamp_as_sql_datetime(timestamp) return [ - # PATH form first — it seeks the archive correctly. build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes), build_timeshift_url_format_b(creds, stream_id, underscore_ts, duration_minutes), - # QUERY form fallback — may return LIVE on some providers (see above). build_timeshift_url_format_a(creds, stream_id, underscore_ts, duration_minutes), build_timeshift_url_format_a(creds, stream_id, sql_ts, duration_minutes), build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes), @@ -201,18 +147,7 @@ def build_timeshift_candidate_urls(creds, stream_id, timestamp, duration_minutes def format_timestamp_as_underscore(timestamp): - """Reshape ``YYYY-MM-DD:HH-MM`` to ``YYYY-MM-DD_HH-MM`` without any - timezone conversion. - - Many XC servers use the underscore shape as their - canonical catch-up URL format, especially for recently-indexed archives - (< 5–6 hours old). The colon-dash and SQL shapes only resolve against the - legacy catch-up parser, which covers archives older than roughly half a day. - - Shape-only, by design: the single timezone conversion in the chain happens - upstream in ``timeshift_proxy`` (UTC → serving provider's zone), so the - value received here is already in the provider's zone. - """ + """Reshape to ``YYYY-MM-DD_HH-MM`` without timezone conversion.""" dt = parse_catchup_timestamp(timestamp) if dt is None: logger.error("Timeshift underscore reshape failed for %r: unrecognised format", timestamp) @@ -221,17 +156,7 @@ def format_timestamp_as_underscore(timestamp): def format_timestamp_as_sql_datetime(timestamp): - """Reshape ``YYYY-MM-DD:HH-MM`` (or underscore variant) to ``YYYY-MM-DD HH:MM:SS`` - without any timezone conversion. - - Some XC servers refuse the dash-only shape for archives whose recording - is still being finalised and only resolve the SQL-datetime shape. - - Shape-only, by design: the single timezone conversion in the chain happens - upstream in ``timeshift_proxy`` (UTC → serving provider's zone via - ``convert_timestamp_to_provider_tz``), so the value received here is - already in the provider's zone — this function must not convert again. - """ + """Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion.""" dt = parse_catchup_timestamp(timestamp) if dt is None: logger.error("Timeshift SQL timestamp reshape failed for %r: unrecognised format", timestamp) From a8f48a1cbbf170c2e776b7f7d7c405c852c24f14 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:47:38 -0500 Subject: [PATCH 18/56] refactor(tasks): Remove outdated comments and simplify the rollup_channel_catchup_fields function. Enhance clarity by focusing on active account streams and self-healing logic for catch-up flags. --- apps/m3u/tasks.py | 51 +++++++++-------------------------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0f36d4fc..34a72316 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1053,9 +1053,6 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, account_type='XC', stream_id=provider_stream_id ) - # Derive denormalized catch-up fields from the XC - # API response at import time so output views can - # read them as zero-cost column reads. _tv_archive = str(stream.get("tv_archive", "0")) _is_catchup = _tv_archive in ("1", "True") try: @@ -1291,8 +1288,6 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): account_type=account_type_for_hash, stream_id=provider_stream_id ) - # Derive catch-up fields from stream attributes (M3U files - # may carry tv_archive via custom attributes). _attrs = stream_info["attributes"] _tv_archive_m3u = str(_attrs.get("tv_archive", "0")) _is_catchup_m3u = _tv_archive_m3u in ("1", "True") @@ -1890,23 +1885,7 @@ def _classify_sync_failure(exc): def rollup_channel_catchup_fields(account_id): - """Roll up denormalized catch-up fields from streams to channels. - - Updates ``is_catchup`` and ``catchup_days`` on every Channel that has - at least one Stream belonging to *account_id* (auto-created or manually - assigned), then self-heals any channel left flagged with no catch-up - stream at all. - - Called from the account-refresh pipeline after streams have been - written so that both new and existing channels reflect the current - ``tv_archive`` flags. Intentionally separate from - ``sync_auto_channels`` because it applies to manual channels too and - has no dependency on auto-channel logic. - - Uses a single-pass CTE (``bool_or`` + ``MAX``) instead of correlated - subqueries so the work stays O(channelstream rows for this account) - rather than O(channels * subqueries). - """ + """Roll up catch-up flags from streams to channels (active accounts only).""" from django.db import connection with connection.cursor() as cur: @@ -1914,12 +1893,11 @@ def rollup_channel_catchup_fields(account_id): WITH agg AS ( SELECT cs.channel_id, - bool_or(s.is_catchup) AS any_catchup, - -- Only catch-up streams contribute their archive depth: - -- a non-catchup stream may carry a stale catchup_days value. - MAX(s.catchup_days) FILTER (WHERE s.is_catchup) AS max_days + bool_or(s.is_catchup AND a.is_active) AS any_catchup, + MAX(s.catchup_days) FILTER (WHERE s.is_catchup AND a.is_active) AS max_days FROM dispatcharr_channels_channelstream cs JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + JOIN m3u_m3uaccount a ON a.id = s.m3u_account_id WHERE cs.channel_id IN ( SELECT DISTINCT cs2.channel_id FROM dispatcharr_channels_channelstream cs2 @@ -1936,16 +1914,7 @@ def rollup_channel_catchup_fields(account_id): WHERE c.id = agg.channel_id """, [account_id]) - # Self-heal: reset channels still flagged as catch-up that no longer - # have ANY catch-up stream. The CTE above only reaches channels that - # still hold at least one stream from this account, so a channel whose - # last stream was just removed (stale cleanup, manual unassignment) - # falls outside its scope. The ChannelStream post_delete signal - # normally resets those rows already — this pass guarantees the - # invariant regardless of how the link rows disappeared (raw SQL, - # signal-less bulk operations, historic staleness). Idempotent and - # cheap: ``is_catchup`` is indexed and TRUE on a small minority of - # channels. + # Self-heal stale is_catchup flags. cur.execute(""" UPDATE dispatcharr_channels_channel c SET is_catchup = FALSE, catchup_days = 0 @@ -1954,7 +1923,10 @@ def rollup_channel_catchup_fields(account_id): SELECT 1 FROM dispatcharr_channels_channelstream cs JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id - WHERE cs.channel_id = c.id AND s.is_catchup = TRUE + JOIN m3u_m3uaccount a ON a.id = s.m3u_account_id + WHERE cs.channel_id = c.id + AND s.is_catchup = TRUE + AND a.is_active = TRUE ) """) @@ -3728,11 +3700,6 @@ def _refresh_single_m3u_account_impl(account_id): f"Error running auto channel sync for account {account_id}: {str(e)}" ) - # Roll up catch-up fields from streams to channels. Runs after - # sync_auto_channels so new and existing channels both reflect the - # current tv_archive flags from the just-completed stream refresh. - # Covers manual channels too, so it lives here rather than inside - # sync_auto_channels which only manages auto-created channels. try: rollup_channel_catchup_fields(account_id) logger.debug(f"Catch-up field rollup complete for account {account_id}") From fa39a594be246d2f3234f0d9f37eeeb3b1c2decd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:53:02 -0500 Subject: [PATCH 19/56] feat(epg): Enhance generate_epg function to support xc_catchup_prev_days parameter for improved catch-up day resolution. Update related views and tests to ensure consistent behavior across EPG generation and catch-up functionality. --- apps/output/epg.py | 34 +++++++----------------- apps/output/tests.py | 19 ++++++++++++++ apps/output/views.py | 62 +++++++------------------------------------- 3 files changed, 37 insertions(+), 78 deletions(-) diff --git a/apps/output/epg.py b/apps/output/epg.py index b017b8e5..d34d209e 100644 --- a/apps/output/epg.py +++ b/apps/output/epg.py @@ -1088,7 +1088,7 @@ def generate_dummy_epg( return xml_lines -def generate_epg(request, profile_name=None, user=None): +def generate_epg(request, profile_name=None, user=None, *, xc_catchup_prev_days=False): """ Dynamically generate an XMLTV (EPG) file using a streaming response. Since the EPG data is stored independently of Channels, we group programmes @@ -1100,32 +1100,16 @@ def generate_epg(request, profile_name=None, user=None): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - # prev_days resolution order: - # 1. URL ?prev_days= (explicit, even 0 means "no past") - # 2. user.custom_properties.epg_prev_days - # 3. CoreSettings.proxy_settings.xmltv_prev_days_override (>0) - # 4. Auto-detect: max provider tv_archive_duration capped at 30 - url_prev = request.GET.get('prev_days') - user_prev = user_custom.get('epg_prev_days') if user_custom else None - if url_prev is not None: - try: - prev_days = max(0, min(int(url_prev), 30)) - except (ValueError, TypeError): - prev_days = 0 - elif user_prev not in (None, ""): - try: - prev_days = max(0, min(int(user_prev), 30)) - except (ValueError, TypeError): - prev_days = 0 + if xc_catchup_prev_days: + from apps.channels.utils import resolve_xc_epg_prev_days + + prev_days = resolve_xc_epg_prev_days(request, user) else: - from apps.timeshift.helpers import compute_provider_archive_days_capped - from core.models import CoreSettings - proxy_settings = CoreSettings.get_proxy_settings() try: - override = int(proxy_settings.get("xmltv_prev_days_override", 0) or 0) - except (TypeError, ValueError): - override = 0 - prev_days = max(0, min(override, 30)) if override > 0 else compute_provider_archive_days_capped() + prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) + prev_days = max(0, min(prev_days, 30)) + except (ValueError, TypeError): + prev_days = 0 use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() cache_params = ( diff --git a/apps/output/tests.py b/apps/output/tests.py index 30b22a18..25a38d18 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -896,3 +896,22 @@ class XcVodSeriesRegressionTests(TestCase): stream = xc_get_vod_streams(self.request, self.user)[0] self.assertEqual(stream["container_extension"], first.container_extension) + + +class GenerateEpgPrevDaysTests(SimpleTestCase): + """Profile EPG keeps legacy prev_days=0 unless URL or user setting says otherwise.""" + + def setUp(self): + self.factory = RequestFactory() + + @patch("apps.output.epg.stream_cached_response") + @patch("apps.output.epg.Channel.objects") + def test_non_xc_epg_defaults_prev_days_to_zero(self, _channels, mock_cache): + from apps.output.epg import generate_epg + + mock_cache.side_effect = lambda cache_key, _source, **_kwargs: cache_key + request = self.factory.get("/epg/") + + cache_key = generate_epg(request, profile_name="test", user=None) + + self.assertIn(":p=0:", cache_key) diff --git a/apps/output/views.py b/apps/output/views.py index eee288cd..a7e34c31 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -369,20 +369,12 @@ def _xc_allowed_output_formats(user): def _build_xc_server_info(request, hostname, port): - """Build the server_info dict for XC API responses. + """Build XC ``server_info``; keep timezone, ``time_now``, and EPG times in UTC. - The timezone, time_now, and xc_get_epg start/end fields form a "timezone - triple" that MUST all use the same zone — XC clients (iPlayTV, TiviMate) - use server_info.timezone to interpret start/end strings and calculate seek - offsets into catch-up archives. We keep the whole triple in **UTC**: the EPG - surface stays timezone-neutral and any provider-local conversion happens at - catch-up request time, against the serving provider's own zone (see - apps/timeshift/views.timeshift_proxy). This is what keeps multi-provider - setups consistent. Learned from plugin v1.1.4 → v1.2.6 (the earlier - per-instance timezone shifting caused 6 iterations of wrong-programme bugs). + XC clients use ``server_info.timezone`` to interpret EPG start/end strings. + Provider-local conversion happens in the timeshift proxy at request time. """ - # datetime.timezone.utc, not ZoneInfo("UTC"): the latter can read a mis-set - # host /etc/timezone in some Docker setups. + # datetime.timezone.utc, not ZoneInfo("UTC"); avoids mis-set Docker /etc/timezone. return { "url": hostname, "server_protocol": request.scheme, @@ -552,10 +544,7 @@ def xc_xmltv(request): ) return JsonResponse({'error': 'Unauthorized'}, status=401) - # XMLTV is emitted in UTC — the XC API surface is strictly UTC. Catch-up - # clients build their seek from the UTC wall-clock; the timeshift proxy - # applies any provider-local offset at request time. No per-instance rewrite. - return generate_epg(request, None, user) + return generate_epg(request, None, user, xc_catchup_prev_days=True) def xc_get_live_categories(user): @@ -705,11 +694,6 @@ def _xc_channel_entry(channel, channel_num_map, _get_default_group_id, _logo_url effective_group = channel.effective_channel_group_obj group_id = effective_group.id if effective_group else _get_default_group_id() - # Denormalized catch-up fields — populated at M3U/XC import time and - # rolled up via ChannelStream signal. Zero DB queries here. - # Always emit channel.id as stream_id — clients must only see - # Dispatcharr's internal IDs. The provider's stream_id is a backend - # detail looked up internally by the timeshift endpoint. if channel.is_catchup: tv_archive = 1 tv_archive_duration = channel.catchup_days @@ -767,17 +751,12 @@ def xc_get_epg(request, user, short=False): if not channel_id: raise Http404() - # Clients always receive channel.id from get_live_streams, so this is - # a straightforward int lookup — no provider stream_id fallback needed. try: resolved_channel_id = int(channel_id) except (TypeError, ValueError): raise Http404() channel = None - # Apply effective-value annotation + hidden-exclusion at every channel - # resolution path so a single channel lookup honors the same visibility - # rules as xc_get_live_streams. def _annotate(qs): return with_effective_values(qs, select_related_fks=True).exclude(hidden_from_output=True) @@ -859,6 +838,8 @@ def xc_get_epg(request, user, short=False): int(channel.effective_channel_number) if channel.effective_channel_number is not None else 0, ) + from apps.channels.utils import resolve_xc_epg_prev_days + limit = int(request.GET.get('limit', 4)) user_custom = user.custom_properties or {} try: @@ -866,21 +847,10 @@ def xc_get_epg(request, user, short=False): num_days = max(0, min(num_days, 365)) except (ValueError, TypeError): num_days = 0 - try: - prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) - prev_days = max(0, min(prev_days, 30)) - except (ValueError, TypeError): - prev_days = 0 + prev_days = resolve_xc_epg_prev_days(request, user, auto_detect_fallback=False) now = django_timezone.now() - # When a channel supports catch-up, automatically include past programmes - # within the archive window even if the client did not request prev_days. - # XC clients (iPlayTV, TiviMate) call get_simple_data_table without - # prev_days and expect the response to already contain archived entries - # marked with has_archive=1 — they use that list to populate the catch-up - # programme menu. - # Use denormalized catch-up fields (zero DB queries). Clamp to the same - # 30-day ceiling applied to explicit prev_days values. + # XC catch-up clients expect past programmes when prev_days was not set. _channel_is_catchup = getattr(channel, "is_catchup", False) _channel_catchup_days = min(getattr(channel, "catchup_days", 0) or 0, 30) if _channel_is_catchup and prev_days == 0: @@ -931,21 +901,11 @@ def xc_get_epg(request, user, short=False): output = {"epg_listings": []} - # Reuse the denormalized catch-up fields for the has_archive flag. if _channel_is_catchup: archive_window = timedelta(days=_channel_catchup_days) else: archive_window = None - # start/end are emitted in UTC — the whole XC API surface is strictly UTC, - # so the "timezone triple" (server_info.timezone, these start/end strings, - # and time_now) is consistently UTC. XC clients display these strings and - # use server_info.timezone (also UTC) to build the catch-up URL; the proxy - # then converts that UTC instant to the SERVING provider's own local zone at - # request time (see apps/timeshift/views.timeshift_proxy). Keeping the EPG - # UTC is what makes multi-provider setups consistent — we cannot know at EPG - # time which provider will serve a given catch-up. - # start_timestamp/stop_timestamp (epoch) are inherently timezone-agnostic. _epg_utc = dt_timezone.utc for program in programs: @@ -981,10 +941,6 @@ def xc_get_epg(request, user, short=False): "stream_id": f"{channel_id}", } - # has_archive tells XC clients which past programmes are available - # for catch-up playback. Always emitted (not gated by short) because - # both get_simple_data_table and get_short_epg callers need it to - # populate the catch-up programme menu. if archive_window is not None and end < now and end > now - archive_window: program_output["has_archive"] = 1 else: From 9e941c7011c1788aec610d522caaa8135b5b6493 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 12:53:22 -0500 Subject: [PATCH 20/56] refactor(proxy): Consolidate MPEG-TS sync detection logic into utils.py and streamline comments in timeshift views. Update network access checks for consistency across XC API endpoints. Enhance clarity in timeshift proxy documentation and tests. --- apps/channels/tests/test_catchup_utils.py | 100 ++++++ apps/proxy/live_proxy/input/http_streamer.py | 28 -- apps/proxy/utils.py | 37 ++- apps/timeshift/tests/test_views.py | 7 +- apps/timeshift/views.py | 305 ++++--------------- 5 files changed, 181 insertions(+), 296 deletions(-) create mode 100644 apps/channels/tests/test_catchup_utils.py diff --git a/apps/channels/tests/test_catchup_utils.py b/apps/channels/tests/test_catchup_utils.py new file mode 100644 index 00000000..68ac20c3 --- /dev/null +++ b/apps/channels/tests/test_catchup_utils.py @@ -0,0 +1,100 @@ +from unittest.mock import patch + +from django.test import RequestFactory, SimpleTestCase, TestCase + +from apps.accounts.models import User +from apps.channels.models import Channel, ChannelStream, Stream +from apps.channels.utils import resolve_xc_epg_prev_days +from apps.m3u.models import M3UAccount + + +class ResolveXcEpgPrevDaysTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = User(username="xc-prev", custom_properties={}) + + def test_url_prev_days_zero_is_explicit(self): + request = self.factory.get("/xmltv.php?prev_days=0") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 0) + + def test_user_epg_prev_days_used_when_url_omitted(self): + self.user.custom_properties = {"epg_prev_days": 5} + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 5) + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_auto_detect_only_when_no_url_or_user_default(self, mock_compute): + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14) + mock_compute.assert_called_once() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_per_channel_epg_skips_global_auto_detect(self, mock_compute): + request = self.factory.get("/player_api.php") + self.assertEqual( + resolve_xc_epg_prev_days(request, self.user, auto_detect_fallback=False), + 0, + ) + mock_compute.assert_not_called() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + def test_user_default_prevents_auto_detect(self, mock_compute): + self.user.custom_properties = {"epg_prev_days": 3} + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3) + mock_compute.assert_not_called() + + @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) + @patch("core.models.CoreSettings.get_proxy_settings", return_value={"xmltv_prev_days_override": 7}) + def test_proxy_override_prevents_auto_detect(self, _settings, mock_compute): + request = self.factory.get("/xmltv.php") + self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7) + mock_compute.assert_not_called() + + +class CatchupRollupActiveAccountTests(TestCase): + """Denormalized catch-up flags ignore disabled M3U accounts.""" + + @classmethod + def setUpTestData(cls): + cls.inactive = M3UAccount.objects.create( + name="catchup-inactive", + server_url="http://example.test", + account_type="XC", + is_active=False, + ) + + def test_channelstream_signal_ignores_inactive_catchup_stream(self): + channel = Channel.objects.create(name="inactive-only") + stream = Stream.objects.create( + name="inactive-catchup", + url="http://example.test/inactive", + m3u_account=self.inactive, + is_catchup=True, + catchup_days=9, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) + + def test_rollup_ignores_inactive_catchup_stream(self): + from apps.m3u.tasks import rollup_channel_catchup_fields + + channel = Channel.objects.create(name="rollup-inactive-only") + stream = Stream.objects.create( + name="rollup-inactive-catchup", + url="http://example.test/rollup-inactive", + m3u_account=self.inactive, + is_catchup=True, + catchup_days=9, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(self.inactive.id) + + channel.refresh_from_db() + self.assertFalse(channel.is_catchup) + self.assertEqual(channel.catchup_days, 0) diff --git a/apps/proxy/live_proxy/input/http_streamer.py b/apps/proxy/live_proxy/input/http_streamer.py index d90297c5..7efb5d33 100644 --- a/apps/proxy/live_proxy/input/http_streamer.py +++ b/apps/proxy/live_proxy/input/http_streamer.py @@ -159,31 +159,3 @@ class HTTPStreamReader: # Wait for thread if self.thread and self.thread.is_alive(): self.thread.join(timeout=2.0) - - -# --------------------------------------------------------------------------- -# MPEG-TS sync detection (used by the timeshift catch-up proxy) -# --------------------------------------------------------------------------- - -_TS_PACKET_SIZE = 188 -_TS_SYNC_BYTE = 0x47 - - -def find_ts_sync(buf): - """Offset of the first MPEG-TS sync chain in *buf*, or -1. - - A valid chain needs 0x47 at offsets i, i+188 and i+376 — three sync - bytes one packet apart (the standard demuxer probe). The timeshift - proxy peeks at the first upstream bytes with this to reject HTTP-200 - PHP error pages and to strip any pre-sync preamble before the bytes - reach a strict demuxer (ExoPlayer). - """ - end = len(buf) - 2 * _TS_PACKET_SIZE - for i in range(0, end): - if ( - buf[i] == _TS_SYNC_BYTE - and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE - and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE - ): - return i - return -1 diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index 32e2b674..d3720801 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -63,17 +63,10 @@ def attempt_stream_termination(user_id, requesting_client_id, active_connections if result.get("status") == "error": logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}") elif t['type'] == 'timeshift': - # Timeshift uses the same Redis key pattern as live - # (RedisKeys.client_stop). The stream_generator in - # apps/timeshift/views.py polls this key on its 5-second - # heartbeat cadence. + # Same Redis stop key as live; timeshift generator polls it every 5s. redis_client = RedisClient.get_client() if not redis_client: - # Without Redis the stop key cannot be set, so the old - # stream cannot be terminated. Failing the whole attempt - # is the safe outcome: the caller then denies the NEW - # stream instead of letting the user exceed the provider's - # connection limit (which escalates to an IP ban). + # Deny the new stream if we cannot stop the old one. return False stop_key = RedisKeys.client_stop(t['media_id'], t['client_id']) redis_client.setex(stop_key, 60, "true") @@ -123,7 +116,6 @@ def get_user_active_connections(user_id): if user_id is None or (client_user_id and int(client_user_id) == user_id): try: - # Timeshift virtual_channel_ids start with "timeshift_" conn_type = 'timeshift' if channel_id.startswith('timeshift_') else 'live' logger.debug(f"[stream limits] Found {conn_type.upper()} connection for user {user_id} on channel {channel_id} with client ID {client_id}") connected_at = float(connected_at) if connected_at else 0 @@ -205,3 +197,28 @@ def check_user_stream_limits(user, client_id, media_id=None): return False return True + + +_TS_PACKET_SIZE = 188 +_TS_SYNC_BYTE = 0x47 + + +def find_ts_sync(buf): + """Return byte offset of the first valid MPEG-TS sync chain in *buf*, or -1. + + Args: + buf: Raw bytes from an upstream HTTP response (typically the first 1 KB). + + Returns: + Offset of the first 0x47 byte that starts three consecutive 188-byte + packets, or -1. Used to strip PHP/HTML preamble before streaming. + """ + end = len(buf) - 2 * _TS_PACKET_SIZE + for i in range(0, end): + if ( + buf[i] == _TS_SYNC_BYTE + and buf[i + _TS_PACKET_SIZE] == _TS_SYNC_BYTE + and buf[i + 2 * _TS_PACKET_SIZE] == _TS_SYNC_BYTE + ): + return i + return -1 diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 59b8fd0b..7d741784 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from django.test import RequestFactory, TestCase, override_settings from apps.timeshift import views -from apps.proxy.live_proxy.input.http_streamer import find_ts_sync as _find_ts_sync +from apps.proxy.utils import find_ts_sync as _find_ts_sync class FindTsSyncTests(TestCase): @@ -404,8 +404,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): stream_mock.assert_not_called() def test_network_access_denied_returns_403(self): - # Same network-level gate as the live XC endpoint: when the request's - # network is not allowed for STREAMS, nothing else runs. + # Same network gate as other XC API endpoints (player_api, xmltv, etc.). request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=False) as gate, \ @@ -415,7 +414,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): request, "u", "p", "8", "2026-06-08:17-00", "8.ts" ) self.assertEqual(response.status_code, 403) - self.assertEqual(gate.call_args[0][1], "STREAMS") + self.assertEqual(gate.call_args[0][1], "XC_API") channel_cls.objects.get.assert_not_called() stream_mock.assert_not_called() diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index f158df4b..e93777d8 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -1,20 +1,4 @@ -"""XC catch-up (timeshift) HTTP view — proxies the provider's catch-up -archive (PATH `/timeshift/.../{id}.ts` form first, `/streaming/timeshift.php` -query form as fallback) to clients like iPlayTV and TiviMate, with -multi-provider failover across the channel's catch-up streams. - -Provider capacity accounting follows the same contract as live and VOD: a -profile slot is reserved through ``apps.m3u.connection_pool`` before any -upstream connect and released exactly once when the session ends (one-shot -Redis token), so Dispatcharr's pool counters always match real upstream usage. -A user gets ONE active catch-up session per channel — a seek displaces the -previous one. - -URL parameter quirk matching what iPlayTV / TiviMate emit: - Position "stream_id" -> EPG channel number, IGNORED here. - Position "duration" -> Dispatcharr's Channel.id (the XC API emits - channel.id as stream_id to clients). -""" +"""XC catch-up (timeshift) proxy with multi-provider failover.""" import hmac import itertools @@ -42,8 +26,11 @@ from apps.proxy.live_proxy.config_helper import ConfigHelper from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState from apps.proxy.live_proxy.redis_keys import RedisKeys from apps.proxy.live_proxy.utils import get_client_ip -from apps.proxy.live_proxy.input.http_streamer import find_ts_sync -from apps.proxy.utils import check_user_stream_limits, get_user_active_connections +from apps.proxy.utils import ( + check_user_stream_limits, + find_ts_sync, + get_user_active_connections, +) from core.utils import RedisClient from dispatcharr.utils import network_access_allowed @@ -61,17 +48,20 @@ CLIENT_TTL_SECONDS = 60 def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id - # The "duration" URL slot carries Dispatcharr's internal Channel.id — the - # XC API emits channel.id as the stream_id to clients, never the provider's - # stream_id. Resolve by Channel.id; 404 if it doesn't exist. + """Proxy an XC catch-up request to the provider with multi-stream failover. + + URL shape (iPlayTV / TiviMate): + ``stream_id``: EPG channel number (ignored here). + ``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id). + ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM``). + """ raw_id = duration[:-3] if duration.endswith(".ts") else duration user = _authenticate_user(username, password) if user is None: return HttpResponseForbidden("Invalid credentials") - # Same network-level gate as the live XC stream endpoint (stream_xc). - if not network_access_allowed(request, "STREAMS", user): + if not network_access_allowed(request, "XC_API", user): return HttpResponseForbidden("Access denied") try: @@ -82,10 +72,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if not _user_can_access_channel(user, channel): return HttpResponseForbidden("Access denied") - # Reject malformed timestamps up front: every shape helper falls back to - # pass-through on parse failure, so an unvalidated value would be forwarded - # verbatim into the upstream URL (query-injection vector + a pointless - # provider request). + # Shape helpers pass through on parse failure; reject bad input before upstream. if parse_catchup_timestamp(timestamp) is None: return HttpResponseBadRequest("Invalid timestamp") @@ -93,15 +80,9 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if not catchup_streams: return HttpResponseBadRequest("Timeshift not supported for this channel") - # Verbose timeshift logging follows the standard logger level (set via - # DISPATCHARR_LOG_LEVEL / the logging config), not a per-feature toggle. debug = logger.isEnabledFor(logging.DEBUG) - # The client supplies the catch-up timestamp in UTC — Dispatcharr's XC API - # surface is strictly UTC (server_info.timezone="UTC", EPG start/end in UTC), - # and the client builds the URL from those UTC strings. The EPG duration - # lookup therefore uses the original UTC value (programmes are stored in - # UTC); the per-provider conversion happens inside the failover loop below. + # EPG duration lookup stays in UTC; provider TZ conversion is per-attempt below. duration_minutes = get_programme_duration(channel, timestamp) safe_ts = timestamp.replace(":", "-").replace("/", "-") @@ -112,35 +93,14 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) redis_client = RedisClient.get_client() - # One active catch-up session per user+channel: a new request (programme - # jump, or a seek re-request on the same programme) DISPLACES the user's - # previous session on this channel. Its provider slot is released - # synchronously — so the reservation below cannot collide with the dying - # session on max_connections=1 providers — and its generator is stopped - # via the same Redis stop-key the stream-limit path uses. Runs BEFORE the - # stream-limit check so a seek displaces its own predecessor instead of - # being denied when terminate_on_limit_exceeded is off. + # Displace any prior catch-up session on this channel before reserving a slot. _terminate_previous_timeshift_sessions(redis_client, user, channel.id) - # Enforce user stream limits once, before connecting upstream — this - # terminates the oldest active stream (live, timeshift, or VOD) via the - # Redis stop-key mechanism, same as the live and VOD proxy entry points. - # (No explicit ChannelService.stop_channel(): that would kill other users' - # live streams on the same channel. The media_id only matters for the - # live same-channel exemption, so a channel-scoped id is enough.) if not check_user_stream_limits( user, client_id, media_id=f"timeshift_{channel.id}_{safe_ts}" ): return HttpResponseForbidden("Stream limit exceeded") - # Failover: try each catch-up stream in channelstream order — mirroring how - # live playback walks a channel's stream list. Every attempt carries ITS - # OWN provider context (account, provider stream id, reported timezone, - # user-agent, per-account format cache). Ban safety is per ACCOUNT: when an - # account fails decisively (401/403/406 — auth or ban-class status), its - # other catch-up streams (e.g. FHD/HD variants of the same channel) are - # skipped too, so the cascade never hammers a banning provider; a DIFFERENT - # account is a different host and remains safe to try. last_response = None decisive_accounts = set() capacity_blocked = False @@ -155,9 +115,6 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if stream_id_value is None: continue - # Provider profile walk — same ordering as live's Channel.get_stream(): - # active profiles only, default first; an account without an active - # default profile is skipped entirely, mirroring live dispatch. m3u_profiles = list(m3u_account.profiles.filter(is_active=True)) default_profile = next((p for p in m3u_profiles if p.is_default), None) if default_profile is None: @@ -170,35 +127,17 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) p for p in m3u_profiles if not p.is_default ] - # Real XC providers index their archive in their OWN local zone (the - # server_info.timezone they report on auth — stored on the default - # profile). Convert the UTC instant to that zone for the upstream URL — - # empirically a provider reads `17-00` as 17:00 LOCAL, not UTC, so - # skipping this would seek 1-2h off. This is the ONLY timezone - # conversion in the chain, and it is per-provider (account-level: all - # profiles log into the same server, so the default profile's zone - # applies to every profile of the walk). + # Providers index archives in their own timezone (from server_info on auth). provider_tz_name = None _server_info = (default_profile.custom_properties or {}).get("server_info") or {} if isinstance(_server_info, dict): provider_tz_name = _server_info.get("timezone") provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) - # Reserve a provider profile slot BEFORE connecting upstream — the - # same accounting contract live (Channel.get_stream) and VOD follow, - # so the Redis pool counters always match real upstream usage. Walk - # to the next profile only on RESERVATION failure (profile_full / - # credential_full — transient capacity, never ban-class); an upstream - # failure instead moves to the next catch-up STREAM, because probing - # the same server again through an alternate profile is wasteful and - # ban-adjacent. + # Reserve a provider profile slot before connecting (same contract as live/VOD). reserved_profile = None for profile in profile_walk: if redis_client is None: - # Without Redis no accounting is possible — proceed unpooled - # rather than blocking playback (stats writes are equally - # fail-open). Deployments always run Redis; this is a dev-only - # path. reserved_profile = profile break reserved, _count, reason = reserve_profile_slot(profile, redis_client) @@ -206,7 +145,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) reserved_profile = profile break logger.info( - "Timeshift: profile %s %s on account %s — trying next profile", + "Timeshift: profile %s %s on account %s, trying next profile", profile.id, reason or "unavailable", m3u_account.id, ) if reserved_profile is None: @@ -223,11 +162,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) redis_client, client_id, reserved_profile.id ) if not token_stored: - # Redis hiccup right after the INCR: without a token no - # release path could ever free this slot, so release it - # directly NOW (race-free: the session is not yet visible to - # the takeover scan) and report transient unavailability - # instead of streaming unaccounted. + # Release immediately if we cannot record ownership for later cleanup. try: release_profile_slot(reserved_profile.id, redis_client) except Exception as exc: @@ -238,25 +173,14 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) capacity_blocked = True continue - # From here until the streaming response owns the slot, ANY failure - # must release the reservation before propagating — otherwise the - # pool counter (which has no TTL) leaks until the next Redis flush. try: - # Build the upstream URLs with the RESERVED profile's credentials, - # resolved the same way live playback does (credential extraction - # via the profile's URL transform). Using the raw account fields - # for a non-default profile would consume that profile's slot - # while authenticating with the default login — double-occupying - # the provider connection the pool thinks is free. + # Failures before streaming starts must release the reserved slot. + # Use credentials for the profile whose slot was reserved. server_url, xc_username, xc_password = get_transformed_credentials( m3u_account, reserved_profile ) creds = TimeshiftCredentials(server_url, xc_username, xc_password) - # Ordered upstream candidates, PATH form first — see - # build_timeshift_candidate_urls() for the full rationale (the - # QUERY form is a last-resort fallback because some providers - # return LIVE on it, ignoring the requested timestamp). candidate_urls = build_timeshift_candidate_urls( creds, stream_id_value, provider_timestamp, duration_minutes ) @@ -295,23 +219,17 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) ) except Exception: if token_stored: - # A False return here means someone else (a takeover) already - # consumed the token and released the slot — nothing to do. _release_slot_token(redis_client, client_id) raise if response.status_code < 400: - # The reserved slot is now owned by the streaming response: it is - # released (exactly once, via the one-shot token) when the - # generator finishes or the WSGI layer closes the response. return response - # Failed attempt: free the slot before trying the next stream. _release_slot_token(redis_client, client_id) last_response = response if getattr(response, "timeshift_decisive", False): decisive_accounts.add(m3u_account.id) logger.warning( - "Timeshift attempt failed (HTTP %d%s) on account %s for channel %s — " + "Timeshift attempt failed (HTTP %d%s) on account %s for channel %s, " "trying next catch-up stream", response.status_code, ", decisive: skipping this account's other streams" @@ -322,19 +240,10 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if last_response is not None: return last_response if capacity_blocked: - # Every eligible stream failed on pool capacity alone (no upstream - # attempt was made) — mirror the VOD proxy's pool-exhausted status so - # clients back off instead of treating it as a permanent failure. return HttpResponse("No available stream slot", status=503) - # Streams existed but none was usable (non-XC accounts / missing stream_id). return HttpResponseBadRequest("Cannot build timeshift URL") -# --------------------------------------------------------------------------- -# Authentication, lookup, access control -# --------------------------------------------------------------------------- - - def _authenticate_user(username, password): try: user = User.objects.get(username=username) @@ -366,27 +275,17 @@ def _user_can_access_channel(user, channel): ) -# --------------------------------------------------------------------------- -# Provider pool slot ownership (one-shot release token) + session takeover -# --------------------------------------------------------------------------- - -# Maps a timeshift client to the profile slot it reserved. Consumed (GET+DEL, -# atomically) by whichever release path runs first — the generator's finally, -# the response-close wrapper, a failed failover attempt, or a takeover by the -# user's next request on the same channel — so the slot is released exactly -# once even across uWSGI workers. Note the TTL does NOT recover the slot: if a -# worker dies mid-session the token eventually expires while the counter (no -# TTL) stays consumed — like the rest of the pool, the recovery for -# crash-leaked counters is the Redis flush at container start -# (scripts/wait_for_redis.py). +# One-shot Redis token: whichever release path runs first frees the pool slot. _SLOT_TOKEN_KEY = "timeshift_slot:{client_id}" _SLOT_TOKEN_TTL = 24 * 3600 def _store_slot_token(redis_client, client_id, profile_id): - """Record slot ownership; returns False when the token could not be - written (the caller must then release the reservation itself — without a - token no other path ever could).""" + """Store a one-shot Redis token mapping *client_id* to *profile_id*. + + Returns False if the token could not be written; the caller must release + the reserved slot directly in that case. + """ if redis_client is None: return False try: @@ -402,13 +301,7 @@ def _store_slot_token(redis_client, client_id, profile_id): def _release_slot_token(redis_client, client_id): - """Release the profile slot owned by *client_id*, exactly once. - - GET+DEL inside a MULTI/EXEC transaction: concurrent release attempts are - serialized by Redis, so only the one that actually deletes a live token - decrements the pool counters. Returns True when this call performed the - release. - """ + """Release the profile slot owned by *client_id* (at most once via GET+DEL).""" if redis_client is None: return False key = _SLOT_TOKEN_KEY.format(client_id=client_id) @@ -427,15 +320,10 @@ def _release_slot_token(redis_client, client_id): def _terminate_previous_timeshift_sessions(redis_client, user, channel_id): - """Displace the user's previous catch-up session(s) on this channel. + """Displace the user's prior catch-up session(s) on this channel. - For each of the user's active timeshift sessions whose virtual channel id - belongs to *channel_id*: release its provider slot NOW (synchronously — - the dying generator's own release becomes a no-op thanks to the one-shot - token), drop its stats keys so the stream-limit count no longer includes - it, and set the stop key its generator polls on the 5-second heartbeat. - The accounting is therefore correct immediately; only the old TCP socket - closes lazily (≤ ~5 s). + Releases the provider slot, unregisters stats keys, and sets the live + stop key so the old generator exits on its next heartbeat. """ if redis_client is None or user is None: return @@ -461,14 +349,7 @@ def _terminate_previous_timeshift_sessions(redis_client, user, channel_id): class _SlotReleasingStream: - """Iterator wrapper whose close() always releases the reserved slot. - - Django registers ``streaming_content.close`` in the response's resource - closers, and the WSGI layer guarantees close() runs — including when the - client disconnects before the first chunk, in which case the generator - never starts and its ``finally`` would never execute. The one-shot token - makes the duplicate call from an already-finished generator a no-op. - """ + """Iterator wrapper that releases the pool slot when WSGI closes the response.""" def __init__(self, generator, on_close): self._generator = generator @@ -487,11 +368,6 @@ class _SlotReleasingStream: self._on_close() -# --------------------------------------------------------------------------- -# Stats integration (direct Redis writes, no ClientManager instance) -# --------------------------------------------------------------------------- - - def _register_stats_client( redis_client, virtual_channel_id, @@ -506,8 +382,7 @@ def _register_stats_client( channel_logo_id=None, m3u_profile_id=None, ): - """Write the same Redis keys the live proxy's ClientManager writes so the - catch-up viewer appears on `/stats`. `is_timeshift=1` toggles the badge.""" + """Write Redis keys so catch-up viewers appear on ``/stats``.""" if redis_client is None: return client_set_key = RedisKeys.clients(virtual_channel_id) @@ -583,23 +458,9 @@ def _unregister_stats_client(redis_client, virtual_channel_id, client_id): logger.warning("Timeshift stats unregister failed: %s", exc) -# --------------------------------------------------------------------------- -# Provider streaming -# --------------------------------------------------------------------------- - - def _open_upstream(url, user_agent, range_header): - """Open the upstream HTTP request (status + headers known synchronously). - - Redirects are followed on purpose: XC providers routinely 302 from the - main host to a load-balanced streaming node carrying a session token, so - disabling redirects would break normal catch-up. The cascade therefore - observes the FINAL status; a ban-onset 302 that redirects somewhere - non-streamable surfaces as a failed TS-sync peek (soft reject) or an - error status. - """ - # identity: the TS-sync peek reads raw bytes (response.raw), which are NOT - # transparently decompressed — a gzip-encoded body would fail the sync check. + """Open upstream HTTP; redirects are followed (XC load-balancer nodes).""" + # identity: raw peek bytes are not gzip-transparent. headers = {"Accept-Encoding": "identity"} if user_agent: headers["User-Agent"] = user_agent @@ -618,13 +479,7 @@ _FORMAT_CACHE_TTL = 3600 # 1 hour def _get_cached_format_index(account_id): - """Return the index of the URL shape that last worked for this account, - or None if we haven't seen one succeed yet. - - Uses ``django.core.cache`` (Redis-backed) so every uWSGI worker shares - the same format discovery — the first worker that finds the winning URL - shape caches it for all others. - """ + """Index of the URL shape that last worked for this account, or None.""" if account_id is None: return None return cache.get(_FORMAT_CACHE_KEY.format(account_id)) @@ -653,13 +508,13 @@ def _stream_from_provider( account_id=None, redis_client=None, ): - # Use 256 KB chunks: amortises per-yield uWSGI/gevent overhead. + """Try each upstream URL until one returns streamable MPEG-TS. + + Sets ``timeshift_decisive`` on auth/ban-class failures (401/403/406) so the + failover loop skips the rest of that account's streams. + """ chunk_size = max(ConfigHelper.chunk_size(), 262144) - # Reorder the candidates so the cached winning shape is tried first. - # The cache is per-account; within a single fast-forward session every - # seek lands in the same archive entry and therefore the same parser - # path, so the first attempt almost always succeeds. cached_index = _get_cached_format_index(account_id) if cached_index is not None and 0 <= cached_index < len(candidate_urls): ordered_urls = [candidate_urls[cached_index]] + [ @@ -672,10 +527,7 @@ def _stream_from_provider( ordered_urls = list(candidate_urls) original_indices = list(range(len(candidate_urls))) - # Try each candidate URL until one returns a streamable MPEG-TS response. - # Some XC servers return HTTP 200 with PHP error text instead of 404 when - # the timestamp format doesn't match their parser. We peek at the first - # bytes to confirm TS sync before accepting the response. + # Peek for MPEG-TS sync; some providers return HTTP 200 with PHP/HTML errors. upstream = None last_status = None last_url = ordered_urls[0] @@ -685,9 +537,6 @@ def _stream_from_provider( try: response = _open_upstream(url, user_agent, range_header) except requests.exceptions.RequestException as exc: - # Log only the exception class and a redacted URL: requests - # exceptions embed the full URL, which carries the XC credentials - # (path segments in format B, query params in format A). logger.error( "Timeshift provider unreachable (%s): %s", _redact_url(url), type(exc).__name__, @@ -703,24 +552,18 @@ def _stream_from_provider( _redact_url(url), ) if response.status_code in (200, 206): - # Peek at the first bytes to confirm we're getting MPEG-TS, not - # a PHP error page disguised as 200. Read up to 1 KB — enough - # to find a TS sync chain or detect HTML/PHP text. peek = response.raw.read(1024) sync_offset = find_ts_sync(peek) if peek else -1 if sync_offset >= 0: - # Valid TS — strip any pre-sync garbage (PHP warnings, BOM) - # and prepend the clean bytes into the iter_content chain. response._peek_data = peek[sync_offset:] upstream = response winning_index = orig_idx break else: - # No TS sync found — likely a PHP error. Log and try next. snippet = peek[:200].decode("utf-8", errors="replace") if peek else "(empty)" logger.warning( "Timeshift upstream returned 200 but no TS sync in first %d " - "bytes (likely PHP error): %s — url=%s", + "bytes (likely PHP error): %s, url=%s", len(peek) if peek else 0, snippet.replace("\n", " ")[:120], _redact_url(url), @@ -729,18 +572,7 @@ def _stream_from_provider( last_status = 404 # Treat as soft rejection for cascade continue response.close() - # Decisive statuses where trying other URL shapes can't help and may - # escalate an IP ban: auth failures (401/403) and IP-level block (406). - # The 3xx case is defense-in-depth only: requests follows redirects - # transparently (XC providers legitimately 302 to load-balanced - # streaming nodes, so redirects MUST be followed), meaning a 3xx can - # only surface here if that ever changes — but if one does, it is the - # documented first sign of an IP ban and must stop the cascade. - # A 5xx is different: it is usually format-specific — some XC servers run - # PHP with display_errors off, so the "Undefined array key" warning that - # another server emits as 200+text becomes a hard 500. The very next - # candidate timestamp shape often succeeds, so keep trying instead of - # giving up (this is why catch-up appeared broken on providers that 500). + # Auth/ban-class statuses stop trying more shapes on this account; 5xx does not. code = response.status_code if code in (401, 403, 406) or 300 <= code < 400: decisive_failure = True @@ -752,18 +584,13 @@ def _stream_from_provider( if upstream is None: logger.error("Timeshift upstream rejected: status=%s url=%s", last_status, _redact_url(last_url)) - # Mirror semantically meaningful upstream statuses so clients react - # correctly: 404 stops retry loops (catch-up not yet indexed), 403 - # surfaces auth problems. Everything else stays a generic 400. + # Map 404/403 to meaningful client responses; other failures stay 400. if last_status == 404: failure = HttpResponseNotFound("Catch-up not available yet") elif last_status == 403: failure = HttpResponseForbidden("Provider denied access") else: failure = HttpResponseBadRequest("Provider error") - # Tell the failover loop whether this account failed DECISIVELY - # (auth/ban-class status): its other catch-up streams must be skipped, - # while a different provider remains safe to try. failure.timeshift_decisive = decisive_failure return failure @@ -785,8 +612,6 @@ def _stream_from_provider( m3u_profile_id=m3u_profile_id, ) - # Stream directly via iter_content+yield (same pattern as VOD proxy). - # The peek already validated TS sync and stripped any preamble. peek_data = getattr(upstream, "_peek_data", None) chunks_iter = upstream.iter_content(chunk_size=chunk_size) if peek_data: @@ -798,8 +623,6 @@ def _stream_from_provider( total_yielded = 0 chunk_count = 0 loop_start = time.time() - # Same stop-key pattern as live (generator.py:395) and VOD - # (multi_worker_connection_manager.py:1095). stop_key = RedisKeys.client_stop(virtual_channel_id, client_id) try: for data in chunks_iter: @@ -810,12 +633,8 @@ def _stream_from_provider( total_yielded += len(data) chunk_count += 1 - # Stop-signal + stats heartbeat on the same 5-second cadence. - # Time-based (not chunk-modulo) so the provider slot is freed - # within seconds of a stream-limit termination regardless of - # bitrate — at 256 KB chunks a 100-chunk interval would mean - # ~25 MB (~27 s at typical FHD rates) before noticing the stop. now = time.time() + # Poll stop key and refresh stats every 5 seconds. if now - last_heartbeat >= 5: if redis_client and redis_client.exists(stop_key): logger.info("Timeshift client %s received stop signal", client_id) @@ -849,16 +668,8 @@ def _stream_from_provider( except Exception: pass _unregister_stats_client(redis_client, virtual_channel_id, client_id) - # Free the provider pool slot this session reserved. One-shot - # token: a no-op if a takeover (the user's next request on this - # channel) already released it. _release_slot_token(redis_client, client_id) - # The wrapper guarantees session teardown even when the generator never - # starts (client gone before the first chunk — its finally would then - # never run): Django registers the iterator's close() as a resource - # closer, which WSGI always invokes. Both calls are idempotent, so the - # duplicate from a normally-finished generator is harmless. def _close_session(): _unregister_stats_client(redis_client, virtual_channel_id, client_id) _release_slot_token(redis_client, client_id) @@ -869,16 +680,7 @@ def _stream_from_provider( content_type=content_type, status=status, ) - # Tell nginx not to buffer this streaming response. Without this, the - # default uwsgi_buffering=on under `location /` throttles the response - # roughly to half the upstream rate (back-pressure from buffer flushes - # propagates back into the generator). - response["X-Accel-Buffering"] = "no" - # Forward Content-Range so iPlayTV / TiviMate seek cursor stays correct. - # Content-Length is NOT forwarded: Django's StreamingHttpResponse uses - # chunked transfer encoding, which is incompatible with Content-Length. - # Sending both causes clients to wait for the full file instead of - # playing progressively. + response["X-Accel-Buffering"] = "no" # avoid nginx throttling the stream if content_range: response["Content-Range"] = content_range response["Accept-Ranges"] = "bytes" @@ -886,12 +688,7 @@ def _stream_from_provider( def _redact_url(url): - """Strip credentials from a URL for safe logging. - - Handles both ``user:pass@host`` and XC path-based credentials - (``/username/password/...``) by truncating to ``scheme://host/...``. - Query-string parameters are always stripped. - """ + """Truncate *url* to ``scheme://host/...`` for safe logging (drops credentials).""" if not url or "://" not in url: return url scheme, rest = url.split("://", 1) From 8e0a7b44f5fa8a4c0abecf90098740a749204d6a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 13:33:50 -0500 Subject: [PATCH 21/56] feat(timestamps): Introduce comprehensive timestamp normalization and parsing for catch-up functionality. Support various input formats including colon-dash, underscore, and Unix epoch. Enhance related tests to ensure robust handling of timestamp shapes in timeshift operations. --- apps/timeshift/helpers.py | 136 ++++++++++++++++++++++----- apps/timeshift/tests/test_helpers.py | 102 ++++++++++++++++++++ apps/timeshift/tests/test_views.py | 7 ++ apps/timeshift/views.py | 3 +- 4 files changed, 223 insertions(+), 25 deletions(-) diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 7f26cb73..98d10c36 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -1,6 +1,7 @@ """URL builders and timestamp helpers for XC catch-up.""" import logging +import re from collections import namedtuple from datetime import datetime, timezone from urllib.parse import quote @@ -17,23 +18,97 @@ DEFAULT_DURATION_MINUTES = 120 DURATION_BUFFER_MINUTES = 5 MAX_DURATION_MINUTES = 480 +# Wall-clock shapes seen from XC / iPlayTV / TiviMate clients. Compiled once. +_CATCHUP_WALL_CLOCK_RE = re.compile( + r"^" + r"(?P\d{4}-\d{2}-\d{2})" + r"(?P[:_]| )" + r"(?P\d{2})" + r"(?P[-:])" + r"(?P\d{2})" + r"(?:" + r":" + r"(?P\d{2})" + r")?" + r"$" +) + + +def normalize_catchup_timestamp_input(timestamp_str): + """Map a client catch-up timestamp to an ISO-8601 string for ``fromisoformat``. + + Supported inputs: + - ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate colon-dash) + - ``YYYY-MM-DD_HH-MM`` (XC underscore) + - ``YYYY-MM-DD:HH:MM[:SS]`` (XC colon time in catch-up URLs) + - ``YYYY-MM-DD HH:MM[:SS]`` (EPG / SQL datetime) + - Unix epoch seconds (10 digits) or milliseconds (13 digits) + + Returns: + An ISO-8601 date-time string (``YYYY-MM-DDTHH:MM:SS``), or None if + the value does not match a known catch-up shape. + """ + if timestamp_str is None: + return None + if not isinstance(timestamp_str, str): + timestamp_str = str(timestamp_str) + value = timestamp_str.strip() + if not value: + return None + + if value.isdigit(): + length = len(value) + if length == 10: + dt = datetime.fromtimestamp(int(value), tz=timezone.utc) + return dt.replace(tzinfo=None).isoformat(timespec="seconds") + if length == 13: + dt = datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc) + return dt.replace(tzinfo=None).isoformat(timespec="seconds") + return None + + match = _CATCHUP_WALL_CLOCK_RE.match(value) + if not match: + return None + + parts = match.groupdict() + second = parts["second"] or "00" + return f"{parts['date']}T{parts['hour']}:{parts['minute']}:{second}" + def parse_catchup_timestamp(timestamp_str): - """Parse a catch-up timestamp string. + """Parse a catch-up timestamp string into a naive UTC wall-clock datetime. - Args: - timestamp_str: ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate) or - ``YYYY-MM-DD_HH-MM`` (XC underscore form). + See ``normalize_catchup_timestamp_input`` for supported input shapes. Returns: A naive datetime on success, or None. """ - for fmt in ("%Y-%m-%d:%H-%M", "%Y-%m-%d_%H-%M"): - try: - return datetime.strptime(timestamp_str, fmt) - except ValueError: - continue - return None + iso_value = normalize_catchup_timestamp_input(timestamp_str) + if iso_value is None: + if timestamp_str is not None and str(timestamp_str).strip(): + logger.debug( + "Timeshift: unrecognised catch-up timestamp: %r", timestamp_str + ) + return None + try: + return datetime.fromisoformat(iso_value) + except ValueError: + logger.debug( + "Timeshift: invalid catch-up timestamp after normalize: %r -> %r", + timestamp_str, + iso_value, + ) + return None + + +def _reshape_timestamp(timestamp, strftime_fmt, label): + dt = parse_catchup_timestamp(timestamp) + if dt is None: + logger.error( + "Timeshift %s reshape failed for %r: unrecognised format", label, timestamp + ) + return timestamp + return dt.strftime(strftime_fmt) def convert_timestamp_to_provider_tz(timestamp_str, provider_tz_name): @@ -135,30 +210,43 @@ def build_timeshift_candidate_urls(creds, stream_id, timestamp, duration_minutes List of URL strings to try in order. QUERY forms are last because some providers return live TV even when ``start`` is set. """ - underscore_ts = format_timestamp_as_underscore(timestamp) - sql_ts = format_timestamp_as_sql_datetime(timestamp) + dt = parse_catchup_timestamp(timestamp) + if dt is None: + colon_dash_ts = timestamp + underscore_ts = timestamp + colon_seconds_ts = timestamp + sql_ts = timestamp + else: + colon_dash_ts = dt.strftime("%Y-%m-%d:%H-%M") + underscore_ts = dt.strftime("%Y-%m-%d_%H-%M") + colon_seconds_ts = dt.strftime("%Y-%m-%d:%H:%M:%S") + sql_ts = dt.strftime("%Y-%m-%d %H:%M:%S") return [ - build_timeshift_url_format_b(creds, stream_id, timestamp, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, colon_dash_ts, duration_minutes), build_timeshift_url_format_b(creds, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_b(creds, stream_id, colon_seconds_ts, duration_minutes), build_timeshift_url_format_a(creds, stream_id, underscore_ts, duration_minutes), build_timeshift_url_format_a(creds, stream_id, sql_ts, duration_minutes), - build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, colon_dash_ts, duration_minutes), + build_timeshift_url_format_a(creds, stream_id, colon_seconds_ts, duration_minutes), ] +def format_timestamp_as_colon_dash(timestamp): + """Reshape to ``YYYY-MM-DD:HH-MM`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d:%H-%M", "colon-dash") + + +def format_timestamp_as_colon_seconds(timestamp): + """Reshape to ``YYYY-MM-DD:HH:MM:SS`` without timezone conversion.""" + return _reshape_timestamp(timestamp, "%Y-%m-%d:%H:%M:%S", "colon-seconds") + + def format_timestamp_as_underscore(timestamp): """Reshape to ``YYYY-MM-DD_HH-MM`` without timezone conversion.""" - dt = parse_catchup_timestamp(timestamp) - if dt is None: - logger.error("Timeshift underscore reshape failed for %r: unrecognised format", timestamp) - return timestamp - return dt.strftime("%Y-%m-%d_%H-%M") + return _reshape_timestamp(timestamp, "%Y-%m-%d_%H-%M", "underscore") def format_timestamp_as_sql_datetime(timestamp): """Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion.""" - dt = parse_catchup_timestamp(timestamp) - if dt is None: - logger.error("Timeshift SQL timestamp reshape failed for %r: unrecognised format", timestamp) - return timestamp - return dt.strftime("%Y-%m-%d %H:%M:%S") + return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL") diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index 20f922d9..4bd7e0e3 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -1,5 +1,7 @@ """Tests for `apps.timeshift.helpers`: timestamp shape conversion and URL build.""" +from datetime import datetime, timezone + from django.test import TestCase from apps.timeshift.helpers import ( @@ -8,8 +10,12 @@ from apps.timeshift.helpers import ( build_timeshift_url_format_a, build_timeshift_url_format_b, convert_timestamp_to_provider_tz, + format_timestamp_as_colon_dash, + format_timestamp_as_colon_seconds, format_timestamp_as_sql_datetime, format_timestamp_as_underscore, + normalize_catchup_timestamp_input, + parse_catchup_timestamp, ) @@ -22,6 +28,87 @@ def _make_creds(): class TimestampFormatTests(TestCase): """Timestamp reshape functions change format only; no timezone conversion.""" + def test_normalize_colon_dash_shape(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-05-21:12-55"), + "2026-05-21T12:55:00", + ) + + def test_normalize_colon_seconds_xc_format(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-06-23:04:00:00"), + "2026-06-23T04:00:00", + ) + + def test_normalize_epg_sql_format(self): + self.assertEqual( + normalize_catchup_timestamp_input("2026-06-23 04:00:00"), + "2026-06-23T04:00:00", + ) + + def test_normalize_unix_epoch_seconds(self): + epoch = str(int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + normalize_catchup_timestamp_input(epoch), + "2026-06-23T04:00:00", + ) + + def test_normalize_unix_epoch_milliseconds(self): + epoch_ms = str( + int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp() * 1000) + ) + self.assertEqual( + normalize_catchup_timestamp_input(epoch_ms), + "2026-06-23T04:00:00", + ) + + def test_normalize_rejects_garbage(self): + self.assertIsNone(normalize_catchup_timestamp_input("garbage")) + self.assertIsNone(normalize_catchup_timestamp_input("")) + self.assertIsNone(normalize_catchup_timestamp_input("12345")) + + def test_parse_rejects_invalid_calendar_date(self): + self.assertIsNone(parse_catchup_timestamp("2026-13-45:04-00")) + + def test_parse_colon_dash_format(self): + dt = parse_catchup_timestamp("2026-05-21:12-55") + self.assertEqual(dt, datetime(2026, 5, 21, 12, 55, 0)) + + def test_parse_underscore_format(self): + dt = parse_catchup_timestamp("2026-05-21_12-55") + self.assertEqual(dt, datetime(2026, 5, 21, 12, 55, 0)) + + def test_parse_colon_minutes_without_seconds(self): + dt = parse_catchup_timestamp("2026-06-23:04:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_parse_colon_seconds_xc_format(self): + dt = parse_catchup_timestamp("2026-06-23:04:00:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_parse_epg_sql_format(self): + dt = parse_catchup_timestamp("2026-06-23 04:00:00") + self.assertEqual(dt, datetime(2026, 6, 23, 4, 0, 0)) + + def test_format_colon_dash_from_colon_seconds(self): + self.assertEqual( + format_timestamp_as_colon_dash("2026-06-23:04:00:00"), + "2026-06-23:04-00", + ) + + def test_format_colon_seconds_from_colon_dash(self): + self.assertEqual( + format_timestamp_as_colon_seconds("2026-06-23:04-00"), + "2026-06-23:04:00:00", + ) + + def test_format_colon_seconds_from_unix_epoch(self): + epoch = str(int(datetime(2026, 6, 23, 4, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + format_timestamp_as_colon_dash(epoch), + "2026-06-23:04-00", + ) + def test_format_sql_reshapes_without_tz_conversion(self): self.assertEqual( format_timestamp_as_sql_datetime("2026-05-12:17-00"), @@ -110,6 +197,14 @@ class CandidateOrderingTests(TestCase): # Canonical colon-dash timestamp, passed through unchanged. self.assertIn("/40/2026-05-12:19-00/22372.ts", urls[0]) + def test_accepts_colon_seconds_input_timestamp(self): + urls = build_timeshift_candidate_urls( + self.creds, "22372", "2026-06-23:04:00:00", 40 + ) + self.assertTrue(self._is_path_form(urls[0])) + self.assertIn("/40/2026-06-23:04-00/22372.ts", urls[0]) + self.assertIn("/40/2026-06-23:04:00:00/22372.ts", urls[2]) + def test_accepts_underscore_input_timestamp(self): # Client may send the underscore shape; PATH form still leads. urls = build_timeshift_candidate_urls(self.creds, "22372", "2026-05-12_19-00", 40) @@ -166,6 +261,13 @@ class ConvertTimestampToProviderTzTests(TestCase): "2026-06-08:17-00", ) + def test_utc_to_brussels_from_unix_epoch(self): + epoch = str(int(datetime(2026, 6, 8, 17, 0, 0, tzinfo=timezone.utc).timestamp())) + self.assertEqual( + convert_timestamp_to_provider_tz(epoch, "Europe/Brussels"), + "2026-06-08:19-00", + ) + def test_garbage_timestamp_passthrough(self): self.assertEqual( convert_timestamp_to_provider_tz("garbage", "Europe/Brussels"), diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 7d741784..de18238e 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -389,6 +389,13 @@ class TimeshiftProxyTimestampWiringTests(TestCase): _, _, build_mock, _, _ = self._call("2026-06-08:17-00", provider_tz="UTC") self.assertEqual(build_mock.call_args[0][2], "2026-06-08:17-00") + def test_colon_seconds_timestamp_accepted(self): + response, sentinel, build_mock, duration_mock, _ = self._call( + "2026-06-23:04:00:00" + ) + self.assertIs(response, sentinel) + self.assertEqual(duration_mock.call_args[0][1], "2026-06-23:04:00:00") + def test_invalid_timestamp_rejected_before_upstream(self): request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index e93777d8..6393cef0 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -53,7 +53,8 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) URL shape (iPlayTV / TiviMate): ``stream_id``: EPG channel number (ignored here). ``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id). - ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM``). + ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM`` or XC colon form + ``YYYY-MM-DD:HH:MM:SS``). """ raw_id = duration[:-3] if duration.endswith(".ts") else duration From 7289e60aa4a6424d7ab16dd7c1929b2aeb6b3d57 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 13:34:55 -0500 Subject: [PATCH 22/56] changelog: move to unreleased. --- CHANGELOG.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5b402d..4890e407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. + - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; the per-account rollup also self-heals channels left flagged with no remaining catch-up stream (e.g. after stale-stream cleanup on manual/multi-provider channels). + - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. + - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. + - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it exactly once when the session ends (one-shot Redis token, covering disconnects before the first chunk). When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. + - **One catch-up session per user and channel.** A seek or programme jump displaces the user's previous session on the same channel: its provider slot is released synchronously and the old stream is stopped through the standard stop-key mechanism, so rapid seeking can't stack upstream provider connections. + - **Single setting**, `xmltv_prev_days_override`, under `Settings → Proxy Settings` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. + - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. + - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. + ## [0.27.1] - 2026-06-25 ### Security @@ -241,15 +253,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **None** (default): software decode. - **NVIDIA NVDEC (`--cuvid`)**: requires the NVIDIA container toolkit and a supported GPU inside the container. - **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container. -- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. - - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; the per-account rollup also self-heals channels left flagged with no remaining catch-up stream (e.g. after stale-stream cleanup on manual/multi-provider channels). - - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. - - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it exactly once when the session ends (one-shot Redis token, covering disconnects before the first chunk). When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. - - **One catch-up session per user and channel.** A seek or programme jump displaces the user's previous session on the same channel: its provider slot is released synchronously and the old stream is stopped through the standard stop-key mechanism, so rapid seeking can't stack upstream provider connections. - - **Single setting**, `xmltv_prev_days_override`, under `Settings → Proxy Settings` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: - `/hdhr/output_profile//lineup.json` - output profile only - `/hdhr//output_profile//lineup.json` - channel profile + output profile From ca3d0eb9e1b61dc1d7968dee32c842df0f92aeb1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 17:36:23 -0500 Subject: [PATCH 23/56] feat(timeshift): Implement session-based media ID handling and improve stream limit checks for timeshift proxy. Enhance session management by allowing distinct client/session identification for timeshift requests, ensuring better resource allocation and user experience. Update tests to validate new session handling logic. --- apps/proxy/utils.py | 16 + apps/timeshift/tests/test_views.py | 874 ++++++++++++++++++++++++++-- apps/timeshift/views.py | 896 ++++++++++++++++++++++++----- 3 files changed, 1595 insertions(+), 191 deletions(-) diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index d3720801..fc157791 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -183,6 +183,22 @@ def check_user_stream_limits(user, client_id, media_id=None): logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)") return True + # Timeshift sibling range/probe requests share one provider slot per + # session_id. Each distinct client/session still consumes its own slot. + if ignore_same_channel and media_id: + media_id_str = str(media_id) + for conn in active_connections: + if conn.get('type') != 'timeshift': + continue + if conn.get('client_id') != client_id: + continue + conn_media_id = str(conn.get('media_id') or '') + if conn_media_id == media_id_str or conn_media_id.startswith(f"{media_id_str}_"): + logger.debug( + f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed (ignore_same_channel=True)" + ) + return True + if user_stream_count >= user.stream_limit: if user_limit_settings.get("terminate_on_limit_exceeded", True) == False: return False diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index de18238e..310649cb 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -1,16 +1,56 @@ """Tests for the timeshift proxy view, focused on upstream status mapping.""" +import fnmatch +import time from unittest.mock import MagicMock, patch from django.test import RequestFactory, TestCase, override_settings from apps.timeshift import views +from apps.proxy.utils import check_user_stream_limits as _check_user_stream_limits from apps.proxy.utils import find_ts_sync as _find_ts_sync +TEST_SESSION_ID = "timeshift_testsession1" +TEST_MEDIA_ID = "timeshift_8_2026-06-08-17-00" + + +def _proxy_url(session_id=TEST_SESSION_ID): + base = "/timeshift/u/p/8/2026-06-08:17-00/8.ts" + return f"{base}?session_id={session_id}" if session_id else base + + +def _seed_pool_session( + redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + *, + busy="1", + serving_range=None, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", +): + views._create_pool_session( + redis, + session_id=session_id, + media_id=media_id, + user_id=user_id, + client_ip=client_ip, + client_user_agent=client_user_agent, + account_id=1, + profile_id=31, + stream_id="111", + provider_timestamp="2026-06-08:19-00", + ) + if serving_range is not None: + redis.hset(f"timeshift_pool:{session_id}", "serving_range", serving_range) + if busy is not None: + redis.hset(f"timeshift_pool:{session_id}", "busy", busy) + class FindTsSyncTests(TestCase): """Locate the first MPEG-TS sync chain so a leading HTML/PHP preamble - can be skipped before the bytes reach a strict demuxer (ExoPlayer).""" + can be skipped before the bytes reach the strict demuxer (ExoPlayer).""" def test_returns_zero_when_buffer_already_aligned(self): buf = b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 + b"\x47" + b"\x00" * 187 @@ -231,6 +271,76 @@ class StreamFromProviderStatusMappingTests(TestCase): # PHP response was rejected, second candidate accepted self.assertEqual(mocked_open.call_count, 2) + @patch.object(views, "_open_upstream") + def test_416_range_not_satisfiable_passes_through(self, mocked_open): + # A tail/seek probe past EOF must go back to the client verbatim, + # never cascaded to other URL shapes (byte offsets are file-specific, + # so cascading only multiplies upstream connections). + resp = _fake_upstream(416) + resp.headers = {"Content-Type": "video/mp2t", "Content-Range": "bytes */1000"} + mocked_open.return_value = resp + kwargs = dict(self.kwargs, range_header="bytes=999999-") + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 416) + self.assertEqual(response["Content-Range"], "bytes */1000") + self.assertTrue(getattr(response, "timeshift_passthrough", False)) + # No cascade: the first (and only) candidate decided the outcome. + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_partial_206_to_range_request_accepted_mid_packet(self, mocked_open): + # A 206 answering a Range request legitimately starts mid-TS-packet + # (no 0x47 sync at offset 0). It must be served, not rejected as a + # PHP error and cascaded across every URL shape and provider account. + mid_packet = b"\x00" * 300 + self.assertEqual(_find_ts_sync(mid_packet), -1) + resp = _fake_upstream(206, body=mid_packet) + resp.raw = MagicMock() + resp.raw.read = MagicMock(return_value=mid_packet) + mocked_open.return_value = resp + kwargs = dict(self.kwargs, range_header="bytes=1000-") + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 1) + + @patch.object(views, "_open_upstream") + def test_partial_206_html_error_still_rejected(self, mocked_open): + # The mid-packet allowance is gated on content type: a 206 whose body + # is an HTML/PHP error page must still be rejected and cascaded. + html = b"error" + bad = _fake_upstream(206, content_type="text/html", body=html) + bad.raw = MagicMock() + bad.raw.read = MagicMock(return_value=html) + good = _fake_upstream(206, body=_make_ts_payload()) + mocked_open.side_effect = [bad, good] + kwargs = dict(self.kwargs, range_header="bytes=0-") + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 2) + + @patch.object(views, "_open_upstream") + def test_206_without_range_header_still_requires_sync(self, mocked_open): + # Without a Range header a 206 is unexpected; it must still pass the + # TS-sync probe (the mid-packet allowance is range-only). + mid_packet = b"\x00" * 300 + bad = _fake_upstream(206, body=mid_packet) + bad.raw = MagicMock() + bad.raw.read = MagicMock(return_value=mid_packet) + good = _fake_upstream(206, body=_make_ts_payload()) + mocked_open.side_effect = [bad, good] + with patch.object(views, "RedisClient"), \ + patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 206) + self.assertEqual(mocked_open.call_count, 2) + class RedactUrlTests(TestCase): """`_redact_url` is the guard that keeps XC credentials out of logs — @@ -287,8 +397,9 @@ def _make_alt_profile(profile_id): class _FakeRedis: - """Just enough of the redis-py surface for the slot-token protocol: - setex/get/delete plus a transactional pipeline doing GET+DEL.""" + """Just enough of the redis-py surface for the idle-session pool: setex/get/ + delete plus a transactional pipeline doing GET+DEL, and the hash, set and + lock primitives the pool entries rely on.""" def __init__(self): self.store = {} @@ -311,6 +422,93 @@ class _FakeRedis: def pipeline(self, transaction=False): return _FakeRedisPipeline(self) + # --- hash + lock surface for the session slot --- + def hgetall(self, key): + value = self.store.get(key) + return dict(value) if isinstance(value, dict) else {} + + def hset(self, key, field=None, value=None, mapping=None, **kwargs): + hash_value = self.store.get(key) + if not isinstance(hash_value, dict): + hash_value = {} + self.store[key] = hash_value + if field is not None and value is not None: + hash_value[str(field)] = str(value) + for f, v in (mapping or {}).items(): + hash_value[str(f)] = str(v) + for f, v in kwargs.items(): + hash_value[str(f)] = str(v) + return len(hash_value) + + def hincrby(self, key, field, amount=1): + hash_value = self.store.get(key) + if not isinstance(hash_value, dict): + hash_value = {} + self.store[key] = hash_value + new_value = int(hash_value.get(field, 0)) + amount + hash_value[field] = str(new_value) + return new_value + + def hget(self, key, field): + hash_value = self.store.get(key) + return hash_value.get(field) if isinstance(hash_value, dict) else None + + def expire(self, key, ttl): + return 1 if key in self.store else 0 + + # --- set surface for the idle-session pool --- + def sadd(self, key, *members): + existing = self.store.get(key) + if not isinstance(existing, set): + existing = set() + self.store[key] = existing + before = len(existing) + existing.update(str(m) for m in members) + return len(existing) - before + + def srem(self, key, *members): + existing = self.store.get(key) + if not isinstance(existing, set): + return 0 + removed = 0 + for member in members: + if str(member) in existing: + existing.discard(str(member)) + removed += 1 + return removed + + def smembers(self, key): + existing = self.store.get(key) + return set(existing) if isinstance(existing, set) else set() + + def scard(self, key): + existing = self.store.get(key) + return len(existing) if isinstance(existing, set) else 0 + + def lock(self, name, timeout=None, blocking_timeout=None): + return _FakeRedisLock() + + def scan(self, cursor=0, match=None, count=100): + keys = sorted( + k for k in self.store + if match is None or fnmatch.fnmatch(k, match) + ) + if cursor >= len(keys): + return 0, [] + batch = keys[cursor:cursor + count] + next_cursor = cursor + len(batch) + if next_cursor >= len(keys): + return 0, batch + return next_cursor, batch + + +class _FakeRedisLock: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + class _FakeRedisPipeline: def __init__(self, redis): @@ -350,7 +548,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): self.factory = RequestFactory() def _call(self, timestamp, provider_tz="Europe/Brussels"): - request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts") + request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts?session_id={TEST_SESSION_ID}") sentinel = MagicMock(status_code=200) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=True), \ @@ -412,7 +610,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def test_network_access_denied_returns_403(self): # Same network gate as other XC API endpoints (player_api, xmltv, etc.). - request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + request = self.factory.get(_proxy_url()) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=False) as gate, \ patch.object(views, "Channel") as channel_cls, \ @@ -435,7 +633,7 @@ class TimeshiftProxyFailoverTests(TestCase): self.factory = RequestFactory() def _call(self, streams, provider_responses): - request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + request = self.factory.get(_proxy_url()) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ @@ -524,6 +722,20 @@ class TimeshiftProxyFailoverTests(TestCase): ) self.assertEqual(limits_mock.call_count, 1) + def test_passthrough_is_not_failed_over_to_other_accounts(self): + # A terminal range answer (e.g. 416 past EOF) must be returned as-is; + # the loop must NOT try the next account, whose byte offsets would not + # match this file and which would just burn another provider slot. + streams = [ + _make_catchup_stream(account_id=1, stream_id="111"), + _make_catchup_stream(account_id=2, stream_id="222"), + ] + passthrough = MagicMock(status_code=416) + passthrough.timeshift_passthrough = True + response, stream_mock, _, _ = self._call(streams, [passthrough]) + self.assertIs(response, passthrough) + self.assertEqual(stream_mock.call_count, 1) + class _ProxyLoopTestMixin: """Shared driver for tests exercising the failover loop end to end — @@ -534,7 +746,7 @@ class _ProxyLoopTestMixin: def _call(self, streams, provider_responses, limits=True, reserve_results=None, build_side_effect=None): - request = self.factory.get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + request = self.factory.get(_proxy_url()) self.fake_redis = _FakeRedis() reserve_kwargs = ( {"side_effect": reserve_results} @@ -819,11 +1031,15 @@ class AuthHelpersDbTests(TestCase): class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): """Provider pool participation: a profile slot is reserved before any - upstream attempt and released exactly once afterwards — the same - accounting contract live (Channel.get_stream) and VOD follow.""" + upstream attempt and released exactly once afterwards, the same accounting + contract live (Channel.get_stream) and VOD follow. Each active stream + reserves its own slot so concurrent provider connections stay capped by + max_streams.""" - def _slot_token_keys(self): - return [k for k in self.fake_redis.store if k.startswith("timeshift_slot:")] + POOL_KEY = f"timeshift_pool:{TEST_SESSION_ID}" + + def _pool_entry_ids(self): + return [k for k in self.fake_redis.store if k.startswith("timeshift_pool:")] def test_reserve_called_with_default_profile_before_upstream(self): streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] @@ -841,18 +1057,18 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): streams, [MagicMock(status_code=404, timeshift_decisive=False)] ) self.assertEqual(response.status_code, 404) - # The one-shot token was consumed and the pool counter decremented. + # The failed attempt's slot was released and its pool entry removed. self.release_mock.assert_called_once_with(31, self.fake_redis) - self.assertEqual(self._slot_token_keys(), []) + self.assertEqual(self._pool_entry_ids(), []) def test_slot_kept_on_success_for_the_streaming_session(self): streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] response, _, _ = self._call(streams, [MagicMock(status_code=200)]) self.assertEqual(response.status_code, 200) - # Slot still owned by the (mocked) streaming session: token present, - # nothing released yet. + # Slot still owned by the (mocked) streaming session: a busy pool entry + # remains for the next request to reuse, nothing released yet. self.release_mock.assert_not_called() - self.assertEqual(len(self._slot_token_keys()), 1) + self.assertEqual(len(self._pool_entry_ids()), 1) def test_decisive_failure_releases_slot_and_skips_account(self): streams = [ @@ -938,7 +1154,7 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): with self.assertRaises(RuntimeError): self._call(streams, RuntimeError("boom")) self.release_mock.assert_called_once_with(31, self.fake_redis) - self.assertEqual(self._slot_token_keys(), []) + self.assertEqual(self._pool_entry_ids(), []) def test_exception_before_upstream_releases_slot(self): # Same guarantee for failures BEFORE the upstream call (URL building, @@ -949,18 +1165,7 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): self._call(streams, [], build_side_effect=RuntimeError("boom")) self.stream_mock.assert_not_called() self.release_mock.assert_called_once_with(31, self.fake_redis) - self.assertEqual(self._slot_token_keys(), []) - - def test_token_store_failure_releases_directly(self): - # If the ownership token cannot be written, no release path could - # ever free the slot — the view must release it directly and report - # transient unavailability instead of streaming unaccounted. - streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] - with patch.object(views, "_store_slot_token", return_value=False): - response, stream_mock, _ = self._call(streams, []) - self.assertEqual(response.status_code, 503) - stream_mock.assert_not_called() - self.release_mock.assert_called_once_with(31, self.fake_redis) + self.assertEqual(self._pool_entry_ids(), []) def test_mixed_capacity_then_upstream_failure_returns_failure(self): # Mixed outcome: one stream capacity-blocked, another actually tried @@ -991,28 +1196,37 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): self.assertEqual(response.status_code, 404) -class TimeshiftSlotTokenTests(TestCase): - """The one-shot release token: exactly-once semantics across all release - paths (generator finally, response close, takeover, failed attempt).""" +class TimeshiftPoolReleaseTests(TestCase): + """Pool slot release and response close paths for a pooled session.""" def setUp(self): self.redis = _FakeRedis() + self.session_id = TEST_SESSION_ID - def test_release_happens_exactly_once(self): - views._store_slot_token(self.redis, "timeshift_abc", 31) + def _pool_key(self): + return f"timeshift_pool:{self.session_id}" + + def test_release_callback_frees_slot_exactly_once(self): + _seed_pool_session(self.redis, session_id=self.session_id) + release = views._make_release_once(self.redis, self.session_id, 31) with patch.object(views, "release_profile_slot") as release_mock: - self.assertTrue(views._release_slot_token(self.redis, "timeshift_abc")) - self.assertFalse(views._release_slot_token(self.redis, "timeshift_abc")) + release() + release() release_mock.assert_called_once_with(31, self.redis) + self.assertEqual(self.redis.hget(self._pool_key(), "busy"), "0") + self.assertTrue(self.redis.exists(self._pool_key())) - def test_release_without_token_is_noop(self): + def test_discard_frees_slot_and_removes_entry(self): + _seed_pool_session(self.redis, session_id=self.session_id) with patch.object(views, "release_profile_slot") as release_mock: - self.assertFalse(views._release_slot_token(self.redis, "timeshift_ghost")) - release_mock.assert_not_called() + views._discard_pool_session(self.redis, self.session_id, 31) + release_mock.assert_called_once_with(31, self.redis) + self.assertFalse(self.redis.exists(self._pool_key())) def test_release_without_redis_is_noop(self): + release = views._make_release_once(None, self.session_id, 31) with patch.object(views, "release_profile_slot") as release_mock: - self.assertFalse(views._release_slot_token(None, "timeshift_abc")) + release() release_mock.assert_not_called() def test_wrapper_close_releases_even_when_generator_never_started(self): @@ -1046,10 +1260,11 @@ class TimeshiftSlotTokenTests(TestCase): class TimeshiftTakeoverTests(TestCase): - """One catch-up session per user+channel: a new request displaces the - user's previous session on the same channel — synchronous slot release + - stats unregister + stop key — and never touches other users, other - channels, or live sessions.""" + """A new request displaces the user's previous catch-up session(s) on the + same channel at a DIFFERENT position (stats unregister + stop key, leaving + the displaced generator to free its own slot), while leaving sibling range + requests of the same playback alone, and never touching other users, + channels, or live.""" def setUp(self): self.redis = _FakeRedis() @@ -1063,9 +1278,7 @@ class TimeshiftTakeoverTests(TestCase): "type": conn_type, } - def test_displaces_only_same_channel_timeshift_sessions(self): - views._store_slot_token(self.redis, "timeshift_old1", 31) - views._store_slot_token(self.redis, "timeshift_other", 41) + def test_displaces_other_positions_on_same_channel(self): connections = [ self._conn("timeshift_8_2026-06-08-17-00_111", "timeshift_old1"), self._conn("timeshift_9_2026-06-08-17-00_222", "timeshift_other"), @@ -1075,10 +1288,13 @@ class TimeshiftTakeoverTests(TestCase): return_value=connections) as conns_mock, \ patch.object(views, "release_profile_slot") as release_mock, \ patch.object(views, "_unregister_stats_client") as unregister_mock: - views._terminate_previous_timeshift_sessions(self.redis, self.user, 8) + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-09-20-00", "timeshift_current", + ) conns_mock.assert_called_once_with(5) - # Channel 8's old session: slot released, stats dropped, stop key set. - release_mock.assert_called_once_with(31, self.redis) + # Takeover defers slot release to the displaced generator's stop path; + # it only drops stats and signals the stop key. + release_mock.assert_not_called() unregister_mock.assert_called_once_with( self.redis, "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" ) @@ -1087,8 +1303,28 @@ class TimeshiftTakeoverTests(TestCase): "timeshift_8_2026-06-08-17-00_111", "timeshift_old1" ) self.assertIn(stop_key, self.redis.store) - # Channel 9's session untouched: token still present, no stop key. - self.assertIn("timeshift_slot:timeshift_other", self.redis.store) + # Channel 9's session untouched: no stop key set for it. + other_stop = RedisKeys.client_stop( + "timeshift_9_2026-06-08-17-00_222", "timeshift_other" + ) + self.assertNotIn(other_stop, self.redis.store) + + def test_leaves_sibling_requests_of_current_playback(self): + # Concurrent range/probe requests of the SAME playback must not + # displace one another. + connections = [ + self._conn("timeshift_8_2026-06-08-17-00_111", "timeshift_sibling"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-08-17-00", + "timeshift_sibling", + ) + release_mock.assert_not_called() + unregister_mock.assert_not_called() def test_channel_id_prefix_cannot_match_other_channels(self): # Channel 8 must not displace channel 80/81 sessions (prefix ends @@ -1100,14 +1336,21 @@ class TimeshiftTakeoverTests(TestCase): return_value=connections), \ patch.object(views, "release_profile_slot") as release_mock, \ patch.object(views, "_unregister_stats_client") as unregister_mock: - views._terminate_previous_timeshift_sessions(self.redis, self.user, 8) + views._terminate_previous_timeshift_sessions( + self.redis, self.user, 8, "timeshift_8_2026-06-08-17-00", + "timeshift_new", + ) release_mock.assert_not_called() unregister_mock.assert_not_called() def test_noop_without_redis_or_user(self): with patch.object(views, "get_user_active_connections") as conns_mock: - views._terminate_previous_timeshift_sessions(None, self.user, 8) - views._terminate_previous_timeshift_sessions(self.redis, None, 8) + views._terminate_previous_timeshift_sessions( + None, self.user, 8, "timeshift_8_ts", "timeshift_s" + ) + views._terminate_previous_timeshift_sessions( + self.redis, None, 8, "timeshift_8_ts", "timeshift_s" + ) conns_mock.assert_not_called() def test_proxy_runs_takeover_before_stream_limit_check(self): @@ -1115,7 +1358,7 @@ class TimeshiftTakeoverTests(TestCase): # displace its own predecessor BEFORE the limit check counts it, or # the user's own seek gets denied. call_order = [] - request = RequestFactory().get("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + request = RequestFactory().get(_proxy_url()) with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ @@ -1138,6 +1381,525 @@ class TimeshiftTakeoverTests(TestCase): self.assertEqual(takeover_mock.call_args.args[2], 8) +class TimeshiftSessionReuseTests(TestCase): + """Per-client session pool acquire/reuse paths.""" + + SESSION = TEST_SESSION_ID + + def setUp(self): + self.redis = _FakeRedis() + self.factory = RequestFactory() + self.channel = MagicMock(id=8, name="Test") + self.user = MagicMock(id=5) + + def _pool_key(self): + return f"timeshift_pool:{self.SESSION}" + + def _make_idle_entry(self): + _seed_pool_session(self.redis, session_id=self.SESSION) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, self.SESSION, 31) + + def test_wait_returns_none_without_blocking_when_pool_empty(self): + start = time.monotonic() + acquired = views._wait_for_idle_pool_session(self.redis, self.SESSION) + self.assertIsNone(acquired) + self.assertLess(time.monotonic() - start, 0.5) + + def test_acquire_reuses_idle_entry_and_reserves_slot(self): + self._make_idle_entry() + profile = MagicMock(id=31) + with patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock: + acquired = views._acquire_idle_pool_session(self.redis, self.SESSION) + self.assertIsNotNone(acquired) + descriptor, got_profile = acquired + self.assertEqual(descriptor["stream_id"], "111") + self.assertIs(got_profile, profile) + reserve_mock.assert_called_once_with(profile, self.redis) + self.assertEqual(self.redis.hget(self._pool_key(), "busy"), "1") + + def test_acquire_skips_busy_entry(self): + _seed_pool_session(self.redis, session_id=self.SESSION, busy="1") + with patch.object(views.M3UAccountProfile.objects, "get") as prof_mock, \ + patch.object(views, "reserve_profile_slot") as reserve_mock: + acquired = views._acquire_idle_pool_session(self.redis, self.SESSION) + self.assertIsNone(acquired) + prof_mock.assert_not_called() + reserve_mock.assert_not_called() + + def test_find_matching_idle_session_requires_ip_and_user_agent(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=5, client_ip="1.2.3.4", client_user_agent="test-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertEqual(matched, "timeshift_other") + + def test_find_matching_idle_session_rejects_ip_only_partial_fingerprint(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=5, client_ip="1.2.3.4", client_user_agent="other-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertIsNone(matched) + + def test_find_matching_idle_session_rejects_different_user(self): + _seed_pool_session( + self.redis, session_id="timeshift_other", + user_id=99, client_ip="1.2.3.4", client_user_agent="test-agent", + ) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, "timeshift_other", 31) + matched = views._find_matching_idle_session( + self.redis, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + ) + self.assertIsNone(matched) + + def test_legacy_pool_entry_exists_helper_removed(self): + self.assertFalse(hasattr(views, "_pool_entry_exists")) + + def test_new_session_uses_single_hgetall_before_pool_create(self): + redis = _FakeRedis() + request = self.factory.get(_proxy_url("timeshift_newsession1")) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "_find_matching_idle_session", return_value=None), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=MagicMock(status_code=200)), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]): + redis_cls.get_client.return_value = redis + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + with patch.object(redis, "hgetall", wraps=redis.hgetall) as hgetall_mock: + views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + pool_key = "timeshift_pool:timeshift_newsession1" + self.assertEqual( + sum(1 for c in hgetall_mock.call_args_list if c.args == (pool_key,)), + 1, + ) + + +class TimeshiftSessionRedirectTests(TestCase): + """First request must establish a session via 301 redirect (VOD-style).""" + + def setUp(self): + self.factory = RequestFactory() + + def test_missing_session_id_redirects(self): + request = self.factory.get(_proxy_url(session_id=None)) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + self.assertIn("session_id=timeshift_", response["Location"]) + + def test_redirect_preserves_existing_query_params(self): + request = self.factory.get( + "/timeshift/u/p/8/2026-06-08:17-00/8.ts?foo=bar&baz=1", + ) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls: + redis_cls.get_client.return_value = _FakeRedis() + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + location = response["Location"] + self.assertIn("session_id=timeshift_", location) + self.assertIn("foo=bar", location) + self.assertIn("baz=1", location) + + +class TimeshiftStreamLimitExemptionTests(TestCase): + """Timeshift stream-limit bypass requires the same client session.""" + + MEDIA = TEST_MEDIA_ID + + def setUp(self): + self.user = MagicMock(id=5, username="viewer", stream_limit=1) + + def _limits_settings(self, ignore_same_channel=True): + return { + "ignore_same_channel_connections": ignore_same_channel, + "terminate_on_limit_exceeded": False, + } + + def test_same_session_probe_allowed_at_limit(self): + connections = [{ + "media_id": f"{self.MEDIA}_111", + "client_id": TEST_SESSION_ID, + "connected_at": 0.0, + "type": "timeshift", + }] + with patch("apps.proxy.utils.get_user_active_connections", + return_value=connections), \ + patch("apps.proxy.utils.CoreSettings.get_user_limits_settings", + return_value=self._limits_settings()): + allowed = _check_user_stream_limits( + self.user, TEST_SESSION_ID, media_id=self.MEDIA, + ) + self.assertTrue(allowed) + + def test_different_session_same_programme_counts_against_limit(self): + connections = [{ + "media_id": f"{self.MEDIA}_111", + "client_id": "timeshift_other_session", + "connected_at": 0.0, + "type": "timeshift", + }] + with patch("apps.proxy.utils.get_user_active_connections", + return_value=connections), \ + patch("apps.proxy.utils.CoreSettings.get_user_limits_settings", + return_value=self._limits_settings()): + allowed = _check_user_stream_limits( + self.user, TEST_SESSION_ID, media_id=self.MEDIA, + ) + self.assertFalse(allowed) + + +class FakeRedisScanTests(TestCase): + """FakeRedis SCAN matches redis-py glob semantics used by the pool scanner.""" + + def setUp(self): + self.redis = _FakeRedis() + self.redis.store["timeshift_pool:timeshift_a"] = {"busy": "0"} + self.redis.store["timeshift_pool:timeshift_b"] = {"busy": "0"} + self.redis.store["timeshift_pool:other_c"] = {"busy": "0"} + self.redis.store["vod_persistent_connection:x"] = {} + + def test_scan_glob_filters_pool_keys(self): + cursor = 0 + seen = [] + while True: + cursor, keys = self.redis.scan( + cursor, match="timeshift_pool:timeshift_*", count=1, + ) + seen.extend(keys) + if cursor == 0: + break + self.assertEqual( + seen, + ["timeshift_pool:timeshift_a", "timeshift_pool:timeshift_b"], + ) + + +class TimeshiftRangeClassificationTests(TestCase): + """Startup probes must not be treated as scrubs.""" + + def test_full_file_request_is_not_displacing(self): + self.assertFalse(views._should_displace_busy_playback(None)) + + def test_bytes_zero_displaces_full_file_probe(self): + self.assertTrue( + views._should_displace_busy_playback("bytes=0-", busy_serving_range="none") + ) + + def test_bytes_zero_does_not_displace_active_start_stream(self): + self.assertFalse( + views._should_displace_busy_playback("bytes=0-", busy_serving_range="start") + ) + + def test_bytes_zero_without_busy_context_is_not_displacing(self): + self.assertFalse(views._should_displace_busy_playback("bytes=0-")) + + def test_near_eof_probe_is_not_displacing(self): + self.assertTrue(views._is_near_eof_probe("bytes=2527702896-")) + self.assertFalse(views._should_displace_busy_playback("bytes=2527702896-")) + + def test_near_eof_probe_uses_cached_content_length(self): + # 5 MB into a 10 MB file is a scrub, not a tail probe. + self.assertFalse( + views._is_near_eof_probe("bytes=5000000-", content_length="10000000") + ) + self.assertTrue( + views._should_displace_busy_playback("bytes=5000000-", content_length="10000000") + ) + # Within 512 KB of EOF is a tail probe once length is known. + self.assertTrue( + views._is_near_eof_probe("bytes=9990000-", content_length="10000000") + ) + + def test_midfile_seek_is_displacing(self): + self.assertTrue(views._should_displace_busy_playback("bytes=5000000-")) + + def test_small_nonzero_range_is_displacing(self): + self.assertTrue(views._should_displace_busy_playback("bytes=1000-")) + + +class TimeshiftScrubPreemptTests(TestCase): + """Scrub/range requests must stop the in-flight stream and reuse the pooled + provider slot instead of opening parallel upstream connections.""" + + def setUp(self): + self.redis = _FakeRedis() + self.user = MagicMock(id=5) + self.factory = RequestFactory() + + def _conn(self, media_id, client_id): + return { + "media_id": media_id, + "client_id": client_id, + "connected_at": 0.0, + "type": "timeshift", + } + + def test_preempt_stops_sibling_clients_of_same_playback(self): + connections = [ + self._conn(f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID), + self._conn("timeshift_9_2026-06-08-17-00_222", "timeshift_other"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._preempt_playback_streams(self.redis, TEST_SESSION_ID, self.user) + unregister_mock.assert_called_once_with( + self.redis, f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID, + ) + from apps.proxy.live_proxy.redis_keys import RedisKeys + stop_key = RedisKeys.client_stop(f"{TEST_MEDIA_ID}_111", TEST_SESSION_ID) + self.assertIn(stop_key, self.redis.store) + + def test_preempt_leaves_other_playbacks_alone(self): + connections = [ + self._conn("timeshift_8_2026-06-09-20-00_111", "timeshift_other_pos"), + ] + with patch.object(views, "get_user_active_connections", + return_value=connections), \ + patch.object(views, "_unregister_stats_client") as unregister_mock: + views._preempt_playback_streams(self.redis, TEST_SESSION_ID, self.user) + unregister_mock.assert_not_called() + + def test_busy_pool_returns_503_instead_of_second_provider_connection(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=1000-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_wait_for_idle_pool_session", return_value=None), \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_called_once() + attempt_mock.assert_not_called() + self.assertEqual(len(self._pool_entry_ids()), 1) + + def _pool_entry_ids(self): + return [k for k in self.redis.store if k.startswith("timeshift_pool:")] + + def test_startup_bytes_zero_deferred_without_preempt(self): + _seed_pool_session( + self.redis, session_id=TEST_SESSION_ID, serving_range="start", + ) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=0-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_eof_probe_deferred_without_preempt(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=2527702896-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 503) + preempt_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_create_pool_session_rejects_duplicate_entry(self): + first = views._create_pool_session( + self.redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + account_id=1, + profile_id=31, + stream_id="111", + provider_timestamp="2026", + ) + second = views._create_pool_session( + self.redis, + session_id=TEST_SESSION_ID, + media_id=TEST_MEDIA_ID, + user_id=5, + client_ip="1.2.3.4", + client_user_agent="test-agent", + account_id=2, + profile_id=41, + stream_id="222", + provider_timestamp="2026", + ) + self.assertTrue(first) + self.assertFalse(second) + self.assertTrue(self.redis.exists(f"timeshift_pool:{TEST_SESSION_ID}")) + + def test_scrub_reuses_idle_pool_without_opening_failover(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, TEST_SESSION_ID, 31) + + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE="bytes=5000-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + profile = MagicMock(id=31) + ok = MagicMock(status_code=206) + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock, \ + patch.object(views, "release_profile_slot"), \ + patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_stream_reused_session", return_value=ok) as reuse_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertIs(response, ok) + preempt_mock.assert_not_called() + reuse_mock.assert_called_once() + attempt_mock.assert_not_called() + # Pool acquire re-reserves the idle slot once; failover must not add another. + reserve_mock.assert_called_once_with(profile, self.redis) + + + class RollupSelfHealDbTests(TestCase): """Catch-up flag consistency after stream removal — the review-#4 point 1 guarantees: the ChannelStream signal handles bulk deletes (locked by a diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 6393cef0..113cd460 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -3,8 +3,9 @@ import hmac import itertools import logging +import random import time -import uuid +from urllib.parse import urlencode import requests from django.core.cache import cache @@ -21,6 +22,7 @@ from apps.accounts.models import User from apps.channels.models import Channel from apps.channels.utils import get_channel_catchup_streams from apps.m3u.connection_pool import release_profile_slot, reserve_profile_slot +from apps.m3u.models import M3UAccount, M3UAccountProfile from apps.m3u.tasks import get_transformed_credentials from apps.proxy.live_proxy.config_helper import ConfigHelper from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState @@ -45,6 +47,7 @@ from .helpers import ( logger = logging.getLogger(__name__) CLIENT_TTL_SECONDS = 60 +_MATCH_SCORE_THRESHOLD = 8 # client_ip (5) + client_user_agent (3) def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id @@ -87,21 +90,138 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) duration_minutes = get_programme_duration(channel, timestamp) safe_ts = timestamp.replace(":", "-").replace("/", "-") - client_id = f"timeshift_{uuid.uuid4().hex[:16]}" client_ip = get_client_ip(request) + client_user_agent = request.META.get("HTTP_USER_AGENT", "") or "" range_header = request.META.get("HTTP_RANGE") channel_logo_id = getattr(channel, "logo_id", None) redis_client = RedisClient.get_client() - # Displace any prior catch-up session on this channel before reserving a slot. - _terminate_previous_timeshift_sessions(redis_client, user, channel.id) + # Content identity (channel + catch-up position). Provider slot sharing is + # scoped per client session; never assume all requests for the same + # programme belong to one viewer. + media_id = f"timeshift_{channel.id}_{safe_ts}" - if not check_user_stream_limits( - user, client_id, media_id=f"timeshift_{channel.id}_{safe_ts}" - ): + session_id = request.GET.get("session_id") + if not session_id: + session_id = f"timeshift_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + query_params = {k: request.GET.getlist(k) for k in request.GET} + query_params["session_id"] = [session_id] + redirect_url = f"{request.path}?{urlencode(query_params, doseq=True)}" + logger.debug("Timeshift session redirect: %s -> %s", request.path, session_id) + return HttpResponse(status=301, headers={"Location": redirect_url}) + + # Stable client identity for stats, stop keys, and the provider pool. + effective_session_id = session_id + client_id = session_id + + session_entry = _get_pool_entry(redis_client, session_id) + + # Reuse an idle pool owned by this session, or fingerprint-match a prior + # idle session from the same client (VOD-style) before opening upstream. + if not session_entry: + matched = _find_matching_idle_session( + redis_client, + media_id=media_id, + user_id=user.id, + client_ip=client_ip, + client_user_agent=client_user_agent, + ) + if matched: + logger.info( + "Timeshift fingerprint matched idle session %s for %s", + matched, session_id, + ) + effective_session_id = matched + client_id = matched + + if debug: + if effective_session_id != session_id: + logger.debug( + "Timeshift request: channel=%s media=%s session=%s " + "effective=%s user=%s range=%s ip=%s", + channel.name, media_id, session_id, effective_session_id, + user.id, range_header or "(none)", client_ip, + ) + else: + logger.debug( + "Timeshift request: channel=%s media=%s session=%s " + "user=%s range=%s ip=%s", + channel.name, media_id, effective_session_id, user.id, + range_header or "(none)", client_ip, + ) + + # Displace this user's prior catch-up on other positions of this channel. + _terminate_previous_timeshift_sessions( + redis_client, user, channel.id, media_id, effective_session_id, + ) + + if not check_user_stream_limits(user, client_id, media_id=media_id): return HttpResponseForbidden("Stream limit exceeded") + if effective_session_id == session_id: + pool = _snapshot_from_entry(session_entry) + else: + pool = _pool_snapshot(redis_client, effective_session_id) + pool_exists = pool is not None + pool_busy = pool["busy"] if pool else False + pool_content_length = pool["content_length"] if pool else None + busy_serving_range = pool["serving_range"] if pool else None + + acquired = None + if pool_exists: + if pool_busy: + if _should_displace_busy_playback( + range_header, pool_content_length, busy_serving_range, + ): + _preempt_playback_streams(redis_client, effective_session_id, user) + acquired = _wait_for_idle_pool_session( + redis_client, + effective_session_id, + wait_seconds=_POOL_PREEMPT_WAIT_SECONDS, + ) + else: + acquired = _acquire_idle_pool_session( + redis_client, effective_session_id, + ) + + if acquired is not None: + descriptor, profile = acquired + reuse_response = _stream_reused_session( + redis_client, + session_id=effective_session_id, + descriptor=descriptor, + profile=profile, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, + duration_minutes=duration_minutes, + client_id=client_id, + client_ip=client_ip, + range_header=range_header, + channel_logo_id=channel_logo_id, + user=user, + debug=debug, + ) + if reuse_response is not None: + return reuse_response + + if pool_exists and pool_busy and not _should_displace_busy_playback( + range_header, pool_content_length, busy_serving_range, + ): + logger.debug( + "Timeshift: deferring non-displacing request for session %s range=%s", + effective_session_id, range_header or "(none)", + ) + return HttpResponse("Stream slot busy", status=503) + + if pool_exists and pool_busy: + logger.warning( + "Timeshift: session %s did not become idle in time", + effective_session_id, + ) + return HttpResponse("Stream slot busy", status=503) + last_response = None decisive_accounts = set() capacity_blocked = False @@ -157,75 +277,71 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) ) continue - token_stored = False - if redis_client is not None: - token_stored = _store_slot_token( - redis_client, client_id, reserved_profile.id + if not _create_pool_session( + redis_client, + session_id=effective_session_id, + media_id=media_id, + user_id=user.id, + client_ip=client_ip, + client_user_agent=client_user_agent, + account_id=m3u_account.id, + profile_id=reserved_profile.id, + stream_id=stream_id_value, + provider_timestamp=provider_timestamp, + ): + try: + release_profile_slot(reserved_profile.id, redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed after pool race on profile %s: %s", + reserved_profile.id, exc, + ) + logger.debug( + "Timeshift: pool entry already exists for session %s, deferring", + effective_session_id, ) - if not token_stored: - # Release immediately if we cannot record ownership for later cleanup. - try: - release_profile_slot(reserved_profile.id, redis_client) - except Exception as exc: - logger.error( - "Timeshift: could not release slot for profile %s " - "after token-store failure: %s", reserved_profile.id, exc, - ) - capacity_blocked = True - continue + return HttpResponse("Stream slot busy", status=503) + release_cb = _make_release_once( + redis_client, effective_session_id, reserved_profile.id + ) try: - # Failures before streaming starts must release the reserved slot. - # Use credentials for the profile whose slot was reserved. - server_url, xc_username, xc_password = get_transformed_credentials( - m3u_account, reserved_profile - ) - creds = TimeshiftCredentials(server_url, xc_username, xc_password) - - candidate_urls = build_timeshift_candidate_urls( - creds, stream_id_value, provider_timestamp, duration_minutes - ) - - try: - user_agent = m3u_account.get_user_agent().user_agent - except AttributeError: - user_agent = "" - - virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" - - if debug: - logger.debug( - "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " - "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s", - channel.name, timestamp, provider_tz_name, provider_timestamp, - m3u_account.id, reserved_profile.id, stream_id_value, - virtual_channel_id, client_id, range_header or "(none)", - ) - - response = _stream_from_provider( - candidate_urls=candidate_urls, - user_agent=user_agent, - range_header=range_header, - virtual_channel_id=virtual_channel_id, + response = _attempt_timeshift_stream( + m3u_account=m3u_account, + profile=reserved_profile, + stream_id_value=stream_id_value, + provider_timestamp=provider_timestamp, + provider_tz_name=provider_tz_name, + duration_minutes=duration_minutes, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, client_id=client_id, client_ip=client_ip, - user=user, - channel_display_name=channel.name, - timestamp_utc=timestamp, + range_header=range_header, channel_logo_id=channel_logo_id, - m3u_profile_id=reserved_profile.id, - debug=debug, - account_id=m3u_account.id, + user=user, redis_client=redis_client, + debug=debug, + release_cb=release_cb, + pool_session_id=effective_session_id, ) except Exception: - if token_stored: - _release_slot_token(redis_client, client_id) + _discard_pool_session(redis_client, effective_session_id, reserved_profile.id) raise if response.status_code < 400: + # Streaming: the generator's close path frees the slot via release_cb. return response - _release_slot_token(redis_client, client_id) + if getattr(response, "timeshift_passthrough", False) is True: + # Terminal range answer (e.g. 416 past EOF): the upstream session is + # healthy, so free the slot but keep the entry idle for the next + # probe, and return verbatim without failing over to other accounts. + release_cb() + return response + + # Real failure: drop this session entirely and fail over. + _discard_pool_session(redis_client, effective_session_id, reserved_profile.id) last_response = response if getattr(response, "timeshift_decisive", False): decisive_accounts.add(m3u_account.id) @@ -276,56 +392,367 @@ def _user_can_access_channel(user, channel): ) -# One-shot Redis token: whichever release path runs first frees the pool slot. -_SLOT_TOKEN_KEY = "timeshift_slot:{client_id}" -_SLOT_TOKEN_TTL = 24 * 3600 +# Per-client session pool (keyed by session_id from the 301 redirect). Each +# viewer gets their own provider slot even when watching the same catch-up +# programme. Idle sessions can be fingerprint-matched (VOD-style) when a client +# returns without its prior session_id. +_POOL_KEY = "timeshift_pool:{session_id}" +_POOL_LOCK_KEY = "timeshift_pool_lock:{session_id}" +_POOL_ENTRY_TTL = 6 * 3600 +_POOL_IDLE_TTL = 30 +_POOL_WAIT_SECONDS = 1.0 +_POOL_PREEMPT_WAIT_SECONDS = 5.0 +_POOL_POLL_INTERVAL = 0.05 +_EOF_PROBE_TAIL_BYTES = 512_000 +_EOF_PROBE_UNKNOWN_LENGTH_MIN = 100_000_000 -def _store_slot_token(redis_client, client_id, profile_id): - """Store a one-shot Redis token mapping *client_id* to *profile_id*. +def _pool_key(session_id): + return _POOL_KEY.format(session_id=session_id) - Returns False if the token could not be written; the caller must release - the reserved slot directly in that case. - """ - if redis_client is None: - return False + +def _parse_range_start(range_header): + """Return the byte offset from a ``Range: bytes=START-`` header, or None.""" + if not range_header or not range_header.startswith("bytes="): + return None + start_part = range_header[6:].split("-", 1)[0] + if not start_part: + return None try: - redis_client.setex( - _SLOT_TOKEN_KEY.format(client_id=client_id), - _SLOT_TOKEN_TTL, - str(profile_id), + return int(start_part) + except (TypeError, ValueError): + return None + + +def _is_near_eof_probe(range_header, content_length=None): + """True for tail/duration probes IPTV clients fire during startup.""" + start = _parse_range_start(range_header) + if start is None: + return False + if content_length is not None: + try: + total = int(content_length) + except (TypeError, ValueError): + total = None + else: + return start >= max(0, total - _EOF_PROBE_TAIL_BYTES) + return start >= _EOF_PROBE_UNKNOWN_LENGTH_MIN + + +def _should_displace_busy_playback( + range_header, content_length=None, busy_serving_range=None, +): + """True when this request should stop the in-flight stream (actual scrub).""" + if not range_header: + return False + start = _parse_range_start(range_header) + if start is None: + return False + if _is_near_eof_probe(range_header, content_length): + return False + if start == 0: + # Only displace a known full-file probe; unknown busy context is not a scrub. + return busy_serving_range == "none" + return True + + +def _score_pool_fingerprint(entry, client_ip, client_user_agent): + """Score IP/UA overlap for fingerprint adoption (user and media pre-filtered).""" + score = 0 + if entry.get("client_ip") and entry.get("client_ip") == client_ip: + score += 5 + if entry.get("client_user_agent") and entry.get("client_user_agent") == client_user_agent: + score += 3 + return score + + +def _find_matching_idle_session( + redis_client, *, media_id, user_id, client_ip, client_user_agent, +): + """Find an idle pooled session that likely belongs to the same client.""" + if redis_client is None: + return None + matches = [] + try: + cursor = 0 + while True: + cursor, keys = redis_client.scan( + cursor, match="timeshift_pool:timeshift_*", count=100, + ) + for key in keys: + try: + data = redis_client.hgetall(key) + if not data or data.get("busy") == "1": + continue + if str(data.get("user_id") or "") != str(user_id): + continue + if str(data.get("media_id") or "") != str(media_id): + continue + session_id = key.rsplit(":", 1)[-1] + score = _score_pool_fingerprint( + data, client_ip, client_user_agent, + ) + if score >= _MATCH_SCORE_THRESHOLD: + last_activity = float(data.get("last_activity") or "0") + matches.append((session_id, score, last_activity)) + except Exception as exc: + logger.debug("Timeshift pool scan skip %s: %s", key, exc) + if cursor == 0: + break + except Exception as exc: + logger.warning("Timeshift idle session search failed: %s", exc) + return None + + if not matches: + return None + matches.sort(key=lambda item: (item[1], item[2]), reverse=True) + best = matches[0][0] + logger.debug( + "Timeshift idle match: session=%s score=%s media=%s", + best, matches[0][1], media_id, + ) + return best + + +def _get_pool_entry(redis_client, session_id): + if redis_client is None or not session_id: + return {} + try: + return redis_client.hgetall(_pool_key(session_id)) or {} + except Exception: + return {} + + +def _snapshot_from_entry(entry): + if not entry: + return None + busy = entry.get("busy") == "1" + return { + "entry": entry, + "busy": busy, + "serving_range": (entry.get("serving_range") or "none") if busy else None, + "content_length": entry.get("content_length"), + } + + +def _pool_snapshot(redis_client, session_id): + """Single HGETALL view of pool state for request handling.""" + return _snapshot_from_entry(_get_pool_entry(redis_client, session_id)) + + +def _store_pool_serving_range(redis_client, session_id, range_header): + if redis_client is None or not session_id: + return + start = _parse_range_start(range_header) + if not range_header: + serving_range = "none" + elif start == 0: + serving_range = "start" + else: + serving_range = "range" + try: + redis_client.hset(_pool_key(session_id), "serving_range", serving_range) + except Exception as exc: + logger.debug("Timeshift pool serving_range store failed: %s", exc) + + +def _store_pool_content_length(redis_client, session_id, upstream_response): + if redis_client is None or not session_id or upstream_response is None: + return + content_length = upstream_response.headers.get("Content-Length") + content_range = upstream_response.headers.get("Content-Range", "") + if content_range and "/" in content_range: + total = content_range.rsplit("/", 1)[-1] + if total != "*": + content_length = total + if not content_length: + return + try: + redis_client.hset( + _pool_key(session_id), "content_length", str(content_length), ) - return True except Exception as exc: - logger.warning("Timeshift slot token store failed for %s: %s", client_id, exc) - return False + logger.debug("Timeshift pool content_length store failed: %s", exc) -def _release_slot_token(redis_client, client_id): - """Release the profile slot owned by *client_id* (at most once via GET+DEL).""" - if redis_client is None: - return False - key = _SLOT_TOKEN_KEY.format(client_id=client_id) +def _pool_lock(redis_client, session_id): + return redis_client.lock( + _POOL_LOCK_KEY.format(session_id=session_id), + timeout=10, + blocking_timeout=5, + ) + + +def _acquire_idle_pool_session(redis_client, session_id): + """Re-reserve an idle session's profile slot and mark it busy.""" + if redis_client is None or not session_id: + return None + key = _pool_key(session_id) try: - pipe = redis_client.pipeline(transaction=True) - pipe.get(key) - pipe.delete(key) - token_value, deleted = pipe.execute() - if not token_value or not deleted: - return False - release_profile_slot(int(token_value), redis_client) + with _pool_lock(redis_client, session_id): + data = redis_client.hgetall(key) + if not data or not data.get("profile_id"): + return None + if data.get("busy") == "1": + return None + try: + profile = M3UAccountProfile.objects.get(id=int(data["profile_id"])) + except M3UAccountProfile.DoesNotExist: + redis_client.delete(key) + return None + reserved, _count, _reason = reserve_profile_slot(profile, redis_client) + if not reserved: + return None + redis_client.hset(key, mapping={ + "busy": "1", + "last_activity": str(time.time()), + }) + redis_client.expire(key, _POOL_ENTRY_TTL) + return dict(data), profile + except Exception as exc: + logger.warning("Timeshift pool acquire failed for %s: %s", session_id, exc) + return None + + +def _wait_for_idle_pool_session(redis_client, session_id, wait_seconds=_POOL_WAIT_SECONDS): + if redis_client is None or not session_id: + return None + deadline = time.time() + wait_seconds + while True: + acquired = _acquire_idle_pool_session(redis_client, session_id) + if acquired is not None: + return acquired + if not _get_pool_entry(redis_client, session_id): + return None + if time.time() >= deadline: + return None + time.sleep(_POOL_POLL_INTERVAL) + + +def _create_pool_session( + redis_client, + *, + session_id, + media_id, + user_id, + client_ip, + client_user_agent, + account_id, + profile_id, + stream_id, + provider_timestamp, +): + """Register an already-reserved slot for this client session.""" + if redis_client is None or not session_id: + return False + key = _pool_key(session_id) + now = str(time.time()) + try: + with _pool_lock(redis_client, session_id): + if redis_client.exists(key): + return False + redis_client.hset(key, mapping={ + "media_id": str(media_id), + "user_id": str(user_id), + "client_ip": str(client_ip or ""), + "client_user_agent": str(client_user_agent or ""), + "account_id": str(account_id), + "profile_id": str(profile_id), + "stream_id": str(stream_id), + "provider_timestamp": str(provider_timestamp), + "busy": "1", + "last_activity": now, + }) + redis_client.expire(key, _POOL_ENTRY_TTL) return True except Exception as exc: - logger.warning("Timeshift slot release failed for %s: %s", client_id, exc) + logger.warning("Timeshift pool create failed for %s: %s", session_id, exc) return False -def _terminate_previous_timeshift_sessions(redis_client, user, channel_id): - """Displace the user's prior catch-up session(s) on this channel. +def _release_pool_session(redis_client, session_id, profile_id): + if redis_client is None: + return + if profile_id is not None: + try: + release_profile_slot(int(profile_id), redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed for profile %s: %s", profile_id, exc + ) + if not session_id: + return + key = _pool_key(session_id) + try: + with _pool_lock(redis_client, session_id): + if redis_client.exists(key): + redis_client.hset(key, mapping={ + "busy": "0", + "last_activity": str(time.time()), + }) + redis_client.expire(key, _POOL_IDLE_TTL) + except Exception as exc: + logger.warning("Timeshift pool release failed for %s: %s", session_id, exc) - Releases the provider slot, unregisters stats keys, and sets the live - stop key so the old generator exits on its next heartbeat. - """ + +def _discard_pool_session(redis_client, session_id, profile_id): + if redis_client is None: + return + if profile_id is not None: + try: + release_profile_slot(int(profile_id), redis_client) + except Exception as exc: + logger.warning( + "Timeshift slot release failed for profile %s: %s", profile_id, exc + ) + if not session_id: + return + try: + with _pool_lock(redis_client, session_id): + redis_client.delete(_pool_key(session_id)) + except Exception as exc: + logger.warning("Timeshift pool discard failed for %s: %s", session_id, exc) + + +def _make_release_once(redis_client, session_id, profile_id): + state = {"done": False} + + def _release(): + if state["done"]: + return + state["done"] = True + _release_pool_session(redis_client, session_id, profile_id) + + return _release + + +def _preempt_playback_streams(redis_client, session_id, user): + """Stop in-flight streams for this client session only.""" + if redis_client is None or not session_id or user is None: + return + try: + for conn in get_user_active_connections(user.id): + if conn.get("type") != "timeshift": + continue + if conn.get("client_id") != session_id: + continue + conn_media_id = str(conn.get("media_id") or "") + old_client_id = conn.get("client_id") + logger.debug( + "Timeshift preempt: stopping client %s on %s for reuse", + old_client_id, conn_media_id, + ) + _unregister_stats_client(redis_client, conn_media_id, old_client_id) + stop_key = RedisKeys.client_stop(conn_media_id, old_client_id) + redis_client.setex(stop_key, 60, "true") + except Exception as exc: + logger.warning("Timeshift preempt failed: %s", exc) + + +def _terminate_previous_timeshift_sessions( + redis_client, user, channel_id, current_media_id, current_session_id, +): + """Displace this user's other catch-up positions on the same channel.""" if redis_client is None or user is None: return prefix = f"timeshift_{channel_id}_" @@ -333,22 +760,162 @@ def _terminate_previous_timeshift_sessions(redis_client, user, channel_id): for conn in get_user_active_connections(user.id): if conn.get("type") != "timeshift": continue - media_id = str(conn.get("media_id") or "") - if not media_id.startswith(prefix): + if conn.get("client_id") == current_session_id: + continue + conn_media_id = str(conn.get("media_id") or "") + if not conn_media_id.startswith(prefix): + continue + if conn_media_id.startswith(f"{current_media_id}_") or conn_media_id == current_media_id: continue old_client_id = conn.get("client_id") logger.info( "Timeshift takeover: displacing session %s on %s", - old_client_id, media_id, + old_client_id, conn_media_id, ) - _release_slot_token(redis_client, old_client_id) - _unregister_stats_client(redis_client, media_id, old_client_id) - stop_key = RedisKeys.client_stop(media_id, old_client_id) + _unregister_stats_client(redis_client, conn_media_id, old_client_id) + stop_key = RedisKeys.client_stop(conn_media_id, old_client_id) redis_client.setex(stop_key, 60, "true") except Exception as exc: logger.warning("Timeshift takeover check failed: %s", exc) +def _attempt_timeshift_stream( + *, + m3u_account, + profile, + stream_id_value, + provider_timestamp, + provider_tz_name, + duration_minutes, + channel, + safe_ts, + timestamp, + client_id, + client_ip, + range_header, + channel_logo_id, + user, + redis_client, + debug, + release_cb=None, + pool_session_id=None, +): + """Build the provider URL set for one (account, profile, stream) and stream it.""" + server_url, xc_username, xc_password = get_transformed_credentials( + m3u_account, profile + ) + creds = TimeshiftCredentials(server_url, xc_username, xc_password) + candidate_urls = build_timeshift_candidate_urls( + creds, stream_id_value, provider_timestamp, duration_minutes + ) + + try: + user_agent = m3u_account.get_user_agent().user_agent + except AttributeError: + user_agent = "" + + virtual_channel_id = f"timeshift_{channel.id}_{safe_ts}_{stream_id_value}" + + if debug: + logger.debug( + "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " + "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s", + channel.name, timestamp, provider_tz_name, provider_timestamp, + m3u_account.id, profile.id, stream_id_value, + virtual_channel_id, client_id, range_header or "(none)", + ) + + return _stream_from_provider( + candidate_urls=candidate_urls, + user_agent=user_agent, + range_header=range_header, + virtual_channel_id=virtual_channel_id, + client_id=client_id, + client_ip=client_ip, + user=user, + channel_display_name=channel.name, + timestamp_utc=timestamp, + channel_logo_id=channel_logo_id, + m3u_profile_id=profile.id, + debug=debug, + account_id=m3u_account.id, + redis_client=redis_client, + release_cb=release_cb, + pool_session_id=pool_session_id, + ) + + +def _stream_reused_session( + redis_client, + *, + session_id, + descriptor, + profile, + channel, + safe_ts, + timestamp, + duration_minutes, + client_id, + client_ip, + range_header, + channel_logo_id, + user, + debug, +): + """Stream an idle pooled session that was just re-reserved for this request.""" + try: + m3u_account = M3UAccount.objects.get(id=int(descriptor["account_id"])) + except (M3UAccount.DoesNotExist, ValueError, TypeError): + _discard_pool_session(redis_client, session_id, profile.id) + return None + + provider_timestamp = descriptor.get("provider_timestamp") + if not provider_timestamp: + provider_tz_name = None + server_info = (profile.custom_properties or {}).get("server_info") or {} + if isinstance(server_info, dict): + provider_tz_name = server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz( + timestamp, provider_tz_name + ) + + release_cb = _make_release_once(redis_client, session_id, profile.id) + try: + response = _attempt_timeshift_stream( + m3u_account=m3u_account, + profile=profile, + stream_id_value=descriptor["stream_id"], + provider_timestamp=provider_timestamp, + provider_tz_name=None, + duration_minutes=duration_minutes, + channel=channel, + safe_ts=safe_ts, + timestamp=timestamp, + client_id=client_id, + client_ip=client_ip, + range_header=range_header, + channel_logo_id=channel_logo_id, + user=user, + redis_client=redis_client, + debug=debug, + release_cb=release_cb, + pool_session_id=session_id, + ) + except Exception: + _discard_pool_session(redis_client, session_id, profile.id) + raise + + if response.status_code < 400: + return response + + if getattr(response, "timeshift_passthrough", False) is True: + release_cb() + return response + + _discard_pool_session(redis_client, session_id, profile.id) + return None + + class _SlotReleasingStream: """Iterator wrapper that releases the pool slot when WSGI closes the response.""" @@ -492,6 +1059,19 @@ def _set_cached_format_index(account_id, index): cache.set(_FORMAT_CACHE_KEY.format(account_id), index, _FORMAT_CACHE_TTL) +def _passthrough_response(status, content_range=None): + """A terminal response handed straight to the client (no streaming). + + Marked so the failover loop and reuse path return it verbatim instead of + cascading other URL shapes or failing over to another provider. + """ + response = HttpResponse(status=status) + if content_range: + response["Content-Range"] = content_range + response.timeshift_passthrough = True + return response + + def _stream_from_provider( *, candidate_urls, @@ -508,13 +1088,18 @@ def _stream_from_provider( debug, account_id=None, redis_client=None, + release_cb=None, + pool_session_id=None, ): """Try each upstream URL until one returns streamable MPEG-TS. Sets ``timeshift_decisive`` on auth/ban-class failures (401/403/406) so the - failover loop skips the rest of that account's streams. + failover loop skips the rest of that account's streams. ``release_cb`` frees + the provider slot when the streaming response is closed. """ chunk_size = max(ConfigHelper.chunk_size(), 262144) + if release_cb is None: + release_cb = lambda: None # noqa: E731 cached_index = _get_cached_format_index(account_id) if cached_index is not None and 0 <= cached_index < len(candidate_urls): @@ -552,6 +1137,14 @@ def _stream_from_provider( response.headers.get("Content-Type", "?"), _redact_url(url), ) + if response.status_code == 416: + # Range Not Satisfiable: a seek/tail probe past EOF. Hand it back to + # the client verbatim. Byte offsets are file-specific, so trying + # other URL shapes or failing over to another provider is pointless + # and only multiplies upstream connections. + content_range = response.headers.get("Content-Range") + response.close() + return _passthrough_response(416, content_range) if response.status_code in (200, 206): peek = response.raw.read(1024) sync_offset = find_ts_sync(peek) if peek else -1 @@ -560,18 +1153,29 @@ def _stream_from_provider( upstream = response winning_index = orig_idx break - else: - snippet = peek[:200].decode("utf-8", errors="replace") if peek else "(empty)" - logger.warning( - "Timeshift upstream returned 200 but no TS sync in first %d " - "bytes (likely PHP error): %s, url=%s", - len(peek) if peek else 0, - snippet.replace("\n", " ")[:120], - _redact_url(url), - ) - response.close() - last_status = 404 # Treat as soft rejection for cascade - continue + # A 206 to a Range request legitimately starts mid-packet, so the + # sync byte rarely lands at offset 0. Trust the partial status and + # content type rather than the sync probe; only a full 200 carrying + # a PHP/HTML error page must be rejected here. + content_type = response.headers.get("Content-Type", "") + is_partial = response.status_code == 206 and bool(range_header) + if is_partial and peek and "html" not in content_type and "json" not in content_type: + response._peek_data = peek + upstream = response + winning_index = orig_idx + break + snippet = peek[:200].decode("utf-8", errors="replace") if peek else "(empty)" + logger.warning( + "Timeshift upstream returned %d but no TS sync in first %d " + "bytes (likely PHP error): %s, url=%s", + response.status_code, + len(peek) if peek else 0, + snippet.replace("\n", " ")[:120], + _redact_url(url), + ) + response.close() + last_status = 404 # Treat as soft rejection for cascade + continue response.close() # Auth/ban-class statuses stop trying more shapes on this account; 5xx does not. code = response.status_code @@ -599,6 +1203,9 @@ def _stream_from_provider( content_range = upstream.headers.get("Content-Range", "") status = upstream.status_code + _store_pool_content_length(redis_client, pool_session_id, upstream) + _store_pool_serving_range(redis_client, pool_session_id, range_header) + _register_stats_client( redis_client, virtual_channel_id, @@ -618,29 +1225,57 @@ def _stream_from_provider( if peek_data: chunks_iter = itertools.chain([peek_data], chunks_iter) + session_closed = {"done": False} + + def _finish_session(*, close_upstream=False): + if session_closed["done"]: + return + session_closed["done"] = True + if close_upstream: + try: + upstream.close() + except Exception: + pass + _unregister_stats_client(redis_client, virtual_channel_id, client_id) + release_cb() + def stream_generator(): last_heartbeat = time.time() bytes_since_heartbeat = 0 total_yielded = 0 - chunk_count = 0 loop_start = time.time() stop_key = RedisKeys.client_stop(virtual_channel_id, client_id) + stream_started_logged = False try: for data in chunks_iter: if not data: continue + if debug and not stream_started_logged: + stream_started_logged = True + logger.debug( + "Timeshift stream started: client=%s vid=%s range=%s status=%d", + client_id, virtual_channel_id, range_header or "(none)", status, + ) yield data bytes_since_heartbeat += len(data) total_yielded += len(data) - chunk_count += 1 now = time.time() - # Poll stop key and refresh stats every 5 seconds. + if redis_client and redis_client.exists(stop_key): + logger.info("Timeshift client %s received stop signal", client_id) + redis_client.delete(stop_key) + break + # Refresh stats every 5 seconds. if now - last_heartbeat >= 5: - if redis_client and redis_client.exists(stop_key): - logger.info("Timeshift client %s received stop signal", client_id) - redis_client.delete(stop_key) - break + if debug and total_yielded > 0: + elapsed = now - loop_start + mbps = (total_yielded * 8) / elapsed / 1_000_000 if elapsed > 0 else 0 + logger.debug( + "Timeshift streaming: client=%s range=%s total=%d bytes " + "in %.1fs (%.2f Mbps avg)", + client_id, range_header or "(none)", + total_yielded, elapsed, mbps, + ) _heartbeat_stats_client( redis_client, virtual_channel_id, client_id, bytes_delta=bytes_since_heartbeat, @@ -664,18 +1299,9 @@ def _stream_from_provider( "Timeshift disconnect: vid=%s client=%s yielded=%d bytes in %.1fs (%.2f Mbps avg)", virtual_channel_id, client_id, total_yielded, elapsed, mbps, ) - try: - upstream.close() - except Exception: - pass - _unregister_stats_client(redis_client, virtual_channel_id, client_id) - _release_slot_token(redis_client, client_id) + _finish_session(close_upstream=True) - def _close_session(): - _unregister_stats_client(redis_client, virtual_channel_id, client_id) - _release_slot_token(redis_client, client_id) - - stream_iter = _SlotReleasingStream(stream_generator(), _close_session) + stream_iter = _SlotReleasingStream(stream_generator(), _finish_session) response = StreamingHttpResponse( stream_iter, content_type=content_type, From 5a552cd5658cc5f53d7e58869f86bb91ace5f28e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 17:48:06 -0500 Subject: [PATCH 24/56] refactor(epg): Update xmltv_prev_days_override setting to EPG settings and adjust related logic. Refactor CoreSettings to include a dedicated method for retrieving the override value. Update tests and frontend components to reflect the new structure and ensure consistent behavior across settings management. --- CHANGELOG.md | 2 +- apps/channels/tests/test_catchup_utils.py | 4 +- apps/channels/utils.py | 5 +- core/models.py | 12 ++- .../forms/settings/EpgSettingsForm.jsx | 81 +++++++++++++++++++ .../forms/settings/ProxySettingsForm.jsx | 5 +- frontend/src/constants.js | 3 + frontend/src/pages/Settings.jsx | 16 ++++ .../forms/settings/EpgSettingsFormUtils.js | 3 + .../forms/settings/ProxySettingsFormUtils.js | 1 - .../__tests__/ProxySettingsFormUtils.test.js | 2 - frontend/src/utils/pages/SettingsUtils.js | 23 +++++- 12 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/forms/settings/EpgSettingsForm.jsx create mode 100644 frontend/src/utils/forms/settings/EpgSettingsFormUtils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4890e407..232b06df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it exactly once when the session ends (one-shot Redis token, covering disconnects before the first chunk). When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. - **One catch-up session per user and channel.** A seek or programme jump displaces the user's previous session on the same channel: its provider slot is released synchronously and the old stream is stopped through the standard stop-key mechanism, so rapid seeking can't stack upstream provider connections. - - **Single setting**, `xmltv_prev_days_override`, under `Settings → Proxy Settings` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. + - **Single setting**, `xmltv_prev_days_override`, under `Settings → EPG` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. diff --git a/apps/channels/tests/test_catchup_utils.py b/apps/channels/tests/test_catchup_utils.py index 68ac20c3..d79eec11 100644 --- a/apps/channels/tests/test_catchup_utils.py +++ b/apps/channels/tests/test_catchup_utils.py @@ -45,8 +45,8 @@ class ResolveXcEpgPrevDaysTests(SimpleTestCase): mock_compute.assert_not_called() @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) - @patch("core.models.CoreSettings.get_proxy_settings", return_value={"xmltv_prev_days_override": 7}) - def test_proxy_override_prevents_auto_detect(self, _settings, mock_compute): + @patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7) + def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute): request = self.factory.get("/xmltv.php") self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7) mock_compute.assert_not_called() diff --git a/apps/channels/utils.py b/apps/channels/utils.py index b2cceab6..70b9eecd 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -67,7 +67,7 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): Resolution order: 1. URL ``?prev_days=`` (explicit; 0 means no past programmes) 2. ``user.custom_properties.epg_prev_days`` - 3. ``CoreSettings.proxy_settings.xmltv_prev_days_override`` when > 0 + 3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0 4. Auto-detect (only when *auto_detect_fallback* is True) """ user_custom = (user.custom_properties or {}) if user else {} @@ -87,9 +87,8 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): from core.models import CoreSettings - proxy_settings = CoreSettings.get_proxy_settings() try: - override = int(proxy_settings.get("xmltv_prev_days_override", 0) or 0) + override = int(CoreSettings.get_xmltv_prev_days_override() or 0) except (TypeError, ValueError): override = 0 if override > 0: diff --git a/core/models.py b/core/models.py index 9c5a5795..58fd37da 100644 --- a/core/models.py +++ b/core/models.py @@ -280,8 +280,18 @@ class CoreSettings(models.Model): "epg_match_ignore_prefixes": [], "epg_match_ignore_suffixes": [], "epg_match_ignore_custom": [], + # XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30). + "xmltv_prev_days_override": 0, }) + @classmethod + def get_xmltv_prev_days_override(cls): + """Global XC XMLTV prev_days default (0 = auto-detect from provider archives).""" + try: + return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0) + except (TypeError, ValueError): + return 0 + @classmethod def _safe_string_list(cls, value): """Return a list of strings, filtering out non-list or non-string values.""" @@ -396,8 +406,6 @@ class CoreSettings(models.Model): "channel_shutdown_delay": 0, "channel_init_grace_period": 5, "new_client_behind_seconds": 5, - # XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30). - "xmltv_prev_days_override": 0, }) # System Settings diff --git a/frontend/src/components/forms/settings/EpgSettingsForm.jsx b/frontend/src/components/forms/settings/EpgSettingsForm.jsx new file mode 100644 index 00000000..5668dbdd --- /dev/null +++ b/frontend/src/components/forms/settings/EpgSettingsForm.jsx @@ -0,0 +1,81 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { useForm } from '@mantine/form'; +import { + Alert, + Button, + Flex, + NumberInput, + Stack, + Text, +} from '@mantine/core'; +import { EPG_SETTINGS_OPTIONS } from '../../../constants.js'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { getEpgSettingsFormInitialValues } from '../../../utils/forms/settings/EpgSettingsFormUtils.js'; + +const EpgSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + const [saved, setSaved] = useState(false); + + const form = useForm({ + mode: 'controlled', + initialValues: getEpgSettingsFormInitialValues(), + }); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const parsed = parseSettings(settings); + form.setFieldValue( + 'xmltv_prev_days_override', + parsed.xmltv_prev_days_override ?? 0, + ); + } + }, [settings]); + + const onSubmit = async () => { + setSaved(false); + const changedSettings = getChangedSettings(form.getValues(), settings); + try { + await saveChangedSettings(settings, changedSettings); + setSaved(true); + } catch (error) { + console.error('Error saving EPG settings:', error); + } + }; + + const prevDaysConfig = EPG_SETTINGS_OPTIONS.xmltv_prev_days_override; + + return ( +
+ + {saved && ( + + )} + + + Per-user defaults and URL parameters still override this global value. + EPG channel matching options are configured from the Channels page. + + + + + +
+ ); +}); + +export default EpgSettingsForm; diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index 9b2c2c62..52440769 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -25,7 +25,6 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { 'channel_shutdown_delay', 'channel_init_grace_period', 'new_client_behind_seconds', - 'xmltv_prev_days_override', ].includes(key); }; const isFloatField = (key) => { @@ -40,9 +39,7 @@ const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { ? 300 : key === 'new_client_behind_seconds' ? 120 - : key === 'xmltv_prev_days_override' - ? 30 - : 60; + : 60; }; return ( <> diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 00af156e..b4503dc6 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -61,6 +61,9 @@ export const PROXY_SETTINGS_OPTIONS = { description: 'Seconds of received buffer to start behind live when a new client connects (0 = start at live). Note: this is chunk receive time, not video duration.', }, +}; + +export const EPG_SETTINGS_OPTIONS = { xmltv_prev_days_override: { label: 'XMLTV prev_days Override (catch-up)', description: diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 29d46dd8..1c7964cc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -45,6 +45,9 @@ const DvrSettingsForm = React.lazy( const SystemSettingsForm = React.lazy( () => import('../components/forms/settings/SystemSettingsForm.jsx') ); +const EpgSettingsForm = React.lazy( + () => import('../components/forms/settings/EpgSettingsForm.jsx') +); const NavOrderForm = React.lazy( () => import('../components/forms/settings/NavOrderForm.jsx') ); @@ -122,6 +125,19 @@ const SettingsPage = () => { + + EPG + + + }> + + + + + + System Settings diff --git a/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js b/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js new file mode 100644 index 00000000..d2138b6a --- /dev/null +++ b/frontend/src/utils/forms/settings/EpgSettingsFormUtils.js @@ -0,0 +1,3 @@ +export const getEpgSettingsFormInitialValues = () => ({ + xmltv_prev_days_override: 0, +}); diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js index 93cccc9b..eddbba91 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -15,6 +15,5 @@ export const getProxySettingDefaults = () => { channel_shutdown_delay: 0, channel_init_grace_period: 5, new_client_behind_seconds: 5, - xmltv_prev_days_override: 0, }; }; diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js index c1fd0908..21c48269 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -62,7 +62,6 @@ describe('ProxySettingsFormUtils', () => { channel_shutdown_delay: 0, channel_init_grace_period: 5, new_client_behind_seconds: 5, - xmltv_prev_days_override: 0, }); }); @@ -83,7 +82,6 @@ describe('ProxySettingsFormUtils', () => { expect(typeof result.channel_shutdown_delay).toBe('number'); expect(typeof result.channel_init_grace_period).toBe('number'); expect(typeof result.new_client_behind_seconds).toBe('number'); - expect(typeof result.xmltv_prev_days_override).toBe('number'); }); }); }); diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index acdc6cae..bd46ac52 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -39,6 +39,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom', + 'xmltv_prev_days_override', ]; const dvrFields = [ 'tv_template', @@ -125,6 +126,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { 'retention_count', 'schedule_day_of_week', 'max_system_events', + 'xmltv_prev_days_override', ]; if (numericFields.includes(formKey) && value != null) { value = typeof value === 'number' ? value : parseInt(value, 10); @@ -222,6 +224,20 @@ export const getChangedSettings = (values, settings) => { continue; } + if (settingKey === 'xmltv_prev_days_override') { + const baseline = Number( + settings['epg_settings']?.value?.xmltv_prev_days_override ?? 0, + ); + const nextVal = + typeof actualValue === 'number' + ? actualValue + : parseInt(actualValue, 10) || 0; + if (nextVal !== baseline) { + changedSettings[settingKey] = nextVal; + } + continue; + } + // Convert array values (like m3u_hash_key) to comma-separated strings for comparison if (Array.isArray(actualValue)) { actualValue = actualValue.join(','); @@ -296,6 +312,12 @@ export const parseSettings = (settings) => { epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom) ? epgSettings.epg_match_ignore_custom : []; + parsed.xmltv_prev_days_override = + epgSettings && epgSettings.xmltv_prev_days_override != null + ? typeof epgSettings.xmltv_prev_days_override === 'number' + ? epgSettings.xmltv_prev_days_override + : parseInt(epgSettings.xmltv_prev_days_override, 10) || 0 + : 0; // DVR settings - direct mapping with underscore keys const dvrSettings = settings['dvr_settings']?.value; @@ -367,7 +389,6 @@ export const parseSettings = (settings) => { } // Proxy and network access are already grouped objects - // (xmltv_prev_days_override lives inside proxy_settings) if (settings['proxy_settings']?.value) { parsed.proxy_settings = settings['proxy_settings'].value; } From 88de2d9bf0b72bf14ac45e37d524030d1b37f8d6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Jun 2026 17:51:06 -0500 Subject: [PATCH 25/56] changelog: update for accuracy --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 232b06df..1ad83500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. +- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) (#1242) - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; the per-account rollup also self-heals channels left flagged with no remaining catch-up stream (e.g. after stale-stream cleanup on manual/multi-provider channels). - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. - - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it exactly once when the session ends (one-shot Redis token, covering disconnects before the first chunk). When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. - - **One catch-up session per user and channel.** A seek or programme jump displaces the user's previous session on the same channel: its provider slot is released synchronously and the old stream is stopped through the standard stop-key mechanism, so rapid seeking can't stack upstream provider connections. - - **Single setting**, `xmltv_prev_days_override`, under `Settings → EPG` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. + - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. + - **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Stream-limit exemptions for `ignore_same_channel_connections` apply only to sibling requests from the same `session_id`. + - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. From f1dde066c0bf1335cf9a13dd197cd7ace6c671a6 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:20:11 -0700 Subject: [PATCH 26/56] Extracted utils --- frontend/src/utils/dateTimeUtils.js | 36 +++ frontend/src/utils/forms/ChannelUtils.js | 2 +- .../src/utils/tables/ChannelsTableUtils.js | 233 ++++++++++++++++++ frontend/src/utils/tables/EPGsTableUtils.js | 55 +++++ frontend/src/utils/tables/LogosTableUtils.js | 62 +++++ frontend/src/utils/tables/M3UsTableUtils.js | 142 +++++++++++ .../src/utils/tables/StreamsTableUtils.js | 127 ++++++++++ 7 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/tables/ChannelsTableUtils.js create mode 100644 frontend/src/utils/tables/EPGsTableUtils.js create mode 100644 frontend/src/utils/tables/LogosTableUtils.js create mode 100644 frontend/src/utils/tables/M3UsTableUtils.js create mode 100644 frontend/src/utils/tables/StreamsTableUtils.js diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index f973e0b0..e198329c 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -372,3 +372,39 @@ export const MONTH_ABBR = [ 'nov', 'dec', ]; + +/** + * @param {number} seconds + * @param {object} [options] + * @param {'hms'|'hm'|'m'} [options.precision='hms'] - Segments to include + * @param {boolean} [options.alwaysShowHours=false] - Always include hours segment + * @param {string|null} [options.zeroValue=null] - Return this when seconds is 0/falsy + */ +export const formatDuration = (seconds, options = {}) => { + const { precision = 'hms', alwaysShowHours = false, zeroValue = null } = options; + + if (!seconds || seconds === 0) return zeroValue ?? '0:00'; + + const abs = Math.abs(seconds); + const h = Math.floor(abs / 3600); + const m = Math.floor((abs % 3600) / 60); + const s = Math.floor(abs % 60); + + const mm = m.toString().padStart(2, '0'); + const ss = s.toString().padStart(2, '0'); + const hh = h.toString().padStart(2, '0'); + + switch (precision) { + case 'm': + return `${Math.floor(abs / 60)}`; + case 'hm': + return (alwaysShowHours || h > 0) + ? `${hh}:${mm}` + : `${m}`; + case 'hms': + default: + return (alwaysShowHours || h > 0) + ? `${hh}:${mm}:${ss}` + : `${m}:${ss}`; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js index cc3743e3..40cf9a27 100644 --- a/frontend/src/utils/forms/ChannelUtils.js +++ b/frontend/src/utils/forms/ChannelUtils.js @@ -34,7 +34,7 @@ export const createLogo = (newLogoData) => { const setChannelEPG = (channel, values) => { return API.setChannelEPG(channel.id, values.epg_data_id); }; -const updateChannel = (values) => { +export const updateChannel = (values) => { return API.updateChannel(values); }; export const addChannel = (channel) => { diff --git a/frontend/src/utils/tables/ChannelsTableUtils.js b/frontend/src/utils/tables/ChannelsTableUtils.js new file mode 100644 index 00000000..a5cadb45 --- /dev/null +++ b/frontend/src/utils/tables/ChannelsTableUtils.js @@ -0,0 +1,233 @@ +import { + normalizeFieldValue, + OVERRIDABLE_FIELDS, +} from '../forms/ChannelUtils.js'; +import API from '../../api.js'; + +// Inline edits on auto-synced channels route into the override row so +// sync cannot overwrite them. If the new value matches the provider's, +// clear that field's override instead of writing a duplicate. Manual +// channels keep direct Channel.* writes. +export const buildInlinePatch = (rowOriginal, fieldId, newValue) => { + if (rowOriginal?.auto_created && OVERRIDABLE_FIELDS.includes(fieldId)) { + // Normalize both sides so a stringified form value compares + // cleanly against the typed provider value. + const formValue = normalizeFieldValue(fieldId, newValue); + const providerValue = normalizeFieldValue(fieldId, rowOriginal[fieldId]); + const overrideFieldValue = formValue === providerValue ? null : formValue; + return { + id: rowOriginal.id, + override: { [fieldId]: overrideFieldValue }, + }; + } + const normalized = + newValue === undefined || newValue === '' ? null : newValue; + return { + id: rowOriginal.id, + [fieldId]: normalized, + }; +}; + +export const getEpgOptions = (tvgsById, epgs) => { + const options = [{ value: 'null', label: 'Not Assigned' }]; + + // Convert tvgsById to an array and sort by EPG source name, then by tvg_id + const tvgsArray = Object.values(tvgsById).sort((a, b) => { + const aEpgName = + a.epg_source && epgs[a.epg_source] + ? epgs[a.epg_source].name + : a.epg_source || ''; + const bEpgName = + b.epg_source && epgs[b.epg_source] + ? epgs[b.epg_source].name + : b.epg_source || ''; + const epgCompare = aEpgName.localeCompare(bEpgName); + if (epgCompare !== 0) return epgCompare; + // Secondary sort by tvg_id + return (a.tvg_id || '').localeCompare(b.tvg_id || ''); + }); + + tvgsArray.forEach((tvg) => { + const epgSourceName = + tvg.epg_source && epgs[tvg.epg_source] + ? epgs[tvg.epg_source].name + : tvg.epg_source; + const tvgName = tvg.name; + // Create a comprehensive label: "EPG Name | TVG-ID | TVG Name" + let label; + if (epgSourceName && tvg.tvg_id) { + label = `${epgSourceName} | ${tvg.tvg_id}`; + if (tvgName && tvgName !== tvg.tvg_id) { + label += ` | ${tvgName}`; + } + } else if (tvgName) { + label = tvgName; + } else { + label = `ID: ${tvg.id}`; + } + + options.push({ + value: String(tvg.id), + label: label, + }); + }); + + return options; +}; + +export const getLogoOptions = (channelLogos) => { + const options = [ + { + value: 'null', + label: 'Default', + logo: null, + }, + ]; + + // Convert channelLogos object to array and sort by name + const logosArray = Object.values(channelLogos).sort((a, b) => + (a.name || '').localeCompare(b.name || '') + ); + + logosArray.forEach((logo) => { + options.push({ + value: String(logo.id), + label: logo.name || `Logo ${logo.id}`, + logo: logo, + }); + }); + + return options; +}; + +export const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; +export const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; +export const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; + +export const reorderChannel = (channelId, insertAfterId) => { + return API.reorderChannel(channelId, insertAfterId); +}; +export const deleteChannel = (id) => { + return API.deleteChannel(id); +}; +export const deleteChannels = (channelIds) => { + return API.deleteChannels(channelIds); +}; +export const queryChannels = (params) => { + return API.queryChannels(params); +}; +export const getAllChannelIds = (params) => { + return API.getAllChannelIds(params); +}; +export const updateProfileChannels = (channelIds, profileId, enabled) => { + return API.updateProfileChannels(channelIds, profileId, enabled); +}; +export const updateProfileChannel = (channelId, profileId, enabled) => { + return API.updateProfileChannel(channelId, profileId, enabled); +}; +export const addChannelProfile = (values) => { + return API.addChannelProfile(values); +}; +export const deleteChannelProfile = (id) => { + return API.deleteChannelProfile(id); +}; + +const getSortParam = (sorting) => { + let sortField = sorting[0].id; + // Map frontend column ids to backend ordering field names + const fieldMapping = { + channel_group: 'channel_group__name', + epg: 'epg_data__name', + }; + if (fieldMapping[sortField]) { + sortField = fieldMapping[sortField]; + } + const sortDirection = sorting[0].desc ? '-' : ''; + return { sortField, sortDirection }; +}; + +const applyDebouncedFilters = (debouncedFilters, params) => { + // Apply debounced filters + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (value) { + if (Array.isArray(value)) { + // Convert null values to "null" string for URL parameter + const processedValue = value + .map((v) => (v === null ? 'null' : v)) + .join(','); + params.append(key, processedValue); + } else { + params.append(key, value); + } + } + }); +}; + +// Build URLs with parameters +export const buildM3UUrl = (m3uParams, m3uUrl) => { + const params = new URLSearchParams(); + if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); + if (m3uParams.direct) params.append('direct', 'true'); + if (m3uParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', m3uParams.tvg_id_source); + if (m3uParams.output_format) + params.append('output_format', m3uParams.output_format); + if (m3uParams.output_profile) + params.append('output_profile', m3uParams.output_profile); + + const baseUrl = m3uUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildEPGUrl = (epgParams, epgUrl) => { + const params = new URLSearchParams(); + if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); + if (epgParams.tvg_id_source !== 'channel_number') + params.append('tvg_id_source', epgParams.tvg_id_source); + if (epgParams.days > 0) params.append('days', epgParams.days.toString()); + if (epgParams.prev_days > 0) + params.append('prev_days', epgParams.prev_days.toString()); + + const baseUrl = epgUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; +}; + +export const buildFetchParams = ({ + pagination, + sorting, + debouncedFilters, + selectedProfileId, + showDisabled, + showOnlyStreamlessChannels, + showOnlyStaleChannels, + showOnlyOverriddenChannels, + visibilityFilter, +}) => { + const params = new URLSearchParams(); + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + params.append('include_streams', 'true'); + + if (selectedProfileId !== '0') + params.append('channel_profile_id', selectedProfileId); + if (showDisabled) params.append('show_disabled', true); + if (showOnlyStreamlessChannels) params.append('only_streamless', true); + if (showOnlyStaleChannels) params.append('only_stale', true); + if (showOnlyOverriddenChannels) params.append('only_has_overrides', true); + if (visibilityFilter && visibilityFilter !== 'active') + params.append('visibility_filter', visibilityFilter); + if (sorting.length > 0) { + const { sortField, sortDirection } = getSortParam(sorting); + params.append('ordering', `${sortDirection}${sortField}`); + } + + applyDebouncedFilters(debouncedFilters, params); + return params; +}; + +export const buildHDHRUrl = (hdhrOutputProfileId, hdhrUrl) => { + if (!hdhrOutputProfileId) return hdhrUrl; + // Insert output_profile segment before the trailing slash (or at end) + const base = hdhrUrl.replace(/\/$/, ''); + return `${base}/output_profile/${hdhrOutputProfileId}`; +}; diff --git a/frontend/src/utils/tables/EPGsTableUtils.js b/frontend/src/utils/tables/EPGsTableUtils.js new file mode 100644 index 00000000..f409d1e2 --- /dev/null +++ b/frontend/src/utils/tables/EPGsTableUtils.js @@ -0,0 +1,55 @@ +import API from '../../api.js'; + +// Helper function to format status text +export const formatStatusText = (status) => { + if (!status) return 'Unknown'; + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +}; + +export const updateEpg = async (values, epg, isToggle) => { + return API.updateEPG({ ...values, id: epg.id }, isToggle); +}; +export const deleteEpg = (id) => { + return API.deleteEPG(id); +}; +export const refreshEpg = (id, force = false) => { + return API.refreshEPG(id, force); +}; + +export const getProgressLabel = (action) => { + switch (action) { + case 'downloading': + return 'Downloading'; + case 'extracting': + return 'Extracting'; + case 'parsing_channels': + return 'Parsing Channels'; + case 'parsing_programs': + return 'Parsing Programs'; + default: + return null; + } +}; + +export const getProgressInfo = (progress) => { + // Build additional info string from progress data + if (progress.message) { + return progress.message; + } else if ( + progress.processed !== undefined && + progress.channels !== undefined + ) { + return `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`; + } else if (progress.processed !== undefined && progress.total !== undefined) { + return `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`; + } + return null; +}; + +export const getSortedEpgs = (epgs, compareColumn, compareDesc) => { + return [...epgs].sort((a, b) => { + if (a[compareColumn] < b[compareColumn]) return compareDesc ? 1 : -1; + if (a[compareColumn] > b[compareColumn]) return compareDesc ? -1 : 1; + return 0; + }); +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/LogosTableUtils.js b/frontend/src/utils/tables/LogosTableUtils.js new file mode 100644 index 00000000..b80e0a32 --- /dev/null +++ b/frontend/src/utils/tables/LogosTableUtils.js @@ -0,0 +1,62 @@ +import API from '../../api.js'; + +export const getFilteredLogos = (logos, debouncedNameFilter, filtersUsed) => { + // Apply filters + let filteredLogos = Object.values(logos || {}); + + if (debouncedNameFilter) { + filteredLogos = filteredLogos.filter((logo) => + logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) + ); + } + + if (filtersUsed === 'used') { + filteredLogos = filteredLogos.filter((logo) => logo.is_used); + } else if (filtersUsed === 'unused') { + filteredLogos = filteredLogos.filter((logo) => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); +}; + +export const deleteLogo = (id, deleteFile) => { + return API.deleteLogo(id, deleteFile); +}; +export const deleteLogos = (ids, deleteFiles) => { + return API.deleteLogos(ids, deleteFiles); +}; +export const cleanupUnusedLogos = (deleteFiles) => { + return API.cleanupUnusedLogos(deleteFiles); +}; + +// Generate smart label based on usage +const categorizeUsage = (names) => { + const types = { channels: 0, movies: 0, series: 0 }; + + names.forEach((name) => { + if (name.startsWith('Channel:')) types.channels++; + else if (name.startsWith('Movie:')) types.movies++; + else if (name.startsWith('Series:')) types.series++; + }); + + return types; +}; + +// Analyze channel_names to categorize types +export const generateUsageLabel = (channelNames, channelCount) => { + const types = categorizeUsage(channelNames); + const typeCount = Object.values(types).filter( + (count) => count > 0 + ).length; + if (typeCount === 1) { + // Only one type - be specific + if (types.channels > 0) + return `${types.channels} ${types.channels !== 1 ? 'channels' : 'channel'}`; + if (types.movies > 0) + return `${types.movies} ${types.movies !== 1 ? 'movies' : 'movie'}`; + if (types.series > 0) return `${types.series} series`; + } else { + // Multiple types - use generic "items" + return `${channelCount} ${channelCount !== 1 ? 'items' : 'item'}`; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/tables/M3UsTableUtils.js b/frontend/src/utils/tables/M3UsTableUtils.js new file mode 100644 index 00000000..9416d13f --- /dev/null +++ b/frontend/src/utils/tables/M3UsTableUtils.js @@ -0,0 +1,142 @@ +import API from '../../api.js'; +import { format } from '../dateTimeUtils.js'; +import { formatDuration } from '../dateTimeUtils.js'; +import { formatSpeed } from '../networkUtils.js'; + +export const refreshPlaylist = (id) => API.refreshPlaylist(id); + +export const getPlaylistAutoCreatedChannelsCount = (id) => + API.getPlaylistAutoCreatedChannelsCount(id); + +export const deletePlaylist = (id) => API.deletePlaylist(id); + +export const updatePlaylist = (values, playlist, isToggle = false) => + API.updatePlaylist({ ...values, id: playlist.id }, isToggle); + +export const formatStatusText = (status) => { + switch (status) { + case 'idle': return 'Idle'; + case 'fetching': return 'Fetching'; + case 'parsing': return 'Parsing'; + case 'error': return 'Error'; + case 'success': return 'Success'; + case 'pending_setup': return 'Pending Setup'; + default: + return status + ? status.charAt(0).toUpperCase() + status.slice(1) + : 'Unknown'; + } +}; + +export const getStatusColor = (status) => { + switch (status) { + case 'idle': return 'gray.5'; + case 'fetching': return 'blue.5'; + case 'parsing': return 'indigo.5'; + case 'error': return 'red.5'; + case 'success': return 'green.5'; + case 'pending_setup': return 'orange.5'; + default: return 'gray.5'; + } +}; + +export const getExpirationInfo = (daysLeft, earliest, fullDateFormat) => { + let color; + let label; + if (daysLeft < 0) { + color = 'red.7'; + label = 'Expired'; + } else if (daysLeft === 0) { + color = 'red.5'; + label = 'Expires today'; + } else if (daysLeft <= 7) { + color = 'orange.5'; + label = `${daysLeft}d left`; + } else if (daysLeft <= 30) { + color = 'yellow.5'; + label = `${daysLeft}d left`; + } else { + label = format(earliest, fullDateFormat); + } + return { color, label }; +}; + +export const getExpirationTooltip = (allExpirations, fullDateTimeFormat, label) => { + return allExpirations.length > 0 + ? allExpirations + .map( + (e) => + `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${ + !e.is_active ? ' (inactive)' : '' + }` + ) + .join('\n') + : label; +}; + +export const getSortedPlaylists = (playlists, compareColumn, compareDesc) => { + return playlists + .filter((playlist) => playlist.locked === false) + .sort((a, b) => { + const aVal = a[compareColumn]; + const bVal = b[compareColumn]; + + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + + const comparison = + typeof aVal === 'string' + ? aVal.localeCompare(bVal) + : aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + + return compareDesc ? -comparison : comparison; + }); +}; + +export const getStatusContent = (data) => { + if (data.progress === 100) return null; + + switch (data.action) { + case 'initializing': + return { type: 'initializing' }; + + case 'downloading': + if (data.progress === 0) return { type: 'simple', label: 'Downloading...' }; + return { + type: 'downloading', + progress: parseInt(data.progress), + speed: formatSpeed(data.speed), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + }; + + case 'processing_groups': + if (data.progress === 0) return { type: 'simple', label: 'Processing groups...' }; + return { + type: 'groups', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + groupsProcessed: data.groups_processed, + }; + + case 'parsing': + if (data.progress === 0) return { type: 'simple', label: 'Parsing...' }; + return { + type: 'parsing', + progress: parseInt(data.progress), + elapsedTime: formatDuration(data.elapsed_time), + timeRemaining: data.time_remaining + ? formatDuration(data.time_remaining) + : 'calculating...', + streamsProcessed: data.streams_processed, + }; + + default: + return data.status === 'error' + ? { type: 'error', error: data.error } + : { type: 'simple', label: `${data.action || 'Processing'}...` }; + } +}; + diff --git a/frontend/src/utils/tables/StreamsTableUtils.js b/frontend/src/utils/tables/StreamsTableUtils.js new file mode 100644 index 00000000..238446dc --- /dev/null +++ b/frontend/src/utils/tables/StreamsTableUtils.js @@ -0,0 +1,127 @@ +import API from '../../api.js'; + +export const addStreamsToChannel = (channelId, existingStreams, newStreams) => { + return API.addStreamsToChannel(channelId, existingStreams, newStreams); +}; +export const queryStreamsTable = (params) => { + return API.queryStreamsTable(params); +}; +export const getStreams = (streamIds) => { + return API.getStreams(streamIds); +}; +export const createChannelsFromStreamsAsync = ( + streamIds, + channelProfileIds, + startingChannelNumber +) => + API.createChannelsFromStreamsAsync( + streamIds, + channelProfileIds, + startingChannelNumber + ); +export const deleteStream = (id) => API.deleteStream(id); +export const deleteStreams = (ids) => { + return API.deleteStreams(ids); +}; +export const requeryStreams = () => { + return API.requeryStreams(); +}; +export const createChannelFromStream = (values) => + API.createChannelFromStream(values); +export const getAllStreamIds = (params) => { + return API.getAllStreamIds(params); +}; +export const getStreamFilterOptions = (params) => { + return API.getStreamFilterOptions(params); +}; + +export const getStatsTooltip = (stats) => { + // Build compact display (resolution + video codec) + const parts = []; + if (stats.resolution) { + // Convert "1920x1080" to "1080p" format + const height = stats.resolution.split('x')[1]; + if (height) parts.push(`${height}p`); + } + if (stats.video_codec) { + parts.push(stats.video_codec.toUpperCase()); + } + const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; + + // Build tooltip content with friendly labels + const tooltipLines = []; + if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`); + if (stats.video_codec) + tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`); + if (stats.video_bitrate) + tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); + if (stats.source_fps) + tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); + if (stats.audio_codec) + tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`); + if (stats.audio_channels) + tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); + if (stats.audio_bitrate) + tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); + + const tooltipContent = + tooltipLines.length > 0 + ? tooltipLines.join('\n') + : 'No source info available'; + return { compactDisplay, tooltipContent }; +}; + +export const appendFetchPageParams = (params, pagination, sorting) => { + params.append('page', pagination.pageIndex + 1); + params.append('page_size', pagination.pageSize); + + if (sorting.length > 0) { + const columnId = sorting[0].id; + const fieldMapping = { + name: 'name', + group: 'channel_group__name', + m3u: 'm3u_account__name', + tvg_id: 'tvg_id', + }; + const sortField = fieldMapping[columnId] || columnId; + const sortDirection = sorting[0].desc ? '-' : ''; + params.append('ordering', `${sortDirection}${sortField}`); + } +}; + +export const getChannelProfileIds = (profileIds, selectedProfileId) => { + // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise + if (profileIds) { + if (profileIds.includes('none')) { + return []; + } else if (profileIds.includes('all')) { + return null; + } else { + return profileIds.map((id) => parseInt(id)); + } + } else { + return selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; + } +}; + +export const getChannelNumberValue = (mode, startNumber) => { + return mode === 'provider' + ? null + : mode === 'auto' + ? 0 + : mode === 'highest' + ? -1 + : Number(startNumber); +}; + +export const getFilterParams = (debouncedFilters) => { + const params = new URLSearchParams(); + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (typeof value === 'boolean') { + if (value) params.append(key, 'true'); + } else if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } + }); + return params; +}; From 7a4db314603fafaaeb8f55a92ca1676a250e8712 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:21:51 -0700 Subject: [PATCH 27/56] Moved functionality to shared util --- .../src/utils/cards/VodConnectionCardUtils.js | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index 5bec7d9a..2514571a 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -1,27 +1,5 @@ import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js'; -export const formatDuration = (seconds) => { - if (!seconds) return 'Unknown'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; -}; - -// Format time for display (e.g., "1:23:45" or "23:45") -export const formatTime = (seconds) => { - if (!seconds || seconds === 0) return '0:00'; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } else { - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } -}; - export const getMovieDisplayTitle = (vodContent) => { return vodContent.content_name; }; From 1a46ca4be449c3a2c86ad19eca1ff593ffa1db9a Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:22:44 -0700 Subject: [PATCH 28/56] Added tests for utils --- .../tables/__tests__/M3uTableUtils.test.jsx | 229 +++++++ .../src/utils/__tests__/dateTimeUtils.test.js | 108 ++++ .../__tests__/VodConnectionCardUtils.test.js | 80 +-- .../utils/tables/ChannelTableStreamsUtils.js | 100 +++ .../utils/tables/OutputProfilesTableUtils.js | 6 + .../ChannelTableStreamsUtils.test.js | 288 +++++++++ .../__tests__/ChannelsTableUtils.test.js | 611 ++++++++++++++++++ .../tables/__tests__/EPGsTableUtils.test.js | 238 +++++++ .../tables/__tests__/LogosTableUtils.test.js | 220 +++++++ .../tables/__tests__/M3UsTableUtils.test.js | 474 ++++++++++++++ .../OutputProfilesTableUtils.test.js | 55 ++ .../__tests__/StreamsTableUtils.test.js | 422 ++++++++++++ 12 files changed, 2756 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx create mode 100644 frontend/src/utils/tables/ChannelTableStreamsUtils.js create mode 100644 frontend/src/utils/tables/OutputProfilesTableUtils.js create mode 100644 frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/LogosTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js create mode 100644 frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js diff --git a/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx new file mode 100644 index 00000000..7ad3a002 --- /dev/null +++ b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from '../M3uTableUtils'; + +vi.mock('@mantine/core', () => ({ + Center: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Text: ({ children, size, name }) => ( + + {children} + + ), +})); + +vi.mock('lucide-react', () => ({ + ArrowUpDown: (props) => ( + + ), + ArrowUpNarrowWide: (props) => ( + + ), + ArrowDownWideNarrow: (props) => ( + + ), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Build a minimal header object that matches what TanStack Table provides */ +const makeHeader = ({ id = 'name', label = 'Name', sortable = true } = {}) => ({ + id, + column: { + columnDef: { + header: label, + sortable, + }, + }, +}); + +// ── makeHeaderCellRenderer ─────────────────────────────────────────────────── + +describe('makeHeaderCellRenderer', () => { + describe('with no active sort', () => { + const sorting = []; + let onSortingChange; + let renderHeader; + + beforeEach(() => { + onSortingChange = vi.fn(); + renderHeader = makeHeaderCellRenderer(sorting, onSortingChange); + }); + + it('renders the column label text', () => { + render(renderHeader(makeHeader({ label: 'Channel' }))); + expect(screen.getByTestId('text')).toHaveTextContent('Channel'); + }); + + it('renders the neutral ArrowUpDown icon when no sort is active', () => { + render(renderHeader(makeHeader())); + expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument(); + }); + + it('does not render a sort icon when column is not sortable', () => { + render(renderHeader(makeHeader({ sortable: false }))); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-narrow-wide') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-down-wide-narrow') + ).not.toBeInTheDocument(); + }); + + it('calls onSortingChange with the header id when icon is clicked', () => { + render(renderHeader(makeHeader({ id: 'title' }))); + fireEvent.click(screen.getByTestId('icon-arrow-up-down')); + expect(onSortingChange).toHaveBeenCalledTimes(1); + expect(onSortingChange).toHaveBeenCalledWith('title'); + }); + + it('sets the data-name attribute on the Text element to the header id', () => { + render(renderHeader(makeHeader({ id: 'status' }))); + expect(screen.getByTestId('text')).toHaveAttribute('data-name', 'status'); + }); + }); + + describe('when sorting asc on the current column (desc: false)', () => { + it('renders ArrowUpNarrowWide icon', () => { + const sorting = [{ id: 'name', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.getByTestId('icon-arrow-up-narrow-wide') + ).toBeInTheDocument(); + }); + + it('does not render ArrowDownWideNarrow or ArrowUpDown', () => { + const sorting = [{ id: 'name', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.queryByTestId('icon-arrow-down-wide-narrow') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + }); + }); + + describe('when sorting desc on the current column (desc: true)', () => { + it('renders ArrowDownWideNarrow icon', () => { + const sorting = [{ id: 'name', desc: true }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.getByTestId('icon-arrow-down-wide-narrow') + ).toBeInTheDocument(); + }); + + it('does not render ArrowUpNarrowWide or ArrowUpDown', () => { + const sorting = [{ id: 'name', desc: true }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect( + screen.queryByTestId('icon-arrow-up-narrow-wide') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-arrow-up-down') + ).not.toBeInTheDocument(); + }); + }); + + describe('when a different column is sorted', () => { + it('renders the neutral ArrowUpDown icon for the unsorted column', () => { + const sorting = [{ id: 'status', desc: false }]; + const renderHeader = makeHeaderCellRenderer(sorting, vi.fn()); + render(renderHeader(makeHeader({ id: 'name' }))); + expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument(); + }); + }); +}); + +// ── makeSortingChangeHandler ───────────────────────────────────────────────── + +describe('makeSortingChangeHandler', () => { + let setSorting; + let onDataSort; + + beforeEach(() => { + setSorting = vi.fn(); + onDataSort = vi.fn(); + }); + + describe('first click on a new column', () => { + it('sets ascending sort (desc: false)', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]); + }); + + it('calls onDataSort with the column and desc: false', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', false); + }); + }); + + describe('second click on the same column (currently asc)', () => { + it('sets descending sort (desc: true)', () => { + const sorting = [{ id: 'name', desc: false }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: true }]); + }); + + it('calls onDataSort with the column and desc: true', () => { + const sorting = [{ id: 'name', desc: false }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', true); + }); + }); + + describe('third click on the same column (currently desc) → clears sort', () => { + it('sets sorting to an empty array', () => { + const sorting = [{ id: 'name', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([]); + }); + + it('does NOT call onDataSort when sorting is cleared', () => { + const sorting = [{ id: 'name', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).not.toHaveBeenCalled(); + }); + }); + + describe('switching to a different column while another is sorted', () => { + it('resets to ascending sort on the new column', () => { + const sorting = [{ id: 'status', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]); + }); + + it('calls onDataSort with the new column and desc: false', () => { + const sorting = [{ id: 'status', desc: true }]; + const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort); + handler('name'); + expect(onDataSort).toHaveBeenCalledWith('name', false); + }); + }); + + describe('always calls setSorting', () => { + it('calls setSorting exactly once per handler invocation', () => { + const handler = makeSortingChangeHandler([], setSorting, onDataSort); + handler('title'); + expect(setSorting).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 5ab6ed48..43ecaaf6 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -787,4 +787,112 @@ describe('dateTimeUtils', () => { expect(dateTimeUtils.getMillisecond(date)).toBe(0); }); }); + + describe('formatDuration', () => { + describe('default precision (hms)', () => { + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0)).toBe('0:00'); + }); + + it('should return "0:00" for falsy input', () => { + expect(dateTimeUtils.formatDuration(null)).toBe('0:00'); + expect(dateTimeUtils.formatDuration(undefined)).toBe('0:00'); + }); + + it('should return custom zeroValue when seconds is 0', () => { + expect(dateTimeUtils.formatDuration(0, { zeroValue: 'N/A' })).toBe( + 'N/A' + ); + }); + + it('should format seconds under 1 minute without hours', () => { + expect(dateTimeUtils.formatDuration(45)).toBe('0:45'); + }); + + it('should format minutes and seconds without hours when under 1 hour', () => { + expect(dateTimeUtils.formatDuration(90)).toBe('1:30'); + }); + + it('should format minutes with padded seconds', () => { + expect(dateTimeUtils.formatDuration(65)).toBe('1:05'); + }); + + it('should format hours when >= 1 hour', () => { + expect(dateTimeUtils.formatDuration(3661)).toBe('01:01:01'); + }); + + it('should pad all segments when hours are present', () => { + expect(dateTimeUtils.formatDuration(3600 + 60 + 5)).toBe('01:01:05'); + }); + + it('should handle exactly 1 hour', () => { + expect(dateTimeUtils.formatDuration(3600)).toBe('01:00:00'); + }); + + it('should handle negative seconds by taking absolute value', () => { + expect(dateTimeUtils.formatDuration(-90)).toBe('1:30'); + }); + + it('should show hours when alwaysShowHours is true even under 1 hour', () => { + expect( + dateTimeUtils.formatDuration(90, { alwaysShowHours: true }) + ).toBe('00:01:30'); + }); + + it('should show hours when alwaysShowHours is true for 0 minutes', () => { + expect( + dateTimeUtils.formatDuration(45, { alwaysShowHours: true }) + ).toBe('00:00:45'); + }); + }); + + describe('precision: hm', () => { + it('should return minutes only when under 1 hour and no alwaysShowHours', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'hm' })).toBe('1'); + }); + + it('should return HH:MM when >= 1 hour', () => { + expect(dateTimeUtils.formatDuration(3661, { precision: 'hm' })).toBe( + '01:01' + ); + }); + + it('should return HH:MM when alwaysShowHours is true', () => { + expect( + dateTimeUtils.formatDuration(90, { + precision: 'hm', + alwaysShowHours: true, + }) + ).toBe('00:01'); + }); + + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0, { precision: 'hm' })).toBe( + '0:00' + ); + }); + }); + + describe('precision: m', () => { + it('should return total minutes as integer', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'm' })).toBe('1'); + }); + + it('should return total minutes across hours', () => { + expect(dateTimeUtils.formatDuration(3661, { precision: 'm' })).toBe( + '61' + ); + }); + + it('should truncate partial minutes', () => { + expect(dateTimeUtils.formatDuration(89, { precision: 'm' })).toBe('1'); + }); + + it('should return "0:00" for 0 seconds', () => { + expect(dateTimeUtils.formatDuration(0, { precision: 'm' })).toBe( + '0:00' + ); + }); + }); + }); }); diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js index 489b781a..0f266693 100644 --- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js @@ -2,87 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as VodConnectionCardUtils from '../VodConnectionCardUtils'; import * as dateTimeUtils from '../../dateTimeUtils.js'; -vi.mock('../../dateTimeUtils.js'); +vi.mock('../../dateTimeUtils.js', () => ({ + getNowMs: vi.fn(), + format: vi.fn(), + toFriendlyDuration: vi.fn(), +})); describe('VodConnectionCardUtils', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('formatDuration', () => { - it('should format duration with hours and minutes when hours > 0', () => { - const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s - expect(result).toBe('1h 1m'); - }); - - it('should format duration with only minutes when less than an hour', () => { - const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s - expect(result).toBe('2m'); - }); - - it('should format duration with 0 minutes when less than 60 seconds', () => { - const result = VodConnectionCardUtils.formatDuration(45); - expect(result).toBe('0m'); - }); - - it('should handle multiple hours correctly', () => { - const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s - expect(result).toBe('2h 2m'); - }); - - it('should return Unknown for zero seconds', () => { - const result = VodConnectionCardUtils.formatDuration(0); - expect(result).toBe('Unknown'); - }); - - it('should return Unknown for null', () => { - const result = VodConnectionCardUtils.formatDuration(null); - expect(result).toBe('Unknown'); - }); - - it('should return Unknown for undefined', () => { - const result = VodConnectionCardUtils.formatDuration(undefined); - expect(result).toBe('Unknown'); - }); - }); - - describe('formatTime', () => { - it('should format time with hours when hours > 0', () => { - const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05 - expect(result).toBe('1:01:05'); - }); - - it('should format time without hours when less than an hour', () => { - const result = VodConnectionCardUtils.formatTime(125); // 2:05 - expect(result).toBe('2:05'); - }); - - it('should pad minutes and seconds with zeros', () => { - const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05 - expect(result).toBe('1:00:05'); - }); - - it('should handle only seconds', () => { - const result = VodConnectionCardUtils.formatTime(45); // 0:45 - expect(result).toBe('0:45'); - }); - - it('should return 0:00 for zero seconds', () => { - const result = VodConnectionCardUtils.formatTime(0); - expect(result).toBe('0:00'); - }); - - it('should return 0:00 for null', () => { - const result = VodConnectionCardUtils.formatTime(null); - expect(result).toBe('0:00'); - }); - - it('should return 0:00 for undefined', () => { - const result = VodConnectionCardUtils.formatTime(undefined); - expect(result).toBe('0:00'); - }); - }); - describe('getMovieDisplayTitle', () => { it('should return content_name from vodContent', () => { const vodContent = { content_name: 'The Matrix' }; diff --git a/frontend/src/utils/tables/ChannelTableStreamsUtils.js b/frontend/src/utils/tables/ChannelTableStreamsUtils.js new file mode 100644 index 00000000..8fdbc7a7 --- /dev/null +++ b/frontend/src/utils/tables/ChannelTableStreamsUtils.js @@ -0,0 +1,100 @@ +import API from '../../api.js'; +import { formatBytes } from '../networkUtils.js'; +import { formatDuration } from '../dateTimeUtils.js'; + +const categoryMapping = { + basic: [ + 'resolution', + 'video_codec', + 'source_fps', + 'audio_codec', + 'audio_channels', + ], + video: [ + 'video_bitrate', + 'pixel_format', + 'width', + 'height', + 'aspect_ratio', + 'frame_rate', + ], + audio: [ + 'audio_bitrate', + 'sample_rate', + 'audio_format', + 'audio_channels_layout', + ], + technical: [ + 'stream_type', + 'container_format', + 'duration', + 'file_size', + 'ffmpeg_output_bitrate', + 'input_bitrate', + ], + other: [], +}; + +export const categorizeStreamStats = (stats) => { + if (!stats) + return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; + + const categories = { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }; + + Object.entries(stats).forEach(([key, value]) => { + let categorized = false; + for (const [category, keys] of Object.entries(categoryMapping)) { + if (keys.includes(key)) { + categories[category][key] = value; + categorized = true; + break; + } + } + if (!categorized) { + categories.other[key] = value; + } + }); + + return categories; +}; + +export const formatStatValue = (key, value) => { + if (value === null || value === undefined) return 'N/A'; + + switch (key) { + case 'video_bitrate': + case 'audio_bitrate': + case 'ffmpeg_output_bitrate': + return `${value} kbps`; + case 'source_fps': + case 'frame_rate': + return `${value} fps`; + case 'sample_rate': + return `${value} Hz`; + case 'file_size': + return typeof value === 'number' ? formatBytes(value) : value; + case 'duration': + return typeof value === 'number' + ? formatDuration(value, { alwaysShowHours: true }) + : value; + default: + return value.toString(); + } +}; + +export const formatStatKey = (key) => { + return key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +}; + +export const getChannelStreamStats = (channelId, since, ids) => { + return API.getChannelStreamStats(channelId, since, ids); +}; +export const reorderChannelStreams = (channelId, streamIds) => { + return API.reorderChannelStreams(channelId, streamIds); +}; diff --git a/frontend/src/utils/tables/OutputProfilesTableUtils.js b/frontend/src/utils/tables/OutputProfilesTableUtils.js new file mode 100644 index 00000000..dc2761bd --- /dev/null +++ b/frontend/src/utils/tables/OutputProfilesTableUtils.js @@ -0,0 +1,6 @@ +import API from '../../api.js'; + +export const updateOutputProfile = (values) => API.updateOutputProfile(values); +export const deleteOutputProfile = async (id) => { + await API.deleteOutputProfile(id); +}; diff --git a/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js new file mode 100644 index 00000000..f65e7f2c --- /dev/null +++ b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as ChannelTableStreamsUtils from '../ChannelTableStreamsUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + getChannelStreamStats: vi.fn(), + reorderChannelStreams: vi.fn(), + }, +})); + +vi.mock('../../networkUtils.js', () => ({ + formatBytes: vi.fn((bytes) => `${bytes} B`), +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + formatDuration: vi.fn((seconds) => `duration-${seconds}`), +})); + +import API from '../../../api.js'; +import { formatBytes } from '../../networkUtils.js'; +import { formatDuration } from '../../dateTimeUtils.js'; + +describe('ChannelTableStreamsUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── categorizeStreamStats ─────────────────────────────────────────────────── + describe('categorizeStreamStats', () => { + it('returns empty categories for null input', () => { + expect(ChannelTableStreamsUtils.categorizeStreamStats(null)).toEqual({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + + it('returns empty categories for undefined input', () => { + expect(ChannelTableStreamsUtils.categorizeStreamStats(undefined)).toEqual( + { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + } + ); + }); + + it('categorizes basic fields correctly', () => { + const stats = { + resolution: '1920x1080', + video_codec: 'h264', + source_fps: 30, + audio_codec: 'aac', + audio_channels: 2, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.basic).toEqual(stats); + expect(result.video).toEqual({}); + expect(result.audio).toEqual({}); + expect(result.technical).toEqual({}); + expect(result.other).toEqual({}); + }); + + it('categorizes video fields correctly', () => { + const stats = { + video_bitrate: 5000, + pixel_format: 'yuv420p', + width: 1920, + height: 1080, + aspect_ratio: '16:9', + frame_rate: 29.97, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.video).toEqual(stats); + expect(result.basic).toEqual({}); + }); + + it('categorizes audio fields correctly', () => { + const stats = { + audio_bitrate: 192, + sample_rate: 48000, + audio_format: 'flac', + audio_channels_layout: 'stereo', + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.audio).toEqual(stats); + }); + + it('categorizes technical fields correctly', () => { + const stats = { + stream_type: 'video', + container_format: 'mpegts', + duration: 3600, + file_size: 1024000, + ffmpeg_output_bitrate: 8000, + input_bitrate: 7500, + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.technical).toEqual(stats); + }); + + it('places unknown fields in other', () => { + const stats = { custom_field: 'value', another_field: 42 }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.other).toEqual(stats); + }); + + it('handles mixed fields across categories', () => { + const stats = { + resolution: '1080p', + audio_bitrate: 192, + duration: 7200, + unknown_key: 'test', + }; + const result = ChannelTableStreamsUtils.categorizeStreamStats(stats); + expect(result.basic.resolution).toBe('1080p'); + expect(result.audio.audio_bitrate).toBe(192); + expect(result.technical.duration).toBe(7200); + expect(result.other.unknown_key).toBe('test'); + }); + + it('returns empty categories for empty stats object', () => { + const result = ChannelTableStreamsUtils.categorizeStreamStats({}); + expect(result).toEqual({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + }); + + // ── formatStatValue ───────────────────────────────────────────────────────── + describe('formatStatValue', () => { + it('returns "N/A" for null value', () => { + expect(ChannelTableStreamsUtils.formatStatValue('resolution', null)).toBe( + 'N/A' + ); + }); + + it('returns "N/A" for undefined value', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('resolution', undefined) + ).toBe('N/A'); + }); + + it('formats video_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('video_bitrate', 5000) + ).toBe('5000 kbps'); + }); + + it('formats audio_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('audio_bitrate', 192) + ).toBe('192 kbps'); + }); + + it('formats ffmpeg_output_bitrate with kbps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('ffmpeg_output_bitrate', 8000) + ).toBe('8000 kbps'); + }); + + it('formats source_fps with fps', () => { + expect(ChannelTableStreamsUtils.formatStatValue('source_fps', 30)).toBe( + '30 fps' + ); + }); + + it('formats frame_rate with fps', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('frame_rate', 29.97) + ).toBe('29.97 fps'); + }); + + it('formats sample_rate with Hz', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('sample_rate', 48000) + ).toBe('48000 Hz'); + }); + + it('formats file_size using formatBytes when numeric', () => { + vi.mocked(formatBytes).mockReturnValue('1.0 MB'); + expect( + ChannelTableStreamsUtils.formatStatValue('file_size', 1048576) + ).toBe('1.0 MB'); + expect(formatBytes).toHaveBeenCalledWith(1048576); + }); + + it('returns raw value for file_size when not numeric', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('file_size', 'unknown') + ).toBe('unknown'); + expect(formatBytes).not.toHaveBeenCalled(); + }); + + it('formats duration using formatDuration with alwaysShowHours when numeric', () => { + vi.mocked(formatDuration).mockReturnValue('01:00:00'); + expect(ChannelTableStreamsUtils.formatStatValue('duration', 3600)).toBe( + '01:00:00' + ); + expect(formatDuration).toHaveBeenCalledWith(3600, { + alwaysShowHours: true, + }); + }); + + it('returns raw value for duration when not numeric', () => { + expect(ChannelTableStreamsUtils.formatStatValue('duration', 'live')).toBe( + 'live' + ); + expect(formatDuration).not.toHaveBeenCalled(); + }); + + it('converts default values to string', () => { + expect( + ChannelTableStreamsUtils.formatStatValue('resolution', '1920x1080') + ).toBe('1920x1080'); + }); + + it('converts numeric default values to string', () => { + expect(ChannelTableStreamsUtils.formatStatValue('width', 1920)).toBe( + '1920' + ); + }); + }); + + // ── formatStatKey ─────────────────────────────────────────────────────────── + describe('formatStatKey', () => { + it('replaces underscores with spaces and title-cases each word', () => { + expect(ChannelTableStreamsUtils.formatStatKey('video_bitrate')).toBe( + 'Video Bitrate' + ); + }); + + it('handles single word keys', () => { + expect(ChannelTableStreamsUtils.formatStatKey('resolution')).toBe( + 'Resolution' + ); + }); + + it('handles multi-word keys', () => { + expect( + ChannelTableStreamsUtils.formatStatKey('audio_channels_layout') + ).toBe('Audio Channels Layout'); + }); + + it('handles already-capitalized keys', () => { + expect(ChannelTableStreamsUtils.formatStatKey('FPS')).toBe('FPS'); + }); + }); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('getChannelStreamStats', () => { + it('calls API.getChannelStreamStats with correct args', () => { + const mockReturn = Promise.resolve({ data: [] }); + vi.mocked(API.getChannelStreamStats).mockReturnValue(mockReturn); + const result = ChannelTableStreamsUtils.getChannelStreamStats( + 'ch-1', + '2024-01-01', + [1, 2] + ); + expect(API.getChannelStreamStats).toHaveBeenCalledWith( + 'ch-1', + '2024-01-01', + [1, 2] + ); + expect(result).toBe(mockReturn); + }); + }); + + describe('reorderChannelStreams', () => { + it('calls API.reorderChannelStreams with correct args', () => { + const mockReturn = Promise.resolve(undefined); + vi.mocked(API.reorderChannelStreams).mockReturnValue(mockReturn); + const result = ChannelTableStreamsUtils.reorderChannelStreams( + 'ch-1', + [3, 1, 2] + ); + expect(API.reorderChannelStreams).toHaveBeenCalledWith('ch-1', [3, 1, 2]); + expect(result).toBe(mockReturn); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js new file mode 100644 index 00000000..c4fc9e3e --- /dev/null +++ b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js @@ -0,0 +1,611 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as ChannelsTableUtils from '../ChannelsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../forms/ChannelUtils.js', () => ({ + normalizeFieldValue: vi.fn((field, value) => { + if (value === '' || value === null || value === undefined) return null; + if (field === 'channel_number') return parseFloat(value); + if (['channel_group_id', 'logo_id', 'epg_data_id', 'stream_profile_id'].includes(field)) { + return parseInt(value, 10); + } + return value; + }), + OVERRIDABLE_FIELDS: [ + 'name', + 'channel_number', + 'channel_group_id', + 'logo_id', + 'tvg_id', + 'tvc_guide_stationid', + 'epg_data_id', + 'stream_profile_id', + ], +})); + +vi.mock('../../../api.js', () => ({ + default: { + reorderChannel: vi.fn(), + deleteChannel: vi.fn(), + deleteChannels: vi.fn(), + queryChannels: vi.fn(), + getAllChannelIds: vi.fn(), + updateProfileChannels: vi.fn(), + updateProfileChannel: vi.fn(), + addChannelProfile: vi.fn(), + deleteChannelProfile: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('ChannelsTableUtils', () => { + // ── buildInlinePatch ──────────────────────────────────────────────────────── + describe('buildInlinePatch', () => { + describe('manual channel (auto_created = false)', () => { + const row = { id: 1, auto_created: false, name: 'CNN' }; + + it('returns direct patch for a string field', () => { + expect(ChannelsTableUtils.buildInlinePatch(row, 'name', 'BBC')).toEqual( + { + id: 1, + name: 'BBC', + } + ); + }); + + it('normalizes empty string to null', () => { + expect(ChannelsTableUtils.buildInlinePatch(row, 'name', '')).toEqual({ + id: 1, + name: null, + }); + }); + + it('normalizes undefined to null', () => { + expect( + ChannelsTableUtils.buildInlinePatch(row, 'name', undefined) + ).toEqual({ + id: 1, + name: null, + }); + }); + + it('passes through numeric values', () => { + expect( + ChannelsTableUtils.buildInlinePatch(row, 'channel_number', 5) + ).toEqual({ + id: 1, + channel_number: 5, + }); + }); + }); + + describe('auto-synced channel (auto_created = true)', () => { + const row = { + id: 2, + auto_created: true, + name: 'ESPN', + channel_number: 10, + epg_data_id: 99, + }; + + it('returns override patch when value differs from provider', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'name', + 'ESPN HD' + ); + expect(result).toEqual({ + id: 2, + override: { name: 'ESPN HD' }, + }); + }); + + it('clears override when new value matches provider value', () => { + const result = ChannelsTableUtils.buildInlinePatch(row, 'name', 'ESPN'); + expect(result).toEqual({ + id: 2, + override: { name: null }, + }); + }); + + it('returns override patch for channel_number field', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'channel_number', + 20 + ); + expect(result).toEqual({ + id: 2, + override: { channel_number: 20 }, + }); + }); + + it('clears override for channel_number when value matches provider', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'channel_number', + 10 + ); + expect(result).toEqual({ + id: 2, + override: { channel_number: null }, + }); + }); + + it('uses direct patch for non-overridable field on auto-synced channel', () => { + const result = ChannelsTableUtils.buildInlinePatch( + row, + 'some_other_field', + 'value' + ); + expect(result).toEqual({ + id: 2, + some_other_field: 'value', + }); + }); + }); + }); + + // ── getEpgOptions ─────────────────────────────────────────────────────────── + describe('getEpgOptions', () => { + const epgs = { + 1: { id: 1, name: 'EPG Alpha' }, + 2: { id: 2, name: 'EPG Beta' }, + }; + + const tvgsById = { + 10: { id: 10, tvg_id: 'cnn', name: 'CNN', epg_source: 1 }, + 11: { id: 11, tvg_id: 'bbc', name: 'BBC', epg_source: 2 }, + 12: { id: 12, tvg_id: 'espn', name: 'ESPN', epg_source: 1 }, + }; + + it('includes "Not Assigned" as the first option', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + expect(options[0]).toEqual({ value: 'null', label: 'Not Assigned' }); + }); + + it('returns an option for each tvg entry', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + expect(options).toHaveLength(4); // 1 null + 3 tvgs + }); + + it('formats label as "EPG Name | tvg_id | tvg name" when all present and name differs from tvg_id', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + const cnn = options.find((o) => o.value === '10'); + expect(cnn?.label).toBe('EPG Alpha | cnn | CNN'); + }); + + it('omits tvg name from label when name equals tvg_id', () => { + const tvgs = { + 10: { id: 10, tvg_id: 'CNN', name: 'CNN', epg_source: 1 }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, epgs); + const opt = options.find((o) => o.value === '10'); + expect(opt?.label).toBe('EPG Alpha | CNN'); + }); + + it('uses tvg name as label when no epg_source and no tvg_id', () => { + const tvgs = { + 20: { id: 20, tvg_id: null, name: 'Standalone', epg_source: null }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, {}); + const opt = options.find((o) => o.value === '20'); + expect(opt?.label).toBe('Standalone'); + }); + + it('falls back to "ID: {id}" when no name and no tvg_id', () => { + const tvgs = { + 30: { id: 30, tvg_id: null, name: null, epg_source: null }, + }; + const options = ChannelsTableUtils.getEpgOptions(tvgs, {}); + const opt = options.find((o) => o.value === '30'); + expect(opt?.label).toBe('ID: 30'); + }); + + it('sorts options by EPG source name then tvg_id', () => { + const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs); + // EPG Alpha entries (cnn, espn) should come before EPG Beta (bbc) + const labels = options.slice(1).map((o) => o.label); + const alphaCnn = labels.findIndex((l) => l.includes('cnn')); + const alphaEspn = labels.findIndex((l) => l.includes('espn')); + const betaBbc = labels.findIndex((l) => l.includes('bbc')); + expect(alphaCnn).toBeLessThan(betaBbc); + expect(alphaEspn).toBeLessThan(betaBbc); + }); + + it('returns only the null option for empty tvgsById', () => { + const options = ChannelsTableUtils.getEpgOptions({}, epgs); + expect(options).toHaveLength(1); + expect(options[0].value).toBe('null'); + }); + }); + + // ── getLogoOptions ────────────────────────────────────────────────────────── + describe('getLogoOptions', () => { + const logos = { + 1: { id: 1, name: 'ABC Logo' }, + 2: { id: 2, name: 'NBC Logo' }, + 3: { id: 3, name: null }, + }; + + it('includes "Default" as the first option', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + expect(options[0]).toEqual({ + value: 'null', + label: 'Default', + logo: null, + }); + }); + + it('returns an option for each logo', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + expect(options).toHaveLength(4); // 1 default + 3 logos + }); + + it('uses logo name as label', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const abc = options.find((o) => o.value === '1'); + expect(abc?.label).toBe('ABC Logo'); + expect(abc?.logo).toEqual({ id: 1, name: 'ABC Logo' }); + }); + + it('falls back to "Logo {id}" when name is null', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const noName = options.find((o) => o.value === '3'); + expect(noName?.label).toBe('Logo 3'); + }); + + it('sorts logos by name', () => { + const options = ChannelsTableUtils.getLogoOptions(logos); + const names = options.slice(2).map((o) => o.label); + expect(names[0]).toBe('ABC Logo'); + expect(names[1]).toBe('NBC Logo'); + }); + + it('returns only the default option for empty logos', () => { + const options = ChannelsTableUtils.getLogoOptions({}); + expect(options).toHaveLength(1); + }); + }); + + // ── buildM3UUrl ───────────────────────────────────────────────────────────── + describe('buildM3UUrl', () => { + const baseUrl = 'http://localhost/output/m3u'; + const defaults = { + cachedlogos: true, + direct: false, + tvg_id_source: 'channel_number', + output_format: '', + output_profile: '', + }; + + it('returns base URL with no params when all defaults', () => { + expect(ChannelsTableUtils.buildM3UUrl(defaults, baseUrl)).toBe(baseUrl); + }); + + it('appends cachedlogos=false when disabled', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, cachedlogos: false }, + baseUrl + ); + expect(result).toContain('cachedlogos=false'); + }); + + it('appends direct=true when enabled', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, direct: true }, + baseUrl + ); + expect(result).toContain('direct=true'); + }); + + it('appends tvg_id_source when not channel_number', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, tvg_id_source: 'tvg_id' }, + baseUrl + ); + expect(result).toContain('tvg_id_source=tvg_id'); + }); + + it('does not append tvg_id_source when channel_number', () => { + const result = ChannelsTableUtils.buildM3UUrl(defaults, baseUrl); + expect(result).not.toContain('tvg_id_source'); + }); + + it('appends output_format when set', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, output_format: 'mpegts' }, + baseUrl + ); + expect(result).toContain('output_format=mpegts'); + }); + + it('appends output_profile when set', () => { + const result = ChannelsTableUtils.buildM3UUrl( + { ...defaults, output_profile: '3' }, + baseUrl + ); + expect(result).toContain('output_profile=3'); + }); + }); + + // ── buildEPGUrl ───────────────────────────────────────────────────────────── + describe('buildEPGUrl', () => { + const baseUrl = 'http://localhost/output/epg'; + const defaults = { + cachedlogos: true, + tvg_id_source: 'channel_number', + days: 0, + prev_days: 0, + }; + + it('returns base URL with no params when all defaults', () => { + expect(ChannelsTableUtils.buildEPGUrl(defaults, baseUrl)).toBe(baseUrl); + }); + + it('appends cachedlogos=false when disabled', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, cachedlogos: false }, + baseUrl + ); + expect(result).toContain('cachedlogos=false'); + }); + + it('appends tvg_id_source when not channel_number', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, tvg_id_source: 'gracenote' }, + baseUrl + ); + expect(result).toContain('tvg_id_source=gracenote'); + }); + + it('appends days when > 0', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, days: 7 }, + baseUrl + ); + expect(result).toContain('days=7'); + }); + + it('does not append days when 0', () => { + const result = ChannelsTableUtils.buildEPGUrl(defaults, baseUrl); + expect(result).not.toContain('days'); + }); + + it('appends prev_days when > 0', () => { + const result = ChannelsTableUtils.buildEPGUrl( + { ...defaults, prev_days: 3 }, + baseUrl + ); + expect(result).toContain('prev_days=3'); + }); + }); + + // ── buildHDHRUrl ──────────────────────────────────────────────────────────── + describe('buildHDHRUrl', () => { + it('returns hdhrUrl unchanged when no output profile', () => { + expect(ChannelsTableUtils.buildHDHRUrl('', 'http://localhost/hdhr')).toBe( + 'http://localhost/hdhr' + ); + }); + + it('appends output_profile segment when profile id provided', () => { + expect( + ChannelsTableUtils.buildHDHRUrl('2', 'http://localhost/hdhr') + ).toBe('http://localhost/hdhr/output_profile/2'); + }); + + it('strips trailing slash before appending', () => { + expect( + ChannelsTableUtils.buildHDHRUrl('1', 'http://localhost/hdhr/') + ).toBe('http://localhost/hdhr/output_profile/1'); + }); + }); + + // ── buildFetchParams ──────────────────────────────────────────────────────── + describe('buildFetchParams', () => { + const defaults = { + pagination: { pageIndex: 0, pageSize: 50 }, + sorting: [], + debouncedFilters: {}, + selectedProfileId: '0', + showDisabled: false, + showOnlyStreamlessChannels: false, + showOnlyStaleChannels: false, + showOnlyOverriddenChannels: false, + visibilityFilter: 'active', + }; + + it('always includes page, page_size, and include_streams', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('page')).toBe('1'); + expect(params.get('page_size')).toBe('50'); + expect(params.get('include_streams')).toBe('true'); + }); + + it('increments page by 1 from pageIndex', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + pagination: { pageIndex: 2, pageSize: 25 }, + }); + expect(params.get('page')).toBe('3'); + expect(params.get('page_size')).toBe('25'); + }); + + it('does not include channel_profile_id when selectedProfileId is "0"', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('channel_profile_id')).toBeNull(); + }); + + it('includes channel_profile_id when selectedProfileId is not "0"', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + selectedProfileId: '3', + }); + expect(params.get('channel_profile_id')).toBe('3'); + }); + + it('includes show_disabled when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showDisabled: true, + }); + expect(params.get('show_disabled')).toBe('true'); + }); + + it('includes only_streamless when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyStreamlessChannels: true, + }); + expect(params.get('only_streamless')).toBe('true'); + }); + + it('includes only_stale when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyStaleChannels: true, + }); + expect(params.get('only_stale')).toBe('true'); + }); + + it('includes only_has_overrides when true', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + showOnlyOverriddenChannels: true, + }); + expect(params.get('only_has_overrides')).toBe('true'); + }); + + it('does not include visibility_filter when "active"', () => { + const params = ChannelsTableUtils.buildFetchParams(defaults); + expect(params.get('visibility_filter')).toBeNull(); + }); + + it('includes visibility_filter when not "active"', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + visibilityFilter: 'hidden', + }); + expect(params.get('visibility_filter')).toBe('hidden'); + }); + + it('includes ordering with ascending sort', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'name', desc: false }], + }); + expect(params.get('ordering')).toBe('name'); + }); + + it('includes ordering with descending sort', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'name', desc: true }], + }); + expect(params.get('ordering')).toBe('-name'); + }); + + it('maps channel_group sort field to channel_group__name', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'channel_group', desc: false }], + }); + expect(params.get('ordering')).toBe('channel_group__name'); + }); + + it('maps epg sort field to epg_data__name', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + sorting: [{ id: 'epg', desc: false }], + }); + expect(params.get('ordering')).toBe('epg_data__name'); + }); + + it('applies string debounced filters', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { name: 'CNN' }, + }); + expect(params.get('name')).toBe('CNN'); + }); + + it('applies array debounced filters joined by comma', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { channel_group: ['News', 'Sports'] }, + }); + expect(params.get('channel_group')).toBe('News,Sports'); + }); + + it('converts null values in array filters to "null" string', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { epg: [null, 'SomeEPG'] }, + }); + expect(params.get('epg')).toBe('null,SomeEPG'); + }); + + it('skips falsy debounced filter values', () => { + const params = ChannelsTableUtils.buildFetchParams({ + ...defaults, + debouncedFilters: { name: '' }, + }); + expect(params.get('name')).toBeNull(); + }); + }); + + // ── API wrapper functions ─────────────────────────────────────────────────── + describe('API wrappers', () => { + beforeEach(() => vi.clearAllMocks()); + + it('reorderChannel calls API.reorderChannel', () => { + ChannelsTableUtils.reorderChannel(1, 2); + expect(API.reorderChannel).toHaveBeenCalledWith(1, 2); + }); + + it('deleteChannel calls API.deleteChannel', () => { + ChannelsTableUtils.deleteChannel(5); + expect(API.deleteChannel).toHaveBeenCalledWith(5); + }); + + it('deleteChannels calls API.deleteChannels', () => { + ChannelsTableUtils.deleteChannels([1, 2, 3]); + expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3]); + }); + + it('queryChannels calls API.queryChannels', () => { + const params = new URLSearchParams(); + ChannelsTableUtils.queryChannels(params); + expect(API.queryChannels).toHaveBeenCalledWith(params); + }); + + it('getAllChannelIds calls API.getAllChannelIds', () => { + const params = new URLSearchParams(); + ChannelsTableUtils.getAllChannelIds(params); + expect(API.getAllChannelIds).toHaveBeenCalledWith(params); + }); + + it('updateProfileChannels calls API.updateProfileChannels', () => { + ChannelsTableUtils.updateProfileChannels([1, 2], '3', true); + expect(API.updateProfileChannels).toHaveBeenCalledWith([1, 2], '3', true); + }); + + it('updateProfileChannel calls API.updateProfileChannel', () => { + ChannelsTableUtils.updateProfileChannel(1, '3', false); + expect(API.updateProfileChannel).toHaveBeenCalledWith(1, '3', false); + }); + + it('addChannelProfile calls API.addChannelProfile', () => { + const values = { name: 'Test Profile' }; + ChannelsTableUtils.addChannelProfile(values); + expect(API.addChannelProfile).toHaveBeenCalledWith(values); + }); + + it('deleteChannelProfile calls API.deleteChannelProfile', () => { + ChannelsTableUtils.deleteChannelProfile(4); + expect(API.deleteChannelProfile).toHaveBeenCalledWith(4); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js new file mode 100644 index 00000000..7dd0b8dc --- /dev/null +++ b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as EPGsTableUtils from '../EPGsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + updateEPG: vi.fn(), + deleteEPG: vi.fn(), + refreshEPG: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('EPGsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── formatStatusText ──────────────────────────────────────────────────────── + describe('formatStatusText', () => { + it('returns "Unknown" for null', () => { + expect(EPGsTableUtils.formatStatusText(null)).toBe('Unknown'); + }); + + it('returns "Unknown" for undefined', () => { + expect(EPGsTableUtils.formatStatusText(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(EPGsTableUtils.formatStatusText('')).toBe('Unknown'); + }); + + it('capitalizes first letter and lowercases the rest', () => { + expect(EPGsTableUtils.formatStatusText('idle')).toBe('Idle'); + expect(EPGsTableUtils.formatStatusText('success')).toBe('Success'); + expect(EPGsTableUtils.formatStatusText('error')).toBe('Error'); + }); + + it('handles already-uppercase input', () => { + expect(EPGsTableUtils.formatStatusText('FETCHING')).toBe('Fetching'); + }); + + it('handles mixed case input', () => { + expect(EPGsTableUtils.formatStatusText('pArSiNg')).toBe('Parsing'); + }); + }); + + // ── updateEpg ─────────────────────────────────────────────────────────────── + describe('updateEpg', () => { + it('calls API.updateEPG with merged values and id', async () => { + API.updateEPG.mockResolvedValue({ id: 1 }); + const epg = { id: 1, name: 'Old Name' }; + await EPGsTableUtils.updateEpg({ is_active: false }, epg, false); + expect(API.updateEPG).toHaveBeenCalledWith( + { is_active: false, id: 1 }, + false + ); + }); + + it('passes isToggle=true to API.updateEPG', async () => { + API.updateEPG.mockResolvedValue({ id: 2 }); + const epg = { id: 2 }; + await EPGsTableUtils.updateEpg({ is_active: true }, epg, true); + expect(API.updateEPG).toHaveBeenCalledWith( + { is_active: true, id: 2 }, + true + ); + }); + + it('returns the API response', async () => { + const mockResponse = { id: 3, name: 'Updated' }; + API.updateEPG.mockResolvedValue(mockResponse); + const result = await EPGsTableUtils.updateEpg({}, { id: 3 }, false); + expect(result).toBe(mockResponse); + }); + + it('propagates API errors', async () => { + API.updateEPG.mockRejectedValue(new Error('Network error')); + await expect( + EPGsTableUtils.updateEpg({}, { id: 1 }, false) + ).rejects.toThrow('Network error'); + }); + }); + + // ── deleteEpg ─────────────────────────────────────────────────────────────── + describe('deleteEpg', () => { + it('calls API.deleteEPG with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteEPG.mockReturnValue(mockReturn); + const result = EPGsTableUtils.deleteEpg(5); + expect(API.deleteEPG).toHaveBeenCalledWith(5); + expect(result).toBe(mockReturn); + }); + }); + + // ── refreshEpg ────────────────────────────────────────────────────────────── + describe('refreshEpg', () => { + it('calls API.refreshEPG with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.refreshEPG.mockReturnValue(mockReturn); + const result = EPGsTableUtils.refreshEpg(7); + expect(API.refreshEPG).toHaveBeenCalledWith(7, expect.anything()); + expect(result).toBe(mockReturn); + }); + }); + + // ── getProgressLabel ──────────────────────────────────────────────────────── + describe('getProgressLabel', () => { + it('returns "Downloading" for downloading action', () => { + expect(EPGsTableUtils.getProgressLabel('downloading')).toBe( + 'Downloading' + ); + }); + + it('returns "Extracting" for extracting action', () => { + expect(EPGsTableUtils.getProgressLabel('extracting')).toBe('Extracting'); + }); + + it('returns "Parsing Channels" for parsing_channels action', () => { + expect(EPGsTableUtils.getProgressLabel('parsing_channels')).toBe( + 'Parsing Channels' + ); + }); + + it('returns "Parsing Programs" for parsing_programs action', () => { + expect(EPGsTableUtils.getProgressLabel('parsing_programs')).toBe( + 'Parsing Programs' + ); + }); + + it('returns null for unknown action', () => { + expect(EPGsTableUtils.getProgressLabel('unknown_action')).toBeNull(); + }); + + it('returns null for null action', () => { + expect(EPGsTableUtils.getProgressLabel(null)).toBeNull(); + }); + + it('returns null for undefined action', () => { + expect(EPGsTableUtils.getProgressLabel(undefined)).toBeNull(); + }); + }); + + // ── getProgressInfo ───────────────────────────────────────────────────────── + describe('getProgressInfo', () => { + it('returns message when progress.message is set', () => { + expect( + EPGsTableUtils.getProgressInfo({ message: 'Loading data...' }) + ).toBe('Loading data...'); + }); + + it('returns programs/channels string when processed and channels are set', () => { + expect( + EPGsTableUtils.getProgressInfo({ processed: 1500, channels: 42 }) + ).toBe('1,500 programs for 42 channels'); + }); + + it('prefers message over processed/channels', () => { + expect( + EPGsTableUtils.getProgressInfo({ + message: 'Custom message', + processed: 100, + channels: 5, + }) + ).toBe('Custom message'); + }); + + it('returns processed/total string when processed and total are set without channels', () => { + expect( + EPGsTableUtils.getProgressInfo({ processed: 250, total: 1000 }) + ).toBe('250 / 1,000'); + }); + + it('returns null when no relevant fields are present', () => { + expect(EPGsTableUtils.getProgressInfo({})).toBeNull(); + }); + + it('returns null when only processed is set without channels or total', () => { + expect(EPGsTableUtils.getProgressInfo({ processed: 100 })).toBeNull(); + }); + + it('formats large numbers with locale separators', () => { + const result = EPGsTableUtils.getProgressInfo({ + processed: 1000000, + total: 5000000, + }); + expect(result).toBe('1,000,000 / 5,000,000'); + }); + }); + + // ── getSortedEpgs ─────────────────────────────────────────────────────────── + describe('getSortedEpgs', () => { + const epgs = [ + { id: 1, name: 'Zebra EPG', is_active: true }, + { id: 2, name: 'Alpha EPG', is_active: false }, + { id: 3, name: 'Middle EPG', is_active: true }, + ]; + + it('sorts ascending when compareDesc is false', () => { + const result = EPGsTableUtils.getSortedEpgs( + [...epgs], + 'is_active', + false + ); + // active=false comes first in ascending (false < true) + expect(result[0].id).toBe(2); + }); + + it('sorts descending when compareDesc is true', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'is_active', true); + // active=true comes first in descending + expect(result[0].is_active).toBe(true); + }); + + it('returns items in original relative order when values are equal', () => { + const items = [ + { id: 1, name: 'Same', is_active: true }, + { id: 2, name: 'Same', is_active: true }, + ]; + const result = EPGsTableUtils.getSortedEpgs([...items], 'name', false); + expect(result[0].id).toBe(1); + expect(result[1].id).toBe(2); + }); + + it('sorts by name field ascending', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', false); + expect(result[0].name).toBe('Alpha EPG'); + }); + + it('sorts by name field descending', () => { + const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', true); + expect(result[0].name).toBe('Zebra EPG'); + }); + + it('returns an empty array for empty input', () => { + expect(EPGsTableUtils.getSortedEpgs([], 'name', false)).toEqual([]); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js new file mode 100644 index 00000000..332f198a --- /dev/null +++ b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as LogosTableUtils from '../LogosTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + deleteLogo: vi.fn(), + deleteLogos: vi.fn(), + cleanupUnusedLogos: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('LogosTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── getFilteredLogos ──────────────────────────────────────────────────────── + describe('getFilteredLogos', () => { + const logos = { + 1: { id: 1, name: 'ABC Logo', is_used: true }, + 2: { id: 2, name: 'NBC Logo', is_used: false }, + 3: { id: 3, name: 'CBS Logo', is_used: true }, + 4: { id: 4, name: 'abc sports', is_used: false }, + }; + + it('returns all logos sorted by id when no filters applied', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result.map((l) => l.id)).toEqual([1, 2, 3, 4]); + }); + + it('returns empty array for null logos', () => { + expect(LogosTableUtils.getFilteredLogos(null, '', 'all')).toEqual([]); + }); + + it('returns empty array for undefined logos', () => { + expect(LogosTableUtils.getFilteredLogos(undefined, '', 'all')).toEqual( + [] + ); + }); + + it('filters by name case-insensitively', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'all'); + expect(result).toHaveLength(2); + expect(result.map((l) => l.id)).toEqual([1, 4]); + }); + + it('filters by exact name match', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'NBC Logo', 'all'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + + it('returns empty array when name filter matches nothing', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'xyz', 'all'); + expect(result).toHaveLength(0); + }); + + it('filters to used logos only when filtersUsed is "used"', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'used'); + expect(result.every((l) => l.is_used)).toBe(true); + expect(result).toHaveLength(2); + }); + + it('filters to unused logos only when filtersUsed is "unused"', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'unused'); + expect(result.every((l) => !l.is_used)).toBe(true); + expect(result).toHaveLength(2); + }); + + it('applies name filter and used filter together', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'used'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it('applies name filter and unused filter together', () => { + const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'unused'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(4); + }); + + it('sorts results by id ascending', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + for (let i = 1; i < result.length; i++) { + expect(result[i].id).toBeGreaterThan(result[i - 1].id); + } + }); + + it('returns empty array when logos object is empty', () => { + expect(LogosTableUtils.getFilteredLogos({}, '', 'all')).toEqual([]); + }); + + it('does not filter when debouncedNameFilter is empty string', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + }); + + it('does not apply usage filter for unrecognized filtersUsed value', () => { + const result = LogosTableUtils.getFilteredLogos(logos, '', 'all'); + expect(result).toHaveLength(4); + }); + }); + + // ── deleteLogo ────────────────────────────────────────────────────────────── + describe('deleteLogo', () => { + it('calls API.deleteLogo with id and deleteFile', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteLogo.mockReturnValue(mockReturn); + const result = LogosTableUtils.deleteLogo(5, true); + expect(API.deleteLogo).toHaveBeenCalledWith(5, true); + expect(result).toBe(mockReturn); + }); + + it('passes deleteFile=false correctly', () => { + LogosTableUtils.deleteLogo(3, false); + expect(API.deleteLogo).toHaveBeenCalledWith(3, false); + }); + }); + + // ── deleteLogos ───────────────────────────────────────────────────────────── + describe('deleteLogos', () => { + it('calls API.deleteLogos with ids and deleteFiles', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteLogos.mockReturnValue(mockReturn); + const result = LogosTableUtils.deleteLogos([1, 2, 3], true); + expect(API.deleteLogos).toHaveBeenCalledWith([1, 2, 3], true); + expect(result).toBe(mockReturn); + }); + + it('passes deleteFiles=false correctly', () => { + LogosTableUtils.deleteLogos([4, 5], false); + expect(API.deleteLogos).toHaveBeenCalledWith([4, 5], false); + }); + }); + + // ── cleanupUnusedLogos ────────────────────────────────────────────────────── + describe('cleanupUnusedLogos', () => { + it('calls API.cleanupUnusedLogos with deleteFiles=true', () => { + const mockReturn = Promise.resolve(undefined); + API.cleanupUnusedLogos.mockReturnValue(mockReturn); + const result = LogosTableUtils.cleanupUnusedLogos(true); + expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(true); + expect(result).toBe(mockReturn); + }); + + it('calls API.cleanupUnusedLogos with deleteFiles=false', () => { + LogosTableUtils.cleanupUnusedLogos(false); + expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(false); + }); + }); + + // ── generateUsageLabel ────────────────────────────────────────────────────── + describe('generateUsageLabel', () => { + describe('single type — channels only', () => { + it('returns singular "channel" for 1 channel', () => { + const names = ['Channel: HBO']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel'); + }); + + it('returns plural "channels" for multiple channels', () => { + const names = ['Channel: HBO', 'Channel: CNN', 'Channel: ESPN']; + expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 channels'); + }); + }); + + describe('single type — movies only', () => { + it('returns singular "movie" for 1 movie', () => { + const names = ['Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 movie'); + }); + + it('returns plural "movies" for multiple movies', () => { + const names = ['Movie: Inception', 'Movie: Interstellar']; + expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 movies'); + }); + }); + + describe('single type — series only', () => { + it('returns "series" for 1 series', () => { + const names = ['Series: Breaking Bad']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 series'); + }); + + it('returns "series" for multiple series', () => { + const names = ['Series: Breaking Bad', 'Series: The Wire']; + expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 series'); + }); + }); + + describe('multiple types — generic items', () => { + it('returns singular "item" when channelCount is 1', () => { + const names = ['Channel: HBO', 'Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 item'); + }); + + it('returns plural "items" when channelCount > 1', () => { + const names = ['Channel: HBO', 'Movie: Inception', 'Series: Lost']; + expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 items'); + }); + + it('uses channelCount not array length for generic label', () => { + const names = ['Channel: HBO', 'Movie: Inception']; + expect(LogosTableUtils.generateUsageLabel(names, 5)).toBe('5 items'); + }); + }); + + describe('edge cases', () => { + it('returns "0 items" for empty names array (no types match)', () => { + expect(LogosTableUtils.generateUsageLabel([], 0)).toBe('0 items'); + }); + + it('ignores unrecognized name prefixes', () => { + const names = ['Unknown: Foo', 'Channel: HBO']; + expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel'); + }); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js new file mode 100644 index 00000000..de63e0e9 --- /dev/null +++ b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js @@ -0,0 +1,474 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as M3UsTableUtils from '../M3UsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + refreshPlaylist: vi.fn(), + getPlaylistAutoCreatedChannelsCount: vi.fn(), + deletePlaylist: vi.fn(), + updatePlaylist: vi.fn(), + }, +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + format: vi.fn((val, fmt) => `formatted:${fmt}`), + formatDuration: vi.fn((seconds) => `duration:${seconds}`), +})); + +vi.mock('../../networkUtils.js', () => ({ + formatSpeed: vi.fn((speed) => `speed:${speed}`), +})); + +import API from '../../../api.js'; +import { format, formatDuration } from '../../dateTimeUtils.js'; +import { formatSpeed } from '../../networkUtils.js'; + +describe('M3UsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('refreshPlaylist', () => { + it('calls API.refreshPlaylist with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.refreshPlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.refreshPlaylist(3); + expect(API.refreshPlaylist).toHaveBeenCalledWith(3); + expect(result).toBe(mockReturn); + }); + }); + + describe('getPlaylistAutoCreatedChannelsCount', () => { + it('calls API.getPlaylistAutoCreatedChannelsCount with the correct id', () => { + const mockReturn = Promise.resolve({ count: 5 }); + API.getPlaylistAutoCreatedChannelsCount.mockReturnValue(mockReturn); + const result = M3UsTableUtils.getPlaylistAutoCreatedChannelsCount(7); + expect(API.getPlaylistAutoCreatedChannelsCount).toHaveBeenCalledWith(7); + expect(result).toBe(mockReturn); + }); + }); + + describe('deletePlaylist', () => { + it('calls API.deletePlaylist with the correct id', () => { + const mockReturn = Promise.resolve(undefined); + API.deletePlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.deletePlaylist(2); + expect(API.deletePlaylist).toHaveBeenCalledWith(2); + expect(result).toBe(mockReturn); + }); + }); + + describe('updatePlaylist', () => { + it('merges values with playlist id and passes isToggle', () => { + const mockReturn = Promise.resolve({}); + API.updatePlaylist.mockReturnValue(mockReturn); + const result = M3UsTableUtils.updatePlaylist( + { is_active: false }, + { id: 10, name: 'Test' }, + true + ); + expect(API.updatePlaylist).toHaveBeenCalledWith( + { is_active: false, id: 10 }, + true + ); + expect(result).toBe(mockReturn); + }); + + it('defaults isToggle to false when not provided', () => { + API.updatePlaylist.mockReturnValue(Promise.resolve({})); + M3UsTableUtils.updatePlaylist({ name: 'New' }, { id: 5 }); + expect(API.updatePlaylist).toHaveBeenCalledWith( + { name: 'New', id: 5 }, + false + ); + }); + }); + + // ── formatStatusText ──────────────────────────────────────────────────────── + describe('formatStatusText', () => { + it.each([ + ['idle', 'Idle'], + ['fetching', 'Fetching'], + ['parsing', 'Parsing'], + ['error', 'Error'], + ['success', 'Success'], + ['pending_setup', 'Pending Setup'], + ])('returns "%s" for status "%s"', (status, expected) => { + expect(M3UsTableUtils.formatStatusText(status)).toBe(expected); + }); + + it('capitalizes first letter for unknown status', () => { + expect(M3UsTableUtils.formatStatusText('loading')).toBe('Loading'); + }); + + it('returns "Unknown" for null', () => { + expect(M3UsTableUtils.formatStatusText(null)).toBe('Unknown'); + }); + + it('returns "Unknown" for undefined', () => { + expect(M3UsTableUtils.formatStatusText(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(M3UsTableUtils.formatStatusText('')).toBe('Unknown'); + }); + }); + + // ── getStatusColor ────────────────────────────────────────────────────────── + describe('getStatusColor', () => { + it.each([ + ['idle', 'gray.5'], + ['fetching', 'blue.5'], + ['parsing', 'indigo.5'], + ['error', 'red.5'], + ['success', 'green.5'], + ['pending_setup', 'orange.5'], + ])('returns "%s" for status "%s"', (status, expected) => { + expect(M3UsTableUtils.getStatusColor(status)).toBe(expected); + }); + + it('returns "gray.5" for unknown status', () => { + expect(M3UsTableUtils.getStatusColor('unknown')).toBe('gray.5'); + }); + + it('returns "gray.5" for null', () => { + expect(M3UsTableUtils.getStatusColor(null)).toBe('gray.5'); + }); + }); + + // ── getExpirationInfo ─────────────────────────────────────────────────────── + describe('getExpirationInfo', () => { + it('returns red.7 and "Expired" when daysLeft < 0', () => { + const result = M3UsTableUtils.getExpirationInfo( + -1, + '2024-01-01', + 'MM/DD/YYYY' + ); + expect(result).toEqual({ color: 'red.7', label: 'Expired' }); + }); + + it('returns red.5 and "Expires today" when daysLeft === 0', () => { + const result = M3UsTableUtils.getExpirationInfo( + 0, + '2024-06-01', + 'MM/DD/YYYY' + ); + expect(result).toEqual({ color: 'red.5', label: 'Expires today' }); + }); + + it('returns orange.5 and "{n}d left" when daysLeft is 1–7', () => { + expect(M3UsTableUtils.getExpirationInfo(1, null, 'MM/DD/YYYY')).toEqual({ + color: 'orange.5', + label: '1d left', + }); + expect(M3UsTableUtils.getExpirationInfo(7, null, 'MM/DD/YYYY')).toEqual({ + color: 'orange.5', + label: '7d left', + }); + }); + + it('returns yellow.5 and "{n}d left" when daysLeft is 8–30', () => { + expect(M3UsTableUtils.getExpirationInfo(8, null, 'MM/DD/YYYY')).toEqual({ + color: 'yellow.5', + label: '8d left', + }); + expect(M3UsTableUtils.getExpirationInfo(30, null, 'MM/DD/YYYY')).toEqual({ + color: 'yellow.5', + label: '30d left', + }); + }); + + it('returns formatted date label with no color when daysLeft > 30', () => { + format.mockReturnValue('12/31/2024'); + const result = M3UsTableUtils.getExpirationInfo( + 60, + '2024-12-31', + 'MM/DD/YYYY' + ); + expect(format).toHaveBeenCalledWith('2024-12-31', 'MM/DD/YYYY'); + expect(result.label).toBe('12/31/2024'); + expect(result.color).toBeUndefined(); + }); + }); + + // ── getExpirationTooltip ──────────────────────────────────────────────────── + describe('getExpirationTooltip', () => { + it('returns the fallback label when allExpirations is empty', () => { + const result = M3UsTableUtils.getExpirationTooltip( + [], + 'MM/DD/YYYY HH:mm', + '7d left' + ); + expect(result).toBe('7d left'); + }); + + it('formats each expiration entry with profile name and date', () => { + format.mockImplementation(() => `2024-12-31`); + const expirations = [ + { profile_name: 'Profile A', exp_date: '2024-12-31', is_active: true }, + { profile_name: 'Profile B', exp_date: '2024-11-30', is_active: false }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY HH:mm', + 'fallback' + ); + expect(result).toContain('Profile A: 2024-12-31'); + expect(result).toContain('Profile B: 2024-12-31 (inactive)'); + }); + + it('does not append "(inactive)" for active profiles', () => { + format.mockReturnValue('2024-12-31'); + const expirations = [ + { profile_name: 'Active', exp_date: '2024-12-31', is_active: true }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY', + 'fallback' + ); + expect(result).not.toContain('(inactive)'); + }); + + it('joins multiple entries with newline', () => { + format.mockReturnValue('2024-12-31'); + const expirations = [ + { profile_name: 'A', exp_date: '2024-12-31', is_active: true }, + { profile_name: 'B', exp_date: '2024-12-31', is_active: true }, + ]; + const result = M3UsTableUtils.getExpirationTooltip( + expirations, + 'MM/DD/YYYY', + 'fallback' + ); + expect(result.split('\n')).toHaveLength(2); + }); + }); + + // ── getSortedPlaylists ────────────────────────────────────────────────────── + describe('getSortedPlaylists', () => { + const playlists = [ + { id: 1, name: 'Zebra', locked: false, max_streams: 5 }, + { id: 2, name: 'Alpha', locked: false, max_streams: 10 }, + { id: 3, name: 'Middle', locked: true, max_streams: 1 }, + { id: 4, name: 'Beta', locked: false, max_streams: 3 }, + ]; + + it('excludes locked playlists', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'name', + false + ); + expect(result.find((p) => p.id === 3)).toBeUndefined(); + expect(result).toHaveLength(3); + }); + + it('sorts by string column ascending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'name', + false + ); + expect(result.map((p) => p.name)).toEqual(['Alpha', 'Beta', 'Zebra']); + }); + + it('sorts by string column descending', () => { + const result = M3UsTableUtils.getSortedPlaylists(playlists, 'name', true); + expect(result.map((p) => p.name)).toEqual(['Zebra', 'Beta', 'Alpha']); + }); + + it('sorts by numeric column ascending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'max_streams', + false + ); + expect(result.map((p) => p.max_streams)).toEqual([3, 5, 10]); + }); + + it('sorts by numeric column descending', () => { + const result = M3UsTableUtils.getSortedPlaylists( + playlists, + 'max_streams', + true + ); + expect(result.map((p) => p.max_streams)).toEqual([10, 5, 3]); + }); + + it('sorts nulls to the end regardless of direction', () => { + const withNulls = [ + { id: 1, name: null, locked: false }, + { id: 2, name: 'Alpha', locked: false }, + { id: 3, name: null, locked: false }, + ]; + const asc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', false); + expect(asc[asc.length - 1].name).toBeNull(); + expect(asc[asc.length - 2].name).toBeNull(); + + const desc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', true); + expect(desc[desc.length - 1].name).toBeNull(); + }); + + it('returns empty array when all playlists are locked', () => { + const locked = [{ id: 1, name: 'Locked', locked: true }]; + expect(M3UsTableUtils.getSortedPlaylists(locked, 'name', false)).toEqual( + [] + ); + }); + }); + + // ── getStatusContent ──────────────────────────────────────────────────────── + describe('getStatusContent', () => { + it('returns null when progress is 100', () => { + expect( + M3UsTableUtils.getStatusContent({ progress: 100, action: 'parsing' }) + ).toBeNull(); + }); + + it('returns initializing type for initializing action', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 50, + action: 'initializing', + }) + ).toEqual({ type: 'initializing' }); + }); + + describe('downloading', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 0, + action: 'downloading', + }) + ).toEqual({ type: 'simple', label: 'Downloading...' }); + }); + + it('returns downloading object with formatted fields when progress > 0', () => { + formatSpeed.mockReturnValue('speed:512'); + formatDuration.mockReturnValue('duration:30'); + const result = M3UsTableUtils.getStatusContent({ + action: 'downloading', + progress: 50, + speed: 512, + time_remaining: 30, + }); + expect(result).toEqual({ + type: 'downloading', + progress: 50, + speed: 'speed:512', + timeRemaining: 'duration:30', + }); + expect(formatSpeed).toHaveBeenCalledWith(512); + expect(formatDuration).toHaveBeenCalledWith(30); + }); + + it('returns "calculating..." when time_remaining is absent', () => { + formatSpeed.mockReturnValue('speed:512'); + const result = M3UsTableUtils.getStatusContent({ + action: 'downloading', + progress: 25, + speed: 512, + }); + expect(result.timeRemaining).toBe('calculating...'); + }); + }); + + describe('processing_groups', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ + progress: 0, + action: 'processing_groups', + }) + ).toEqual({ type: 'simple', label: 'Processing groups...' }); + }); + + it('returns groups object with formatted elapsed time', () => { + formatDuration.mockReturnValue('duration:120'); + const result = M3UsTableUtils.getStatusContent({ + action: 'processing_groups', + progress: 40, + elapsed_time: 120, + groups_processed: 15, + }); + expect(result).toEqual({ + type: 'groups', + progress: 40, + elapsedTime: 'duration:120', + groupsProcessed: 15, + }); + expect(formatDuration).toHaveBeenCalledWith(120); + }); + }); + + describe('parsing', () => { + it('returns simple label when progress is 0', () => { + expect( + M3UsTableUtils.getStatusContent({ progress: 0, action: 'parsing' }) + ).toEqual({ type: 'simple', label: 'Parsing...' }); + }); + + it('returns parsing object with all fields', () => { + formatDuration.mockReturnValue('duration:60'); + const result = M3UsTableUtils.getStatusContent({ + action: 'parsing', + progress: 75, + elapsed_time: 60, + time_remaining: 20, + streams_processed: 1000, + }); + expect(result).toEqual({ + type: 'parsing', + progress: 75, + elapsedTime: 'duration:60', + timeRemaining: 'duration:60', + streamsProcessed: 1000, + }); + }); + + it('returns "calculating..." when time_remaining is absent', () => { + formatDuration.mockReturnValue('duration:60'); + const result = M3UsTableUtils.getStatusContent({ + action: 'parsing', + progress: 50, + elapsed_time: 60, + }); + expect(result.timeRemaining).toBe('calculating...'); + }); + }); + + describe('default / error', () => { + it('returns error type when status is error', () => { + const result = M3UsTableUtils.getStatusContent({ + action: 'unknown', + progress: 50, + status: 'error', + error: 'Something went wrong', + }); + expect(result).toEqual({ + type: 'error', + error: 'Something went wrong', + }); + }); + + it('returns simple label with action name for unknown non-error action', () => { + const result = M3UsTableUtils.getStatusContent({ + action: 'custom_action', + progress: 50, + status: 'running', + }); + expect(result).toEqual({ type: 'simple', label: 'custom_action...' }); + }); + + it('returns "Processing..." label when action is undefined', () => { + const result = M3UsTableUtils.getStatusContent({ + progress: 30, + status: 'running', + }); + expect(result).toEqual({ type: 'simple', label: 'Processing...' }); + }); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js new file mode 100644 index 00000000..8ef7bf3b --- /dev/null +++ b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as OutputProfilesTableUtils from '../OutputProfilesTableUtils'; + +vi.mock('../../../api.js', () => ({ + default: { + updateOutputProfile: vi.fn(), + deleteOutputProfile: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('OutputProfilesTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + describe('updateOutputProfile', () => { + it('calls API.updateOutputProfile with the provided values', () => { + const mockReturn = Promise.resolve({ id: 1, name: 'Updated' }); + API.updateOutputProfile.mockReturnValue(mockReturn); + const values = { id: 1, name: 'Updated', is_active: true }; + const result = OutputProfilesTableUtils.updateOutputProfile(values); + expect(API.updateOutputProfile).toHaveBeenCalledWith(values); + expect(result).toBe(mockReturn); + }); + + it('passes through the return value from the API', async () => { + API.updateOutputProfile.mockResolvedValue({ id: 2, name: 'Profile' }); + const result = await OutputProfilesTableUtils.updateOutputProfile({ + id: 2, + }); + expect(result).toEqual({ id: 2, name: 'Profile' }); + }); + }); + + describe('deleteOutputProfile', () => { + it('calls API.deleteOutputProfile with the correct id', async () => { + API.deleteOutputProfile.mockResolvedValue(undefined); + await OutputProfilesTableUtils.deleteOutputProfile(5); + expect(API.deleteOutputProfile).toHaveBeenCalledWith(5); + }); + + it('resolves without a return value', async () => { + API.deleteOutputProfile.mockResolvedValue(undefined); + const result = await OutputProfilesTableUtils.deleteOutputProfile(3); + expect(result).toBeUndefined(); + }); + + it('propagates errors thrown by the API', async () => { + API.deleteOutputProfile.mockRejectedValue(new Error('Not found')); + await expect( + OutputProfilesTableUtils.deleteOutputProfile(99) + ).rejects.toThrow('Not found'); + }); + }); +}); diff --git a/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js new file mode 100644 index 00000000..dd6cf8ab --- /dev/null +++ b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as StreamsTableUtils from '../StreamsTableUtils'; + +// ── Dependency mocks ──────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + addStreamsToChannel: vi.fn(), + queryStreamsTable: vi.fn(), + getStreams: vi.fn(), + createChannelsFromStreamsAsync: vi.fn(), + deleteStream: vi.fn(), + deleteStreams: vi.fn(), + requeryStreams: vi.fn(), + createChannelFromStream: vi.fn(), + getAllStreamIds: vi.fn(), + getStreamFilterOptions: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('StreamsTableUtils', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── API wrappers ──────────────────────────────────────────────────────────── + describe('API wrappers', () => { + it('addStreamsToChannel calls API with correct args', () => { + const mockReturn = Promise.resolve(undefined); + API.addStreamsToChannel.mockReturnValue(mockReturn); + const result = StreamsTableUtils.addStreamsToChannel('ch-1', [1], [2, 3]); + expect(API.addStreamsToChannel).toHaveBeenCalledWith('ch-1', [1], [2, 3]); + expect(result).toBe(mockReturn); + }); + + it('queryStreamsTable calls API with params', () => { + const params = new URLSearchParams({ page: '1' }); + const mockReturn = Promise.resolve({ results: [] }); + API.queryStreamsTable.mockReturnValue(mockReturn); + const result = StreamsTableUtils.queryStreamsTable(params); + expect(API.queryStreamsTable).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + + it('getStreams calls API with streamIds', () => { + const mockReturn = Promise.resolve([]); + API.getStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getStreams([10, 20]); + expect(API.getStreams).toHaveBeenCalledWith([10, 20]); + expect(result).toBe(mockReturn); + }); + + it('createChannelsFromStreamsAsync calls API with correct args', () => { + const mockReturn = Promise.resolve(undefined); + API.createChannelsFromStreamsAsync.mockReturnValue(mockReturn); + const result = StreamsTableUtils.createChannelsFromStreamsAsync( + [1, 2], + [3], + 100 + ); + expect(API.createChannelsFromStreamsAsync).toHaveBeenCalledWith( + [1, 2], + [3], + 100 + ); + expect(result).toBe(mockReturn); + }); + + it('deleteStream calls API with id', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteStream.mockReturnValue(mockReturn); + const result = StreamsTableUtils.deleteStream(5); + expect(API.deleteStream).toHaveBeenCalledWith(5); + expect(result).toBe(mockReturn); + }); + + it('deleteStreams calls API with ids', () => { + const mockReturn = Promise.resolve(undefined); + API.deleteStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.deleteStreams([1, 2, 3]); + expect(API.deleteStreams).toHaveBeenCalledWith([1, 2, 3]); + expect(result).toBe(mockReturn); + }); + + it('requeryStreams calls API', () => { + const mockReturn = Promise.resolve(undefined); + API.requeryStreams.mockReturnValue(mockReturn); + const result = StreamsTableUtils.requeryStreams(); + expect(API.requeryStreams).toHaveBeenCalled(); + expect(result).toBe(mockReturn); + }); + + it('createChannelFromStream calls API with values', () => { + const mockReturn = Promise.resolve({ id: 1 }); + API.createChannelFromStream.mockReturnValue(mockReturn); + const values = { name: 'New Channel' }; + const result = StreamsTableUtils.createChannelFromStream(values); + expect(API.createChannelFromStream).toHaveBeenCalledWith(values); + expect(result).toBe(mockReturn); + }); + + it('getAllStreamIds calls API with params', () => { + const params = new URLSearchParams(); + const mockReturn = Promise.resolve([1, 2, 3]); + API.getAllStreamIds.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getAllStreamIds(params); + expect(API.getAllStreamIds).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + + it('getStreamFilterOptions calls API with params', () => { + const params = new URLSearchParams(); + const mockReturn = Promise.resolve({}); + API.getStreamFilterOptions.mockReturnValue(mockReturn); + const result = StreamsTableUtils.getStreamFilterOptions(params); + expect(API.getStreamFilterOptions).toHaveBeenCalledWith(params); + expect(result).toBe(mockReturn); + }); + }); + + // ── getStatsTooltip ───────────────────────────────────────────────────────── + describe('getStatsTooltip', () => { + it('returns "-" compact display for empty stats', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({}); + expect(compactDisplay).toBe('-'); + }); + + it('returns "No source info available" tooltip for empty stats', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({}); + expect(tooltipContent).toBe('No source info available'); + }); + + it('converts resolution "1920x1080" to "1080p" in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + }); + expect(compactDisplay).toBe('1080p'); + }); + + it('converts resolution "1280x720" to "720p" in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1280x720', + }); + expect(compactDisplay).toBe('720p'); + }); + + it('uppercases video_codec in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + video_codec: 'h264', + }); + expect(compactDisplay).toBe('H264'); + }); + + it('combines resolution and video_codec in compact display', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + video_codec: 'hevc', + }); + expect(compactDisplay).toBe('1080p HEVC'); + }); + + it('includes Resolution in tooltip when present', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + }); + expect(tooltipContent).toContain('Resolution: 1920x1080'); + }); + + it('includes uppercased Video Codec in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + video_codec: 'h264', + }); + expect(tooltipContent).toContain('Video Codec: H264'); + }); + + it('includes Video Bitrate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + video_bitrate: 5000, + }); + expect(tooltipContent).toContain('Video Bitrate: 5000 kbps'); + }); + + it('includes Frame Rate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + source_fps: 30, + }); + expect(tooltipContent).toContain('Frame Rate: 30 FPS'); + }); + + it('includes uppercased Audio Codec in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_codec: 'aac', + }); + expect(tooltipContent).toContain('Audio Codec: AAC'); + }); + + it('includes Audio Channels in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_channels: 2, + }); + expect(tooltipContent).toContain('Audio Channels: 2'); + }); + + it('includes Audio Bitrate in tooltip', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + audio_bitrate: 192, + }); + expect(tooltipContent).toContain('Audio Bitrate: 192 kbps'); + }); + + it('builds multi-line tooltip joined by newlines', () => { + const { tooltipContent } = StreamsTableUtils.getStatsTooltip({ + resolution: '1920x1080', + video_codec: 'h264', + audio_codec: 'aac', + }); + const lines = tooltipContent.split('\n'); + expect(lines).toHaveLength(3); + }); + + it('handles resolution with no height part gracefully', () => { + const { compactDisplay } = StreamsTableUtils.getStatsTooltip({ + resolution: 'unknown', + }); + // No 'x' separator — height is undefined, so no height part added + expect(compactDisplay).toBe('-'); + }); + }); + + // ── appendFetchPageParams ─────────────────────────────────────────────────── + describe('appendFetchPageParams', () => { + it('appends page incremented by 1 from pageIndex', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 2, pageSize: 25 }, + [] + ); + expect(params.get('page')).toBe('3'); + expect(params.get('page_size')).toBe('25'); + }); + + it('does not append ordering when sorting is empty', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [] + ); + expect(params.get('ordering')).toBeNull(); + }); + + it('appends ascending ordering for known column', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'name', desc: false }] + ); + expect(params.get('ordering')).toBe('name'); + }); + + it('appends descending ordering with "-" prefix', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'name', desc: true }] + ); + expect(params.get('ordering')).toBe('-name'); + }); + + it('maps "group" column to "channel_group__name"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'group', desc: false }] + ); + expect(params.get('ordering')).toBe('channel_group__name'); + }); + + it('maps "m3u" column to "m3u_account__name"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'm3u', desc: false }] + ); + expect(params.get('ordering')).toBe('m3u_account__name'); + }); + + it('maps "tvg_id" column to "tvg_id"', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'tvg_id', desc: false }] + ); + expect(params.get('ordering')).toBe('tvg_id'); + }); + + it('uses column id directly for unmapped columns', () => { + const params = new URLSearchParams(); + StreamsTableUtils.appendFetchPageParams( + params, + { pageIndex: 0, pageSize: 50 }, + [{ id: 'custom_field', desc: false }] + ); + expect(params.get('ordering')).toBe('custom_field'); + }); + }); + + // ── getChannelProfileIds ──────────────────────────────────────────────────── + describe('getChannelProfileIds', () => { + it('returns [] when profileIds includes "none"', () => { + expect(StreamsTableUtils.getChannelProfileIds(['none'], '0')).toEqual([]); + }); + + it('returns null when profileIds includes "all"', () => { + expect(StreamsTableUtils.getChannelProfileIds(['all'], '0')).toBeNull(); + }); + + it('returns parsed integer array for specific profile ids', () => { + expect( + StreamsTableUtils.getChannelProfileIds(['1', '2', '3'], '0') + ).toEqual([1, 2, 3]); + }); + + it('returns [selectedProfileId as int] when profileIds is null and selectedProfileId is not "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(null, '3')).toEqual([3]); + }); + + it('returns null when profileIds is null and selectedProfileId is "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(null, '0')).toBeNull(); + }); + + it('returns null when profileIds is undefined and selectedProfileId is "0"', () => { + expect(StreamsTableUtils.getChannelProfileIds(undefined, '0')).toBeNull(); + }); + }); + + // ── getChannelNumberValue ─────────────────────────────────────────────────── + describe('getChannelNumberValue', () => { + it('returns null for "provider" mode', () => { + expect( + StreamsTableUtils.getChannelNumberValue('provider', 100) + ).toBeNull(); + }); + + it('returns 0 for "auto" mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('auto', 100)).toBe(0); + }); + + it('returns -1 for "highest" mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('highest', 100)).toBe(-1); + }); + + it('returns startNumber as Number for any other mode', () => { + expect(StreamsTableUtils.getChannelNumberValue('manual', 42)).toBe(42); + }); + + it('converts string startNumber to number', () => { + expect(StreamsTableUtils.getChannelNumberValue('manual', '200')).toBe( + 200 + ); + }); + }); + + // ── getFilterParams ───────────────────────────────────────────────────────── + describe('getFilterParams', () => { + it('returns empty URLSearchParams for empty filters', () => { + const result = StreamsTableUtils.getFilterParams({}); + expect(result.toString()).toBe(''); + }); + + it('appends string filter values', () => { + const result = StreamsTableUtils.getFilterParams({ name: 'CNN' }); + expect(result.get('name')).toBe('CNN'); + }); + + it('appends "true" for boolean true values', () => { + const result = StreamsTableUtils.getFilterParams({ is_active: true }); + expect(result.get('is_active')).toBe('true'); + }); + + it('does not append boolean false values', () => { + const result = StreamsTableUtils.getFilterParams({ is_active: false }); + expect(result.get('is_active')).toBeNull(); + }); + + it('does not append null values', () => { + const result = StreamsTableUtils.getFilterParams({ name: null }); + expect(result.get('name')).toBeNull(); + }); + + it('does not append undefined values', () => { + const result = StreamsTableUtils.getFilterParams({ name: undefined }); + expect(result.get('name')).toBeNull(); + }); + + it('does not append empty string values', () => { + const result = StreamsTableUtils.getFilterParams({ name: '' }); + expect(result.get('name')).toBeNull(); + }); + + it('appends numeric values as strings', () => { + const result = StreamsTableUtils.getFilterParams({ page: 2 }); + expect(result.get('page')).toBe('2'); + }); + + it('handles multiple filters', () => { + const result = StreamsTableUtils.getFilterParams({ + name: 'ESPN', + is_active: true, + group: '', + }); + expect(result.get('name')).toBe('ESPN'); + expect(result.get('is_active')).toBe('true'); + expect(result.get('group')).toBeNull(); + }); + }); +}); From 28cff8e87b7f27dcfd0dfd7f15a6e802a2aae74d Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:38:33 -0700 Subject: [PATCH 29/56] Refactored components --- .../components/tables/ChannelTableStreams.jsx | 200 ++------ .../src/components/tables/ChannelsTable.jsx | 354 +++++--------- .../ChannelsTable/ChannelTableHeader.jsx | 132 +++-- .../tables/ChannelsTable/EditableCell.jsx | 134 +---- .../tables/CustomTable/CustomTable.jsx | 11 +- .../tables/CustomTable/CustomTableHeader.jsx | 22 +- .../components/tables/CustomTable/index.jsx | 14 +- frontend/src/components/tables/EPGsTable.jsx | 183 ++----- frontend/src/components/tables/LogosTable.jsx | 138 ++---- frontend/src/components/tables/M3UsTable.jsx | 458 ++++-------------- .../components/tables/OutputProfilesTable.jsx | 51 +- .../components/tables/StreamProfilesTable.jsx | 57 +-- .../src/components/tables/StreamsTable.jsx | 378 +++++---------- .../src/components/tables/UserAgentsTable.jsx | 45 +- frontend/src/components/tables/UsersTable.jsx | 17 +- .../src/components/tables/VODLogosTable.jsx | 24 +- 16 files changed, 629 insertions(+), 1589 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index a38795a2..71da2cee 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -1,64 +1,49 @@ -import React, { - useMemo, - useState, - useEffect, - useCallback, - useRef, -} from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { copyToClipboard } from '../../utils'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; +import { ChevronDown, ChevronRight, Eye, GripHorizontal, SquareMinus, } from 'lucide-react'; import { - GripHorizontal, - SquareMinus, - ChevronDown, - ChevronRight, - Eye, -} from 'lucide-react'; -import { - Box, ActionIcon, - Flex, - Text, - useMantineTheme, - Center, Badge, - Group, - Tooltip, - Collapse, + Box, Button, + Center, + Collapse, + Flex, + Group, + Text, + Tooltip, + useMantineTheme, } from '@mantine/core'; -import { - useReactTable, - getCoreRowModel, - flexRender, -} from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; import usePlaylistsStore from '../../store/playlists'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; import { + closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, - closestCenter, useDraggable, useSensor, useSensors, } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; -import { - arrayMove, - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { useSortable } from '@dnd-kit/sortable'; +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { shallow } from 'zustand/shallow'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; +import { + categorizeStreamStats, + formatStatKey, + formatStatValue, + getChannelStreamStats, + reorderChannelStreams, +} from '../../utils/tables/ChannelTableStreamsUtils.js'; // ── Static values (created once, shared across all instances) ──────────────── @@ -69,103 +54,6 @@ const defaultColumnConfig = { minSize: 0, }; -const categoryMapping = { - basic: [ - 'resolution', - 'video_codec', - 'source_fps', - 'audio_codec', - 'audio_channels', - ], - video: [ - 'video_bitrate', - 'pixel_format', - 'width', - 'height', - 'aspect_ratio', - 'frame_rate', - ], - audio: [ - 'audio_bitrate', - 'sample_rate', - 'audio_format', - 'audio_channels_layout', - ], - technical: [ - 'stream_type', - 'container_format', - 'duration', - 'file_size', - 'ffmpeg_output_bitrate', - 'input_bitrate', - ], - other: [], -}; - -const categorizeStreamStats = (stats) => { - if (!stats) - return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; - - const categories = { - basic: {}, - video: {}, - audio: {}, - technical: {}, - other: {}, - }; - - Object.entries(stats).forEach(([key, value]) => { - let categorized = false; - for (const [category, keys] of Object.entries(categoryMapping)) { - if (keys.includes(key)) { - categories[category][key] = value; - categorized = true; - break; - } - } - if (!categorized) { - categories.other[key] = value; - } - }); - - return categories; -}; - -const formatStatValue = (key, value) => { - if (value === null || value === undefined) return 'N/A'; - - switch (key) { - case 'video_bitrate': - case 'audio_bitrate': - case 'ffmpeg_output_bitrate': - return `${value} kbps`; - case 'source_fps': - case 'frame_rate': - return `${value} fps`; - case 'sample_rate': - return `${value} Hz`; - case 'file_size': - if (typeof value === 'number') { - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`; - if (value < 1024 * 1024 * 1024) - return `${(value / (1024 * 1024)).toFixed(2)} MB`; - return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - return value; - case 'duration': - if (typeof value === 'number') { - const hours = Math.floor(value / 3600); - const minutes = Math.floor((value % 3600) / 60); - const seconds = Math.floor(value % 60); - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return value; - default: - return value.toString(); - } -}; - // ── Sub-components ─────────────────────────────────────────────────────────── const RowDragHandleCell = ({ rowId }) => { @@ -219,27 +107,25 @@ const DraggableRow = React.memo( }), }} > - {row.getVisibleCells().map((cell) => { - return ( - - - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - - - ); - })} + {row.getVisibleCells().map((cell) => ( + + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + + ))} ); }, @@ -260,7 +146,7 @@ const StatsCategory = ({ categoryName, stats }) => { {Object.entries(stats).map(([key, value]) => ( - {key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '} + {formatStatKey(key)}:{' '} {formatStatValue(key, value)} @@ -549,7 +435,7 @@ const ChannelStreams = ({ channel }) => { if (t && (since === null || t > since)) since = t; } const ids = opts && opts.ids; - API.getChannelStreamStats(channelId, since, ids).then((updates) => { + getChannelStreamStats(channelId, since, ids).then((updates) => { if (!updates || updates.length === 0) return; patchChannelStreamStats(channelId, updates); }); @@ -596,7 +482,7 @@ const ChannelStreams = ({ channel }) => { const removeStream = useCallback(async (stream) => { const newStreamList = dataRef.current.filter((s) => s.id !== stream.id); setData(newStreamList); - await API.reorderChannelStreams( + await reorderChannelStreams( channelRef.current.id, newStreamList.map((s) => s.id) ); @@ -709,7 +595,7 @@ const ChannelStreams = ({ channel }) => { const newIndex = currentIds.indexOf(over.id); const retval = arrayMove(prevData, oldIndex, newIndex); - API.reorderChannelStreams( + reorderChannelStreams( channel.id, retval.map((row) => row.id) ); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 797471ae..e21a003c 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1,71 +1,59 @@ -import React, { - useEffect, - useMemo, - useState, - useCallback, - useRef, -} from 'react'; -import { - DndContext, - closestCenter, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; +import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import useChannelsStore from '../../store/channels'; -import API from '../../api'; import ChannelForm from '../forms/Channel'; import ChannelBatchForm from '../forms/ChannelBatch'; import RecordingForm from '../forms/Recording'; -import { useDebounce, copyToClipboard } from '../../utils'; +import { copyToClipboard, useDebounce } from '../../utils'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; import { - Tv2, - ScreenShare, - Scroll, - SquareMinus, - CirclePlay, - SquarePen, - Copy, - ScanEye, - EllipsisVertical, - ArrowUpNarrowWide, - ArrowUpDown, ArrowDownWideNarrow, - Search, + ArrowUpDown, + ArrowUpNarrowWide, + CirclePlay, + Copy, + EllipsisVertical, EyeOff, Pencil, + ScanEye, + ScreenShare, + Scroll, + Search, + SquareMinus, + SquarePen, + Tv2, } from 'lucide-react'; -import { listOverriddenFields } from '../../utils/forms/ChannelUtils.js'; +import { listOverriddenFields, requeryChannels, } from '../../utils/forms/ChannelUtils.js'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; import { - Box, - TextInput, - Popover, ActionIcon, + Box, Button, - Paper, - Flex, - Text, - Group, - useMantineTheme, Center, - Switch, + Flex, + Group, Menu, + MenuDropdown, + MenuItem, + MenuTarget, MultiSelect, - Pagination, NativeSelect, - UnstyledButton, - Stack, - Select, NumberInput, + Pagination, + Paper, + Popover, + PopoverDropdown, + PopoverTarget, + Select, + Stack, + Switch, + Text, + TextInput, Tooltip, - Skeleton, + UnstyledButton, + useMantineTheme, } from '@mantine/core'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; @@ -79,21 +67,33 @@ import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding'; import ChannelTableHeader from './ChannelsTable/ChannelTableHeader'; import useOutputProfilesStore from '../../store/outputProfiles'; import { - EditableTextCell, - EditableNumberCell, - EditableGroupCell, EditableEPGCell, + EditableGroupCell, EditableLogoCell, + EditableNumberCell, + EditableTextCell, } from './ChannelsTable/EditableCell'; -import { DraggableRow } from './ChannelsTable/DraggableRow'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; - -const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`; -const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`; -const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`; +import { getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js'; +import { + buildEPGUrl, + buildFetchParams, + buildHDHRUrl, + buildM3UUrl, + deleteChannel, + deleteChannels, + epgUrlBase, + getAllChannelIds, + hdhrUrlBase, + m3uUrlBase, + queryChannels, + reorderChannel, + updateProfileChannel, + updateProfileChannels, +} from '../../utils/tables/ChannelsTableUtils.js'; const ChannelEnabledSwitch = React.memo( ({ rowId, selectedProfileId, selectedTableIds }) => { @@ -109,13 +109,13 @@ const ChannelEnabledSwitch = React.memo( const handleToggle = () => { if (selectedTableIds.length > 1) { - API.updateProfileChannels( + updateProfileChannels( selectedTableIds, selectedProfileId, !isEnabled ); } else { - API.updateProfileChannel(rowId, selectedProfileId, !isEnabled); + updateProfileChannel(rowId, selectedProfileId, !isEnabled); } }; @@ -208,22 +208,22 @@ const ChannelRowActions = React.memo( - + - + - - }> + + }> copyToClipboard(getChannelURL(row.original))} > Copy URL - - + Record - - + + @@ -421,130 +421,47 @@ const ChannelsTable = ({ onReady }) => { /** * Functions */ + const handleFetchSuccess = useCallback((ids) => { + setTablePrefs((prev) => ({ ...prev, pageSize: pagination.pageSize })); + setAllRowIds(ids); + hasFetchedData.current = true; + if (!hasSignaledReady.current && onReady && tvgsLoaded) { + hasSignaledReady.current = true; + onReady(); + } + }, [pagination.pageSize, setTablePrefs, setAllRowIds, onReady, tvgsLoaded]); + const fetchData = useCallback(async () => { - // Build params first to check for duplicates - const params = new URLSearchParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - params.append('include_streams', 'true'); - if (selectedProfileId !== '0') { - params.append('channel_profile_id', selectedProfileId); - } - if (showDisabled === true) { - params.append('show_disabled', true); - } - if (showOnlyStreamlessChannels === true) { - params.append('only_streamless', true); - } - if (showOnlyStaleChannels === true) { - params.append('only_stale', true); - } - if (showOnlyOverriddenChannels === true) { - params.append('only_has_overrides', true); - } - // The backend defaults to "active"; send other choices explicitly so - // hidden rows surface when the user opts into "Hidden Only" or "Show All". - if (visibilityFilter && visibilityFilter !== 'active') { - params.append('visibility_filter', visibilityFilter); - } - - // Apply sorting - if (sorting.length > 0) { - let sortField = sorting[0].id; - // Map frontend column ids to backend ordering field names - const fieldMapping = { - channel_group: 'channel_group__name', - epg: 'epg_data__name', - }; - if (fieldMapping[sortField]) { - sortField = fieldMapping[sortField]; - } - const sortDirection = sorting[0].desc ? '-' : ''; - params.append('ordering', `${sortDirection}${sortField}`); - } - - // Apply debounced filters - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (value) { - if (Array.isArray(value)) { - // Convert null values to "null" string for URL parameter - const processedValue = value - .map((v) => (v === null ? 'null' : v)) - .join(','); - params.append(key, processedValue); - } else { - params.append(key, value); - } - } + const params = buildFetchParams({ + pagination, sorting, debouncedFilters, selectedProfileId, + showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, + showOnlyOverriddenChannels, visibilityFilter, }); - const paramsString = params.toString(); - // Skip if same fetch is already in progress (prevents StrictMode double-fetch) - if ( - fetchInProgressRef.current && - lastFetchParamsRef.current === paramsString - ) { - return; - } + if (fetchInProgressRef.current && lastFetchParamsRef.current === paramsString) return; - // Increment fetch version to track this specific fetch request const currentFetchVersion = ++fetchVersionRef.current; lastFetchParamsRef.current = paramsString; fetchInProgressRef.current = true; - setIsLoading(true); try { - const [, ids] = await Promise.all([ - API.queryChannels(params), - API.getAllChannelIds(params), - ]); - + const [, ids] = await Promise.all([queryChannels(params), getAllChannelIds(params)]); fetchInProgressRef.current = false; - - // Skip state updates if a newer fetch has been initiated - if (currentFetchVersion !== fetchVersionRef.current) { - return; - } - + if (currentFetchVersion !== fetchVersionRef.current) return; setIsLoading(false); - hasFetchedData.current = true; - - setTablePrefs((prev) => ({ - ...prev, - pageSize: pagination.pageSize, - })); - setAllRowIds(ids); - - // Signal ready after first successful data fetch AND EPG data is loaded - // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading - if (!hasSignaledReady.current && onReady && tvgsLoaded) { - hasSignaledReady.current = true; - onReady(); - } + handleFetchSuccess(ids); } catch (error) { fetchInProgressRef.current = false; - - // Skip state updates if a newer fetch has been initiated - if (currentFetchVersion !== fetchVersionRef.current) { - return; - } + if (currentFetchVersion !== fetchVersionRef.current) return; setIsLoading(false); - // API layer handles "Invalid page" errors by resetting and retrying - // Just re-throw to show notification for actual errors throw error; } }, [ - pagination, - sorting, - debouncedFilters, - showDisabled, - selectedProfileId, - showOnlyStreamlessChannels, - showOnlyStaleChannels, - showOnlyOverriddenChannels, - visibilityFilter, + pagination, sorting, debouncedFilters, selectedProfileId, + showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels, + showOnlyOverriddenChannels, visibilityFilter, handleFetchSuccess, ]); const stopPropagation = useCallback((e) => { @@ -604,7 +521,7 @@ const ChannelsTable = ({ onReady }) => { } }, []); - const deleteChannel = async (id) => { + const handleDeleteChannel = async (id) => { console.log(`Deleting channel with ID: ${id}`); const rows = table.getRowModel().rows; @@ -642,15 +559,15 @@ const ChannelsTable = ({ onReady }) => { const executeDeleteChannel = async (id) => { setDeleting(true); try { - await API.deleteChannel(id); - API.requeryChannels(); + await deleteChannel(id); + requeryChannels(); } finally { setDeleting(false); setConfirmDeleteOpen(false); } }; - const deleteChannels = async () => { + const handleDeleteChannels = async () => { if (isWarningSuppressed('delete-channels')) { // Skip warning if suppressed return executeDeleteChannels(); @@ -664,8 +581,8 @@ const ChannelsTable = ({ onReady }) => { setIsLoading(true); setDeleting(true); try { - await API.deleteChannels(table.selectedTableIds); - await API.requeryChannels(); + await deleteChannels(table.selectedTableIds); + await requeryChannels(); setSelectedChannelIds([]); table.setSelectedTableIds([]); } finally { @@ -688,9 +605,9 @@ const ChannelsTable = ({ onReady }) => { return ''; } - const path = `/proxy/ts/stream/${channel.uuid}`; + const path = getShowVideoUrl(channel, env_mode); if (env_mode == 'dev') { - return `${window.location.protocol}//${window.location.hostname}:5656${path}`; + return path; } return `${window.location.protocol}//${window.location.host}${path}`; }, @@ -754,51 +671,23 @@ const ChannelsTable = ({ onReady }) => { setRecordingModalOpen(false); }; - // Build URLs with parameters - const buildM3UUrl = () => { - const params = new URLSearchParams(); - if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); - if (m3uParams.direct) params.append('direct', 'true'); - if (m3uParams.tvg_id_source !== 'channel_number') - params.append('tvg_id_source', m3uParams.tvg_id_source); - if (m3uParams.output_format) - params.append('output_format', m3uParams.output_format); - if (m3uParams.output_profile) - params.append('output_profile', m3uParams.output_profile); - - const baseUrl = m3uUrl; - return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; - }; - - const buildEPGUrl = () => { - const params = new URLSearchParams(); - if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); - if (epgParams.tvg_id_source !== 'channel_number') - params.append('tvg_id_source', epgParams.tvg_id_source); - if (epgParams.days > 0) params.append('days', epgParams.days.toString()); - if (epgParams.prev_days > 0) - params.append('prev_days', epgParams.prev_days.toString()); - - const baseUrl = epgUrl; - return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; - }; // Example copy URLs const copyM3UUrl = async () => { - await copyToClipboard(buildM3UUrl(), { + await copyToClipboard(buildM3UUrl(m3uParams, m3uUrl), { successTitle: 'M3U URL Copied!', successMessage: 'The M3U URL has been copied to your clipboard.', }); }; const copyEPGUrl = async () => { - await copyToClipboard(buildEPGUrl(), { + await copyToClipboard(buildEPGUrl(epgParams, epgUrl), { successTitle: 'EPG URL Copied!', successMessage: 'The EPG URL has been copied to your clipboard.', }); }; const copyHDHRUrl = async () => { - await copyToClipboard(buildHDHRUrl(), { + await copyToClipboard(buildHDHRUrl(hdhrOutputProfileId, hdhrUrl), { successTitle: 'HDHR URL Copied!', successMessage: 'The HDHR URL has been copied to your clipboard.', }); @@ -854,7 +743,7 @@ const ChannelsTable = ({ onReady }) => { useChannelsTableStore.setState({ channels: reorderedData }); // Call backend to reorder - await API.reorderChannel( + await reorderChannel( activeChannel.id, overIndex > activeIndex ? overChannel.id @@ -862,11 +751,11 @@ const ChannelsTable = ({ onReady }) => { ); // Refetch to get updated channel numbers - await API.requeryChannels(); + await requeryChannels(); } catch (error) { // Revert on error console.error('Failed to reorder channel:', error); - await API.requeryChannels(); + await requeryChannels(); } }; @@ -885,13 +774,6 @@ const ChannelsTable = ({ onReady }) => { setM3UUrl(`${m3uUrlBase}${profileString}`); }, [selectedProfileId, profiles]); - const buildHDHRUrl = () => { - if (!hdhrOutputProfileId) return hdhrUrl; - // Insert output_profile segment before the trailing slash (or at end) - const base = hdhrUrl.replace(/\/$/, ''); - return `${base}/output_profile/${hdhrOutputProfileId}`; - }; - useEffect(() => { const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 const endItem = Math.min( @@ -1053,7 +935,7 @@ const ChannelsTable = ({ onReady }) => { row={row} table={table} editChannel={editChannel} - deleteChannel={deleteChannel} + deleteChannel={handleDeleteChannel} handleWatchStream={handleWatchStream} createRecording={createRecording} getChannelURL={getChannelURL} @@ -1298,7 +1180,7 @@ const ChannelsTable = ({ onReady }) => { position="bottom-start" withinPortal > - + - - + + { clients. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { channel list. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { clients. { } /> - +
@@ -1626,7 +1508,7 @@ const ChannelsTable = ({ onReady }) => { { const [opened, setOpened] = useState(false); @@ -59,7 +67,7 @@ const CreateProfilePopover = React.memo(() => { }; const submit = async () => { - await API.addChannelProfile({ name }); + await addChannelProfile({ name }); setName(''); setOpened(false); }; @@ -72,7 +80,7 @@ const CreateProfilePopover = React.memo(() => { withArrow shadow="md" > - + { > - + - + { - + ); }); @@ -125,7 +133,6 @@ const ChannelTableHeader = ({ }) => { const theme = useMantineTheme(); - const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false); @@ -179,38 +186,13 @@ const ChannelTableHeader = ({ const executeDeleteProfile = async (id) => { setDeletingProfile(true); try { - await API.deleteChannelProfile(id); + await deleteChannelProfile(id); } finally { setDeletingProfile(false); setConfirmDeleteProfileOpen(false); } }; - const assignChannels = async () => { - try { - // Call our custom API endpoint - const result = await API.assignChannelNumbers( - selectedTableIds, - channelNumAssignmentStart - ); - - // We might get { message: "Channels have been auto-assigned!" } - notifications.show({ - title: result.message || 'Channels assigned', - color: 'green.5', - }); - - // Refresh the channel list - API.requeryChannels(); - } catch (err) { - console.error(err); - notifications.show({ - title: 'Failed to assign channels', - color: 'red.5', - }); - } - }; - const renderModalOption = renderProfileOption( theme, profiles, @@ -302,14 +284,14 @@ const ChannelTableHeader = ({ > - + - + - - + : @@ -319,9 +301,9 @@ const ChannelTableHeader = ({ {showDisabled ? 'Hide Disabled' : 'Show Disabled'} - + - Only Empty Channels - + - Has Stale Streams - + - Has Overrides - + - - + + Visibility - + {[ { value: 'active', label: 'Active Only' }, { value: 'hidden', label: 'Hidden Only' }, { value: 'all', label: 'Show All' }, ].map(({ value, label }) => ( - setVisibilityFilter && setVisibilityFilter(value) @@ -384,9 +366,9 @@ const ChannelTableHeader = ({ } > {label} - + ))} - + - - - - Standard EPG Source - - Dummy EPG Source - + + + Standard EPG Source + Dummy EPG Source + @@ -668,11 +576,8 @@ const EPGsTable = () => { diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 7c718590..ff5784d5 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -1,44 +1,46 @@ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import LogoForm from '../forms/Logo'; import useLogosStore from '../../store/logos'; import useLocalStorage from '../../hooks/useLocalStorage'; import { - SquarePlus, + ExternalLink, SquareMinus, SquarePen, - ExternalLink, - Filter, - Trash2, + SquarePlus, Trash, } from 'lucide-react'; import { ActionIcon, - Box, - Text, - Paper, - Button, - Flex, - Group, - useMantineTheme, - LoadingOverlay, - Stack, - Image, - Center, Badge, - Tooltip, - Select, - TextInput, - Menu, + Box, + Button, + Center, Checkbox, - Pagination, + Group, + Image, + LoadingOverlay, NativeSelect, + Pagination, + Paper, + Select, + Stack, + Text, + TextInput, + Tooltip, + useMantineTheme, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + cleanupUnusedLogos, + deleteLogo, + deleteLogos, + generateUsageLabel, + getFilteredLogos, +} from '../../utils/tables/LogosTableUtils.js'; -const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { +const LogoRowActions = ({ theme, row, editLogo, handleDeleteLogo }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const onEdit = useCallback(() => { @@ -46,8 +48,8 @@ const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { }, [row.original, editLogo]); const onDelete = useCallback(() => { - deleteLogo(row.original.id); - }, [row.original.id, deleteLogo]); + handleDeleteLogo(row.original.id); + }, [row.original.id, handleDeleteLogo]); const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -127,24 +129,7 @@ const LogosTable = () => { }, [filters.name]); const data = useMemo(() => { - const logosArray = Object.values(logos || {}); - - // Apply filters - let filteredLogos = logosArray; - - if (debouncedNameFilter) { - filteredLogos = filteredLogos.filter((logo) => - logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) - ); - } - - if (filters.used === 'used') { - filteredLogos = filteredLogos.filter((logo) => logo.is_used); - } else if (filters.used === 'unused') { - filteredLogos = filteredLogos.filter((logo) => !logo.is_used); - } - - return filteredLogos.sort((a, b) => a.id - b.id); + return getFilteredLogos(logos, debouncedNameFilter, filters.used); }, [logos, debouncedNameFilter, filters.used]); // Get paginated data @@ -175,15 +160,15 @@ const LogosTable = () => { async (id, deleteFile = false) => { setIsLoading(true); try { - await API.deleteLogo(id, deleteFile); + await deleteLogo(id, deleteFile); await fetchAllLogos(); // Refresh all logos to maintain full view - notifications.show({ + showNotification({ title: 'Success', message: 'Logo deleted successfully', color: 'green', }); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Error', message: 'Failed to delete logo', color: 'red', @@ -206,16 +191,16 @@ const LogosTable = () => { setIsLoading(true); try { - await API.deleteLogos(Array.from(selectedRows), deleteFiles); + await deleteLogos(Array.from(selectedRows), deleteFiles); await fetchAllLogos(); // Refresh all logos to maintain full view - notifications.show({ + showNotification({ title: 'Success', message: `${selectedRows.size} logos deleted successfully`, color: 'green', }); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Error', message: 'Failed to delete logos', color: 'red', @@ -234,14 +219,14 @@ const LogosTable = () => { async (deleteFiles = false) => { setIsCleaningUp(true); try { - const result = await API.cleanupUnusedLogos(deleteFiles); + const result = await cleanupUnusedLogos(deleteFiles); let message = `Successfully deleted ${result.deleted_count} unused logos`; if (result.local_files_deleted > 0) { message += ` and deleted ${result.local_files_deleted} local files`; } - notifications.show({ + showNotification({ title: 'Cleanup Complete', message: message, color: 'green', @@ -249,8 +234,8 @@ const LogosTable = () => { // Force refresh all logos after cleanup to maintain full view await fetchAllLogos(true); - } catch (error) { - notifications.show({ + } catch { + showNotification({ title: 'Cleanup Failed', message: 'Failed to cleanup unused logos', color: 'red', @@ -269,7 +254,7 @@ const LogosTable = () => { setLogoModalOpen(true); }, []); - const deleteLogo = useCallback( + const handleDeleteLogo = useCallback( async (id) => { const logosArray = Object.values(logos || {}); const logo = logosArray.find((l) => l.id === id); @@ -428,40 +413,7 @@ const LogosTable = () => { ); } - // Analyze channel_names to categorize types - const categorizeUsage = (names) => { - const types = { channels: 0, movies: 0, series: 0 }; - - names.forEach((name) => { - if (name.startsWith('Channel:')) types.channels++; - else if (name.startsWith('Movie:')) types.movies++; - else if (name.startsWith('Series:')) types.series++; - }); - - return types; - }; - - const types = categorizeUsage(channelNames); - const typeCount = Object.values(types).filter( - (count) => count > 0 - ).length; - - // Generate smart label based on usage - const generateLabel = () => { - if (typeCount === 1) { - // Only one type - be specific - if (types.channels > 0) - return `${types.channels} channel${types.channels !== 1 ? 's' : ''}`; - if (types.movies > 0) - return `${types.movies} movie${types.movies !== 1 ? 's' : ''}`; - if (types.series > 0) return `${types.series} series`; - } else { - // Multiple types - use generic "items" - return `${count} item${count !== 1 ? 's' : ''}`; - } - }; - - const label = generateLabel(); + const label = generateUsageLabel(channelNames, count); return ( { theme={theme} row={row} editLogo={editLogo} - deleteLogo={deleteLogo} + handleDeleteLogo={handleDeleteLogo} /> ), }, @@ -536,7 +488,7 @@ const LogosTable = () => { [ theme, editLogo, - deleteLogo, + handleDeleteLogo, selectedRows, handleSelectRow, handleSelectAll, diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 4a1d7e6f..fa21191e 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -1,11 +1,10 @@ -import React, { +import { useEffect, useMemo, useRef, useState, useCallback, } from 'react'; -import API from '../../api'; import usePlaylistsStore from '../../store/playlists'; import M3UForm from '../forms/M3U'; import ServerGroupsManagerModal from '../ServerGroupsManagerModal'; @@ -20,16 +19,11 @@ import { ActionIcon, Tooltip, Switch, - Group, - Center, } from '@mantine/core'; import { SquareMinus, SquarePen, RefreshCcw, - ArrowUpDown, - ArrowUpNarrowWide, - ArrowDownWideNarrow, SquarePlus, } from 'lucide-react'; import useLocalStorage from '../../hooks/useLocalStorage'; @@ -42,55 +36,42 @@ import { import ConfirmationDialog from '../../components/ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from './CustomTable'; +import { + deletePlaylist, + getExpirationInfo, + getExpirationTooltip, + getPlaylistAutoCreatedChannelsCount, + getSortedPlaylists, + getStatusColor, + getStatusContent, + formatStatusText, + refreshPlaylist, + updatePlaylist, +} from '../../utils/tables/M3UsTableUtils.js'; +import { + makeHeaderCellRenderer, + makeSortingChangeHandler, +} from './M3uTableUtils.jsx'; -// Helper function to format status text -const formatStatusText = (status) => { - switch (status) { - case 'idle': - return 'Idle'; - case 'fetching': - return 'Fetching'; - case 'parsing': - return 'Parsing'; - case 'error': - return 'Error'; - case 'success': - return 'Success'; - case 'pending_setup': - return 'Pending Setup'; - default: - return status - ? status.charAt(0).toUpperCase() + status.slice(1) - : 'Unknown'; - } -}; +const StatusRow = ({ label, value }) => ( + + {label}{value ? ':' : ''} + {value && {value}} + +); -// Helper function to get status text color -const getStatusColor = (status) => { - switch (status) { - case 'idle': - return 'gray.5'; - case 'fetching': - return 'blue.5'; - case 'parsing': - return 'indigo.5'; - case 'error': - return 'red.5'; - case 'success': - return 'green.5'; - case 'pending_setup': - return 'orange.5'; // Orange to indicate action needed - default: - return 'gray.5'; - } -}; +const StatusBox = ({ children }) => ( + + {children} + +); const RowActions = ({ tableSize, editPlaylist, - deletePlaylist, + handleDeletePlaylist, row, - refreshPlaylist, + handleRefreshPlaylist, }) => { const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -111,7 +92,7 @@ const RowActions = ({ variant="transparent" size={iconSize} color="red.9" - onClick={() => deletePlaylist(row.original.id)} + onClick={() => handleDeletePlaylist(row.original.id)} > @@ -119,7 +100,7 @@ const RowActions = ({ variant="transparent" size={iconSize} color="blue.5" - onClick={() => refreshPlaylist(row.original.id)} + onClick={() => handleRefreshPlaylist(row.original.id)} disabled={!row.original.is_active} > @@ -128,10 +109,10 @@ const RowActions = ({ ); }; + const M3UTable = () => { const [playlist, setPlaylist] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); - const [rowSelection, setRowSelection] = useState([]); const [playlistCreated, setPlaylistCreated] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -179,209 +160,58 @@ const M3UTable = () => { return 'Idle'; } - switch (data.action) { + const content = getStatusContent(data); + + switch (content.type) { case 'initializing': - return buildInitializingStats(); - + return ( + + ); case 'downloading': - return buildDownloadingStats(data); - - case 'processing_groups': - return buildGroupProcessingStats(data); - + return ( + + + + + + ); + case 'groups': + return ( + + + {content.elapsedTime && } + {content.groupsProcessed && } + + ); case 'parsing': - return buildParsingStats(data); - + return ( + + + {content.elapsedTime && } + {content.timeRemaining && } + {content.streamsProcessed && } + + ); + case 'error': + return ( + + Error: + + {content.error || 'Unknown error occurred'} + + + ); default: - return data.status === 'error' - ? buildErrorStats(data) - : `${data.action || 'Processing'}...`; + return content.label; } }; - const buildDownloadingStats = (data) => { - if (data.progress == 100) { - return 'Download complete!'; - } - - if (data.progress == 0) { - return 'Downloading...'; - } - - // Format time remaining in minutes:seconds - const timeRemaining = data.time_remaining - ? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` - : 'calculating...'; - - // Format speed with appropriate unit (KB/s or MB/s) - const speed = - data.speed >= 1024 - ? `${(data.speed / 1024).toFixed(2)} MB/s` - : `${Math.round(data.speed)} KB/s`; - - return ( - - - - - Downloading: - - {parseInt(data.progress)}% - - - - Speed: - - {speed} - - - - Time left: - - {timeRemaining} - - - - ); - }; - - const buildGroupProcessingStats = (data) => { - if (data.progress == 100) { - return 'Groups processed!'; - } - - if (data.progress == 0) { - return 'Processing groups...'; - } - - // Format time displays if available - const elapsedTime = data.elapsed_time - ? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` - : null; - - return ( - - - - - Processing groups: - - {parseInt(data.progress)}% - - {elapsedTime && ( - - - Elapsed: - - {elapsedTime} - - )} - {data.groups_processed && ( - - - Groups: - - {data.groups_processed} - - )} - - - ); - }; - - const buildErrorStats = (data) => { - return ( - - - - - Error: - - - - {data.error || 'Unknown error occurred'} - - - - ); - }; - - const buildParsingStats = (data) => { - if (data.progress == 100) { - return 'Parsing complete!'; - } - - if (data.progress == 0) { - return 'Parsing...'; - } - - // Format time displays - const timeRemaining = data.time_remaining - ? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` - : 'calculating...'; - - const elapsedTime = data.elapsed_time - ? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` - : '0:00'; - - return ( - - - - - Parsing: - - {parseInt(data.progress)}% - - {data.elapsed_time && ( - - - Elapsed: - - {elapsedTime} - - )} - {data.time_remaining && ( - - - Remaining: - - {timeRemaining} - - )} - {data.streams_processed && ( - - - Streams: - - {data.streams_processed} - - )} - - - ); - }; - - const buildInitializingStats = () => { - return ( - - - - - Initializing refresh... - - - - - ); - }; - const editPlaylist = async (playlist = null) => { setPlaylist(playlist); setPlaylistModalOpen(true); }; - const refreshPlaylist = async (id) => { + const handleRefreshPlaylist = async (id) => { // Provide immediate visual feedback before the API call setRefreshProgress(id, { action: 'initializing', @@ -391,9 +221,9 @@ const M3UTable = () => { }); try { - await API.refreshPlaylist(id); + await refreshPlaylist(id); // No need to set again since WebSocket will update us once the task starts - } catch (error) { + } catch { // If the API call fails, show an error state setRefreshProgress(id, { action: 'error', @@ -406,7 +236,7 @@ const M3UTable = () => { } }; - const deletePlaylist = async (id) => { + const handleDeletePlaylist = async (id) => { // Get playlist details for the confirmation dialog const playlist = playlists.find((p) => p.id === id); setPlaylistToDelete(playlist); @@ -418,7 +248,7 @@ const M3UTable = () => { // thinking there are zero auto-created channels. let info; try { - const result = await API.getPlaylistAutoCreatedChannelsCount(id); + const result = await getPlaylistAutoCreatedChannelsCount(id); info = result || { count: 0, sample_names: [] }; } catch { info = { @@ -448,7 +278,9 @@ const M3UTable = () => { setIsLoading(true); setDeleting(true); try { - await API.deletePlaylist(id); + await deletePlaylist(id); + } catch (error) { + console.error('Error deleting playlist:', error); } finally { setDeleting(false); setIsLoading(false); @@ -460,11 +292,11 @@ const M3UTable = () => { const toggleActive = async (playlist) => { try { // Send only the is_active field to trigger our special handling - await API.updatePlaylist( + await updatePlaylist( { - id: playlist.id, is_active: !playlist.is_active, }, + playlist, true ); // Add a new parameter to indicate this is just a toggle } catch (error) { @@ -685,34 +517,10 @@ const M3UTable = () => { const now = getNow(); const daysLeft = diff(earliest, now, 'day'); - let color; - let label; - if (daysLeft < 0) { - color = 'red.7'; - label = 'Expired'; - } else if (daysLeft === 0) { - color = 'red.5'; - label = 'Expires today'; - } else if (daysLeft <= 7) { - color = 'orange.5'; - label = `${daysLeft}d left`; - } else if (daysLeft <= 30) { - color = 'yellow.5'; - label = `${daysLeft}d left`; - } else { - label = format(earliest, fullDateFormat); - } + const { color, label } = getExpirationInfo(daysLeft, earliest, fullDateFormat); const allExpirations = data.all_expirations || []; - const tooltipContent = - allExpirations.length > 0 - ? allExpirations - .map( - (e) => - `${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${!e.is_active ? ' (inactive)' : ''}` - ) - .join('\n') - : label; + const tooltipContent = getExpirationTooltip(allExpirations, fullDateTimeFormat, label); return ( { }, ], [ - refreshPlaylist, + handleRefreshPlaylist, editPlaylist, - deletePlaylist, + handleDeletePlaylist, toggleActive, fullDateFormat, fullDateTimeFormat, @@ -776,7 +584,7 @@ const M3UTable = () => { //optionally access the underlying virtualizer instance const rowVirtualizerInstanceRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); + const [_isLoading, setIsLoading] = useState(true); const closeModal = (newPlaylist = null) => { if (newPlaylist) { @@ -812,85 +620,11 @@ const M3UTable = () => { } }, [editPlaylistId, processedData, playlists, setEditPlaylistId]); - const onSortingChange = (column) => { - console.log(column); - const sortField = sorting[0]?.id; - const sortDirection = sorting[0]?.desc; + const onSortingChange = makeSortingChangeHandler(sorting, setSorting, (col, desc) => + setData(getSortedPlaylists(playlists, col, desc)) + ); - const newSorting = []; - if (sortField == column) { - if (sortDirection == false) { - newSorting[0] = { - id: column, - desc: true, - }; - } - } else { - newSorting[0] = { - id: column, - desc: false, - }; - } - - setSorting(newSorting); - if (newSorting.length > 0) { - const compareColumn = newSorting[0].id; - const compareDesc = newSorting[0].desc; - - setData( - playlists - .filter((playlist) => playlist.locked === false) - .sort((a, b) => { - const aVal = a[compareColumn]; - const bVal = b[compareColumn]; - - // Always sort nulls/undefined to the end regardless of direction - if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; - - let comparison; - if (typeof aVal === 'string') { - comparison = aVal.localeCompare(bVal); - } else { - comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; - } - - return compareDesc ? -comparison : comparison; - }) - ); - } - }; - - const renderHeaderCell = (header) => { - let sortingIcon = ArrowUpDown; - if (sorting[0]?.id == header.id) { - if (sorting[0].desc === false) { - sortingIcon = ArrowUpNarrowWide; - } else { - sortingIcon = ArrowDownWideNarrow; - } - } - - switch (header.id) { - default: - return ( - - - {header.column.columnDef.header} - - {header.column.columnDef.sortable && ( -
- {React.createElement(sortingIcon, { - onClick: () => onSortingChange(header.id), - size: 14, - })} -
- )} -
- ); - } - }; + const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange); const renderBodyCell = useCallback(({ cell, row }) => { switch (cell.column.id) { @@ -899,9 +633,9 @@ const M3UTable = () => { ); } @@ -915,7 +649,6 @@ const M3UTable = () => { enablePagination: false, enableRowVirtualization: true, enableRowSelection: false, - onRowSelectionChange: setRowSelection, renderTopToolbar: false, sorting, manualSorting: true, @@ -1027,11 +760,8 @@ const M3UTable = () => { @@ -1115,7 +845,7 @@ This action cannot be undone.`} }} > - {`${autoChannelsInfo.count} auto-synced channel${autoChannelsInfo.count === 1 ? '' : 's'} created by this provider will also be deleted.`} + {`${autoChannelsInfo.count} auto-synced ${autoChannelsInfo.count === 1 ? 'channel' : 'channels'} created by this provider will also be deleted.`} ) : null} diff --git a/frontend/src/components/tables/OutputProfilesTable.jsx b/frontend/src/components/tables/OutputProfilesTable.jsx index b72d3463..15c91ada 100644 --- a/frontend/src/components/tables/OutputProfilesTable.jsx +++ b/frontend/src/components/tables/OutputProfilesTable.jsx @@ -1,23 +1,26 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import API from '../../api'; +import { useEffect, useMemo, useState } from 'react'; import OutputProfileForm from '../forms/OutputProfile'; import useOutputProfilesStore from '../../store/outputProfiles'; import { - Box, ActionIcon, - Tooltip, - Text, - Paper, - Flex, + Box, Button, - useMantineTheme, Center, - Switch, + Flex, + Paper, Stack, + Switch, + Text, + Tooltip, + useMantineTheme, } from '@mantine/core'; -import { SquareMinus, SquarePen, Eye, EyeOff, SquarePlus } from 'lucide-react'; +import { Eye, EyeOff, SquareMinus, SquarePen, SquarePlus } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { + deleteOutputProfile, + updateOutputProfile, +} from '../../utils/tables/OutputProfilesTableUtils.js'; const RowActions = ({ row, editOutputProfile, deleteOutputProfile }) => { return ( @@ -54,10 +57,6 @@ const OutputProfiles = () => { const [tableSize] = useLocalStorage('table-size', 'default'); const theme = useMantineTheme(); - const rowVirtualizerInstanceRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const columns = useMemo( () => [ { @@ -139,10 +138,6 @@ const OutputProfiles = () => { setProfileModalOpen(true); }; - const deleteOutputProfile = async (id) => { - await API.deleteOutputProfile(id); - }; - const closeOutputProfileForm = () => { setProfile(null); setProfileModalOpen(false); @@ -151,7 +146,7 @@ const OutputProfiles = () => { const toggleHideInactive = () => setHideInactive((v) => !v); const toggleProfileIsActive = async (profile) => { - await API.updateOutputProfile({ + await updateOutputProfile({ id: profile.id, ...profile, is_active: !profile.is_active, @@ -159,23 +154,7 @@ const OutputProfiles = () => { }; useEffect(() => { - if (typeof window !== 'undefined') setIsLoading(false); - }, []); - - useEffect(() => { - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - - useEffect(() => { - setData( - outputProfiles.filter((p) => - hideInactive && !p.is_active ? false : true - ) - ); + setData(outputProfiles.filter((p) => !(hideInactive && !p.is_active))); }, [outputProfiles, hideInactive]); const renderHeaderCell = (header) => ( diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index d34d58ea..537ffaad 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -1,10 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import API from '../../api'; import StreamProfileForm from '../forms/StreamProfile'; import useStreamProfilesStore from '../../store/streamProfiles'; -import { TableHelper } from '../../helpers'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { Box, ActionIcon, @@ -21,16 +19,16 @@ import { import { SquareMinus, SquarePen, - Check, - X, Eye, EyeOff, SquarePlus, } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { updateStreamProfile } from '../../utils/forms/StreamProfileUtils.js'; -const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => { +const RowActions = ({ row, editStreamProfile, handleDeleteStreamProfile }) => { return ( <> { size="sm" color="red.9" disabled={row.original.locked} - onClick={() => deleteStreamProfile(row.original.id)} + onClick={() => handleDeleteStreamProfile(row.original.id)} > {/* Small icon size */} @@ -55,6 +53,10 @@ const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => { ); }; +const deleteStreamProfile = (id) => { + return API.deleteStreamProfile(id); +} + const StreamProfiles = () => { const [profile, setProfile] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); @@ -143,27 +145,21 @@ const StreamProfiles = () => { [] ); - //optionally access the underlying virtualizer instance - const rowVirtualizerInstanceRef = useRef(null); - - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const editStreamProfile = async (profile = null) => { setProfile(profile); setProfileModalOpen(true); }; - const deleteStreamProfile = async (id) => { + const handleDeleteStreamProfile = async (id) => { if (id == settings.default_stream_profile) { - notifications.show({ + showNotification({ title: 'Cannot delete default stream-profile', color: 'red.5', }); return; } - await API.deleteStreamProfile(id); + await deleteStreamProfile(id); }; const closeStreamProfileForm = () => { @@ -171,28 +167,14 @@ const StreamProfiles = () => { setProfileModalOpen(false); }; - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } - }, []); - - useEffect(() => { - //scroll to the top of the table when the sorting changes - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - const toggleHideInactive = () => { setHideInactive(!hideInactive); }; const toggleProfileIsActive = async (profile) => { - await API.updateStreamProfile({ - id: profile.id, + await updateStreamProfile( + profile.id, + { ...profile, is_active: !profile.is_active, }); @@ -200,9 +182,7 @@ const StreamProfiles = () => { useEffect(() => { setData( - streamProfiles.filter((profile) => - hideInactive && !profile.is_active ? false : true - ) + streamProfiles.filter((profile) => !(hideInactive && !profile.is_active)) ); }, [streamProfiles, hideInactive]); @@ -221,7 +201,7 @@ const StreamProfiles = () => { ); } @@ -255,11 +235,8 @@ const StreamProfiles = () => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 14fed719..aaeba59d 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -1,63 +1,54 @@ -import React, { - useEffect, - useMemo, - useCallback, - useState, - useRef, -} from 'react'; -import API from '../../api'; +import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import StreamForm from '../forms/Stream'; import usePlaylistsStore from '../../store/playlists'; import useChannelsStore from '../../store/channels'; import { copyToClipboard, useDebounce } from '../../utils'; import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js'; import { - SquarePlus, - ListPlus, - SquareMinus, - EllipsisVertical, - Copy, + ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, - ArrowDownWideNarrow, - Search, - Filter, - Square, - SquareCheck, + Copy, + EllipsisVertical, Eye, EyeOff, + Filter, + ListPlus, RotateCcw, + Search, + Square, + SquareCheck, + SquareMinus, + SquarePlus, } from 'lucide-react'; import { - TextInput, ActionIcon, - Select, - Tooltip, - Menu, - Flex, Box, - Text, - Paper, Button, Card, - Stack, - Title, - Divider, Center, - Pagination, + Divider, + Flex, Group, - NativeSelect, - MultiSelect, - useMantineTheme, - UnstyledButton, - Skeleton, - Modal, - NumberInput, - Radio, LoadingOverlay, - Pill, + Menu, + MenuDivider, + MenuDropdown, + MenuItem, + MenuLabel, + MenuTarget, + MultiSelect, + NativeSelect, + Pagination, + Paper, + Stack, + Text, + TextInput, + Title, + Tooltip, + UnstyledButton, + useMantineTheme, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import { useNavigate } from 'react-router-dom'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; @@ -68,14 +59,33 @@ import useLocalStorage from '../../hooks/useLocalStorage'; import ConfirmationDialog from '../ConfirmationDialog'; import CreateChannelModal from '../modals/CreateChannelModal'; import useStreamsTableStore from '../../store/streamsTable'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { requeryChannels } from '../../utils/forms/ChannelUtils.js'; +import { + addStreamsToChannel, + appendFetchPageParams, + createChannelFromStream, + createChannelsFromStreamsAsync, + deleteStream, + deleteStreams, + getAllStreamIds, + getChannelNumberValue, + getChannelProfileIds, + getFilterParams, + getStatsTooltip, + getStreamFilterOptions, + getStreams, + queryStreamsTable, + requeryStreams, +} from '../../utils/tables/StreamsTableUtils.js'; const StreamRowActions = ({ theme, row, editStream, - deleteStream, + handleDeleteStream, handleWatchStream, - createChannelFromStream, + handleCreateChannelFromStream, table, }) => { const tableSize = table?.tableSize ?? 'default'; @@ -90,7 +100,7 @@ const StreamRowActions = ({ ); const addStreamToChannel = async () => { - await API.addStreamsToChannel(targetChannelId, channelSelectionStreams, [ + await addStreamsToChannel(targetChannelId, channelSelectionStreams, [ row.original, ]); }; @@ -100,8 +110,8 @@ const StreamRowActions = ({ }, [row.original, editStream]); const onDelete = useCallback(() => { - deleteStream(row.original.id); - }, [row.original.id, deleteStream]); + handleDeleteStream(row.original.id); + }, [row.original.id, handleDeleteStream]); const onPreview = useCallback(() => { console.log( @@ -144,21 +154,21 @@ const StreamRowActions = ({ size={iconSize} color={theme.tailwind.green[5]} variant="transparent" - onClick={() => createChannelFromStream(row.original)} + onClick={() => handleCreateChannelFromStream(row.original)} >
- + - + - - }> + + }> Copy URL - - + + Edit - - + + Delete Stream - - + + Preview Stream - - + + ); @@ -230,13 +240,6 @@ const StreamsTable = ({ onReady }) => { const [isBulkDelete, setIsBulkDelete] = useState(false); const [deleting, setDeleting] = useState(false); - // const [allRowsSelected, setAllRowsSelected] = useState(false); - - // Add local storage for page size - const [storedPageSize, setStoredPageSize] = useLocalStorage( - 'streams-page-size', - 50 - ); const [filters, setFilters] = useState({ name: '', channel_group: '', @@ -485,43 +488,7 @@ const StreamsTable = ({ onReady }) => { ); - // Build compact display (resolution + video codec) - const parts = []; - if (stats.resolution) { - // Convert "1920x1080" to "1080p" format - const height = stats.resolution.split('x')[1]; - if (height) parts.push(`${height}p`); - } - if (stats.video_codec) { - parts.push(stats.video_codec.toUpperCase()); - } - const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; - - // Build tooltip content with friendly labels - const tooltipLines = []; - if (stats.resolution) - tooltipLines.push(`Resolution: ${stats.resolution}`); - if (stats.video_codec) - tooltipLines.push( - `Video Codec: ${stats.video_codec.toUpperCase()}` - ); - if (stats.video_bitrate) - tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); - if (stats.source_fps) - tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); - if (stats.audio_codec) - tooltipLines.push( - `Audio Codec: ${stats.audio_codec.toUpperCase()}` - ); - if (stats.audio_channels) - tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); - if (stats.audio_bitrate) - tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); - - const tooltipContent = - tooltipLines.length > 0 - ? tooltipLines.join('\n') - : 'No source info available'; + const { compactDisplay, tooltipContent } = getStatsTooltip(stats); return ( { // Build a URLSearchParams object containing only the filter portion of the // query. Page-rows fetches add page/page_size/ordering on top of this. const buildFilterParams = useCallback(() => { - const params = new URLSearchParams(); - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (typeof value === 'boolean') { - if (value) params.append(key, 'true'); - } else if (value !== null && value !== undefined && value !== '') { - params.append(key, String(value)); - } - }); - return params; + return getFilterParams(debouncedFilters); }, [debouncedFilters]); // Fetch the visible page of stream rows. Depends on pagination, sorting, @@ -609,21 +568,7 @@ const StreamsTable = ({ onReady }) => { const fetchPageData = useCallback( async ({ showLoader = true } = {}) => { const params = buildFilterParams(); - params.append('page', pagination.pageIndex + 1); - params.append('page_size', pagination.pageSize); - - if (sorting.length > 0) { - const columnId = sorting[0].id; - const fieldMapping = { - name: 'name', - group: 'channel_group__name', - m3u: 'm3u_account__name', - tvg_id: 'tvg_id', - }; - const sortField = fieldMapping[columnId] || columnId; - const sortDirection = sorting[0].desc ? '-' : ''; - params.append('ordering', `${sortDirection}${sortField}`); - } + appendFetchPageParams(params, pagination, sorting); const paramsString = params.toString(); @@ -644,7 +589,7 @@ const StreamsTable = ({ onReady }) => { } try { - const result = await API.queryStreamsTable(params); + const result = await queryStreamsTable(params); fetchInProgressRef.current = false; @@ -700,16 +645,8 @@ const StreamsTable = ({ onReady }) => { const savedStartNumber = localStorage.getItem('channel-numbering-start') || '1'; - let startingChannelNumberValue; - if (savedMode === 'provider') { - startingChannelNumberValue = null; - } else if (savedMode === 'auto') { - startingChannelNumberValue = 0; - } else if (savedMode === 'highest') { - startingChannelNumberValue = -1; - } else { - startingChannelNumberValue = Number(savedStartNumber); - } + const startingChannelNumberValue = + getChannelNumberValue(savedMode, savedStartNumber); await executeChannelCreation( startingChannelNumberValue, @@ -728,7 +665,7 @@ const StreamsTable = ({ onReady }) => { return streamFromCurrentPage; } - const response = await API.getStreams([streamId]); + const response = await getStreams([streamId]); return ( response?.find( (candidate) => Number(candidate.id) === Number(streamId) @@ -740,9 +677,9 @@ const StreamsTable = ({ onReady }) => { if (selectedStreamIds.length === 1) { const selectedStream = await resolveSelectedStream(selectedStreamIds[0]); if (selectedStream) { - await createChannelFromStream(selectedStream); + await handleCreateChannelFromStream(selectedStream); } else { - notifications.show({ + showNotification({ color: 'red', title: 'Stream not found', message: @@ -761,25 +698,13 @@ const StreamsTable = ({ onReady }) => { profileIds = null ) => { try { - // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise - let channelProfileIds; - if (profileIds) { - if (profileIds.includes('none')) { - channelProfileIds = []; - } else if (profileIds.includes('all')) { - channelProfileIds = null; - } else { - channelProfileIds = profileIds - .filter((id) => id !== 'all' && id !== 'none') - .map((id) => parseInt(id)); - } - } else { - channelProfileIds = - selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; - } + const channelProfileIds = getChannelProfileIds( + profileIds, + selectedProfileId + ); // Use the async API for all bulk operations - const response = await API.createChannelsFromStreamsAsync( + const response = await createChannelsFromStreamsAsync( selectedStreamIds, channelProfileIds, startingChannelNumberValue @@ -814,14 +739,7 @@ const StreamsTable = ({ onReady }) => { } // Convert mode to API value - const startingChannelNumberValue = - numberingMode === 'provider' - ? null - : numberingMode === 'auto' - ? 0 - : numberingMode === 'highest' - ? -1 - : Number(customStartNumber); + const startingChannelNumberValue = getChannelNumberValue(numberingMode, customStartNumber); setChannelNumberingModalOpen(false); await executeChannelCreation( @@ -839,7 +757,7 @@ const StreamsTable = ({ onReady }) => { setModalOpen(true); }; - const deleteStream = async (id) => { + const handleDeleteStream = async (id) => { // Get stream details for the confirmation dialog const streamObj = data.find((s) => s.id === id); setStreamToDelete(streamObj); @@ -858,7 +776,7 @@ const StreamsTable = ({ onReady }) => { setDeleting(true); setIsLoading(true); try { - await API.deleteStream(id); + await deleteStream(id); // Clear the selection for the deleted stream setSelectedStreamIds([]); table.setSelectedTableIds([]); @@ -869,7 +787,7 @@ const StreamsTable = ({ onReady }) => { } }; - const deleteStreams = async () => { + const handleDeleteStreams = async () => { setIsBulkDelete(true); setStreamToDelete(null); @@ -885,7 +803,7 @@ const StreamsTable = ({ onReady }) => { setDeleting(true); setIsLoading(true); try { - await API.deleteStreams(selectedStreamIds); + await deleteStreams(selectedStreamIds); setSelectedStreamIds([]); table.setSelectedTableIds([]); } finally { @@ -900,14 +818,14 @@ const StreamsTable = ({ onReady }) => { setModalOpen(false); setIsLoading(true); try { - await API.requeryStreams(); + await requeryStreams(); } finally { setIsLoading(false); } }; // Single channel creation functions - const createChannelFromStream = async (stream) => { + const handleCreateChannelFromStream = async (stream) => { // Set default profile selection based on current profile filter const defaultProfileIds = selectedProfileId === '0' ? ['all'] : [selectedProfileId]; @@ -922,16 +840,7 @@ const StreamsTable = ({ onReady }) => { const savedChannelNumber = localStorage.getItem('single-channel-numbering-specific') || '1'; - let channelNumberValue; - if (savedMode === 'provider') { - channelNumberValue = null; - } else if (savedMode === 'auto') { - channelNumberValue = 0; - } else if (savedMode === 'highest') { - channelNumberValue = -1; - } else { - channelNumberValue = Number(savedChannelNumber); - } + const channelNumberValue = getChannelNumberValue(savedMode, savedChannelNumber); await executeSingleChannelCreation( stream, @@ -951,30 +860,14 @@ const StreamsTable = ({ onReady }) => { channelNumber = null, profileIds = null ) => { - // Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise - let channelProfileIds; - if (profileIds) { - if (profileIds.includes('none')) { - channelProfileIds = []; - } else if (profileIds.includes('all')) { - channelProfileIds = null; - } else { - channelProfileIds = profileIds - .filter((id) => id !== 'all' && id !== 'none') - .map((id) => parseInt(id)); - } - } else { - channelProfileIds = - selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null; - } - - await API.createChannelFromStream({ + const channelProfileIds = getChannelProfileIds(profileIds, selectedProfileId); + await createChannelFromStream({ name: stream.name, channel_number: channelNumber, stream_id: stream.id, channel_profile_ids: channelProfileIds, }); - await API.requeryChannels(); + await requeryChannels(); }; // Handle confirming the single channel numbering modal @@ -992,14 +885,7 @@ const StreamsTable = ({ onReady }) => { } // Convert mode to API value - const channelNumberValue = - singleChannelMode === 'provider' - ? null - : singleChannelMode === 'auto' - ? 0 - : singleChannelMode === 'highest' - ? -1 - : Number(specificChannelNumber); + const channelNumberValue = getChannelNumberValue(singleChannelMode, specificChannelNumber); setSingleChannelModalOpen(false); await executeSingleChannelCreation( @@ -1013,11 +899,11 @@ const StreamsTable = ({ onReady }) => { setSingleChannelMode(nextMode); }; - const addStreamsToChannel = async () => { + const handleAddStreamsToChannel = async () => { // Look up full stream objects from the current page data const selectedIdSet = new Set(selectedStreamIds); const newStreams = data.filter((s) => selectedIdSet.has(s.id)); - await API.addStreamsToChannel( + await addStreamsToChannel( targetChannelId, channelSelectionStreams, newStreams @@ -1030,7 +916,6 @@ const StreamsTable = ({ onReady }) => { const onPageSizeChange = (e) => { const newPageSize = parseInt(e.target.value); - setStoredPageSize(newPageSize); setPagination({ ...pagination, pageSize: newPageSize, @@ -1246,14 +1131,14 @@ const StreamsTable = ({ onReady }) => { theme={theme} row={row} editStream={editStream} - deleteStream={deleteStream} + handleDeleteStream={handleDeleteStream} handleWatchStream={handleWatchStream} - createChannelFromStream={createChannelFromStream} + handleCreateChannelFromStream={handleCreateChannelFromStream} /> ); } }, - [theme, editStream, deleteStream, handleWatchStream] + [theme, editStream, handleDeleteStream, handleWatchStream] ); const table = useTable({ @@ -1316,7 +1201,7 @@ const StreamsTable = ({ onReady }) => { lastIdsParamsRef.current = paramsString; let cancelled = false; (async () => { - const ids = await API.getAllStreamIds(params); + const ids = await getAllStreamIds(params); if (!cancelled && ids) { setAllRowIds(ids); } @@ -1335,7 +1220,7 @@ const StreamsTable = ({ onReady }) => { lastFilterOptionsParamsRef.current = paramsString; let cancelled = false; (async () => { - const filterOptions = await API.getStreamFilterOptions(params); + const filterOptions = await getStreamFilterOptions(params); if (cancelled || !filterOptions || typeof filterOptions !== 'object') { return; } @@ -1377,16 +1262,14 @@ const StreamsTable = ({ onReady }) => { return; } - const loadGroups = async () => { + (async () => { hasFetchedChannelGroups.current = true; try { await fetchChannelGroups(); } catch (error) { console.error('Error fetching channel groups:', error); } - }; - - loadGroups(); + })(); }, [channelGroups, fetchChannelGroups]); useEffect(() => { @@ -1466,7 +1349,6 @@ const StreamsTable = ({ onReady }) => { fontSize: '20px', lineHeight: 1, letterSpacing: '-0.3px', - // color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary marginBottom: 0, }} > @@ -1501,7 +1383,7 @@ const StreamsTable = ({ onReady }) => { : 'default' } size="xs" - onClick={addStreamsToChannel} + onClick={handleAddStreamsToChannel} p={5} color={ selectedStreamIds.length > 0 && targetChannelId @@ -1544,16 +1426,16 @@ const StreamsTable = ({ onReady }) => { - + - + - - + { } > Only Unassociated - - + { } > Hide Stale - - + + @@ -1603,7 +1485,7 @@ const StreamsTable = ({ onReady }) => { leftSection={} variant="default" size="xs" - onClick={deleteStreams} + onClick={handleDeleteStreams} disabled={selectedStreamIds.length == 0} > Delete @@ -1611,17 +1493,17 @@ const StreamsTable = ({ onReady }) => { - + - + - - Toggle Columns - + Toggle Columns + toggleColumnVisibility('name')} leftSection={ columnVisibility.name !== false ? ( @@ -1632,8 +1514,8 @@ const StreamsTable = ({ onReady }) => { } > Name - - + toggleColumnVisibility('group')} leftSection={ columnVisibility.group !== false ? ( @@ -1644,8 +1526,8 @@ const StreamsTable = ({ onReady }) => { } > Group - - + toggleColumnVisibility('m3u')} leftSection={ columnVisibility.m3u !== false ? ( @@ -1656,8 +1538,8 @@ const StreamsTable = ({ onReady }) => { } > M3U - - + toggleColumnVisibility('tvg_id')} leftSection={ columnVisibility.tvg_id !== false ? ( @@ -1668,8 +1550,8 @@ const StreamsTable = ({ onReady }) => { } > TVG-ID - - + toggleColumnVisibility('stats')} leftSection={ columnVisibility.stats !== false ? ( @@ -1680,15 +1562,15 @@ const StreamsTable = ({ onReady }) => { } > Stats - - - + + } > Reset to Default - - + + diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index ffd47719..585d6f7f 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -1,15 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import API from '../../api'; import useUserAgentsStore from '../../store/userAgents'; import UserAgentForm from '../forms/UserAgent'; -import { TableHelper } from '../../helpers'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { ActionIcon, Center, Flex, - Select, Tooltip, Text, Paper, @@ -20,8 +17,20 @@ import { import { SquareMinus, SquarePen, Check, X, SquarePlus } from 'lucide-react'; import { CustomTable, useTable } from './CustomTable'; import useLocalStorage from '../../hooks/useLocalStorage'; +import { showNotification } from '../../utils/notificationUtils.js'; -const RowActions = ({ row, editUserAgent, deleteUserAgent }) => { +const deleteUserAgents = async (ids) => { + for (const id of ids) { + try { + await API.deleteUserAgent(id); + } catch { + /* empty */ + } + } +}; +const deleteUserAgent = (id) => API.deleteUserAgent(id); + +const RowActions = ({ row, editUserAgent, handleDeleteUserAgent }) => { return ( <> { variant="transparent" size="sm" color="red.9" // Red color for delete actions - onClick={() => deleteUserAgent(row.original.id)} + onClick={() => handleDeleteUserAgent(row.original.id)} > {/* Small icon size */} @@ -49,7 +58,6 @@ const RowActions = ({ row, editUserAgent, deleteUserAgent }) => { const UserAgentsTable = () => { const [userAgent, setUserAgent] = useState(null); const [userAgentModalOpen, setUserAgentModalOpen] = useState(false); - const [activeFilterValue, setActiveFilterValue] = useState('all'); const userAgents = useUserAgentsStore((state) => state.userAgents); const settings = useSettingsStore((s) => s.settings); @@ -117,35 +125,30 @@ const UserAgentsTable = () => { [] ); - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - const editUserAgent = async (userAgent = null) => { setUserAgent(userAgent); setUserAgentModalOpen(true); }; - const deleteUserAgent = async (ids) => { + const handleDeleteUserAgent = async (ids) => { if (Array.isArray(ids)) { if (ids.includes(settings.default_user_agent)) { - notifications.show({ + showNotification({ title: 'Cannot delete default user-agent', color: 'red.5', }); return; } - - await API.deleteUserAgents(ids); + await deleteUserAgents(ids); } else { if (ids == settings.default_user_agent) { - notifications.show({ + showNotification({ title: 'Cannot delete default user-agent', color: 'red.5', }); return; } - - await API.deleteUserAgent(ids); + await deleteUserAgent(ids); } }; @@ -154,12 +157,6 @@ const UserAgentsTable = () => { setUserAgentModalOpen(false); }; - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } - }, []); - const renderHeaderCell = (header) => { switch (header.id) { default: @@ -178,7 +175,7 @@ const UserAgentsTable = () => { ); } diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 7ec9504c..541452c7 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -26,6 +26,9 @@ import ConfirmationDialog from '../ConfirmationDialog'; import useLocalStorage from '../../hooks/useLocalStorage'; import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js'; +const deleteUser = (id) => { + return API.deleteUser(id); +}; const XCPasswordCell = ({ getValue }) => { const [isVisible, setIsVisible] = useState(false); const customProps = getValue() || {}; @@ -67,7 +70,7 @@ const XCPasswordCell = ({ getValue }) => { ); }; -const UserRowActions = ({ theme, row, editUser, deleteUser }) => { +const UserRowActions = ({ theme, row, editUser, handleDeleteUser }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const authUser = useAuthStore((s) => s.user); @@ -76,8 +79,8 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => { }, [row.original, editUser]); const onDelete = useCallback(() => { - deleteUser(row.original.id); - }, [row.original.id, deleteUser]); + handleDeleteUser(row.original.id); + }, [row.original.id, handleDeleteUser]); const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -138,7 +141,7 @@ const UsersTable = () => { setIsLoading(true); setDeleting(true); try { - await API.deleteUser(id); + await deleteUser(id); } finally { setDeleting(false); setIsLoading(false); @@ -151,7 +154,7 @@ const UsersTable = () => { setUserModalOpen(true); }, []); - const deleteUser = useCallback( + const handleDeleteUser = useCallback( async (id) => { const user = users.find((u) => u.id === id); setUserToDelete(user); @@ -317,12 +320,12 @@ const UsersTable = () => { theme={theme} row={row} editUser={editUser} - deleteUser={deleteUser} + handleDeleteUser={handleDeleteUser} /> ), }, ], - [theme, editUser, deleteUser, fullDateFormat, fullDateTimeFormat] + [theme, editUser, handleDeleteUser, fullDateFormat, fullDateTimeFormat] ); const closeUserForm = () => { diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx index b5529a57..591b99dc 100644 --- a/frontend/src/components/tables/VODLogosTable.jsx +++ b/frontend/src/components/tables/VODLogosTable.jsx @@ -6,7 +6,6 @@ import { Button, Center, Checkbox, - Flex, Group, Image, LoadingOverlay, @@ -20,12 +19,12 @@ import { Tooltip, useMantineTheme, } from '@mantine/core'; -import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react'; +import { ExternalLink, Trash, SquareMinus } from 'lucide-react'; import useVODLogosStore from '../../store/vodLogos'; import useLocalStorage from '../../hooks/useLocalStorage'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../../utils/notificationUtils.js'; const VODLogoRowActions = ({ theme, row, deleteLogo }) => { const [tableSize] = useLocalStorage('table-size', 'default'); @@ -79,8 +78,8 @@ export default function VODLogosTable() { const [paginationString, setPaginationString] = useState(''); const [isCleaningUp, setIsCleaningUp] = useState(false); const [unusedLogosCount, setUnusedLogosCount] = useState(0); - const [loadingUnusedCount, setLoadingUnusedCount] = useState(false); const tableRef = React.useRef(null); + useEffect(() => { fetchVODLogos({ page: currentPage, @@ -93,14 +92,11 @@ export default function VODLogosTable() { // Fetch the total count of unused logos useEffect(() => { const fetchUnusedCount = async () => { - setLoadingUnusedCount(true); try { const count = await getUnusedLogosCount(); setUnusedLogosCount(count); } catch (error) { console.error('Failed to fetch unused logos count:', error); - } finally { - setLoadingUnusedCount(false); } }; @@ -157,21 +153,21 @@ export default function VODLogosTable() { try { if (deleteTarget.length === 1) { await deleteVODLogo(deleteTarget[0]); - notifications.show({ + showNotification({ title: 'Success', message: 'VOD logo deleted successfully', color: 'green', }); } else { await deleteVODLogos(deleteTarget); - notifications.show({ + showNotification({ title: 'Success', message: `${deleteTarget.length} VOD logos deleted successfully`, color: 'green', }); } } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error.message || 'Failed to delete VOD logos', color: 'red', @@ -193,7 +189,7 @@ export default function VODLogosTable() { setIsCleaningUp(true); try { const result = await cleanupUnusedVODLogos(); - notifications.show({ + showNotification({ title: 'Success', message: `Cleaned up ${result.deleted_count} unused VOD logos`, color: 'green', @@ -202,7 +198,7 @@ export default function VODLogosTable() { const newCount = await getUnusedLogosCount(); setUnusedLogosCount(newCount); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error.message || 'Failed to cleanup unused VOD logos', color: 'red', @@ -315,7 +311,7 @@ export default function VODLogosTable() { const usageParts = []; if (movie_count > 0) { usageParts.push( - `${movie_count} movie${movie_count !== 1 ? 's' : ''}` + `${movie_count} ${movie_count !== 1 ? 'movies' : 'movie'}` ); } if (series_count > 0) { @@ -325,7 +321,7 @@ export default function VODLogosTable() { const label = usageParts.length === 1 ? usageParts[0] - : `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`; + : `${totalUsage} ${totalUsage !== 1 ? 'items' : 'item'}`; return ( Date: Sat, 27 Jun 2026 01:39:31 -0700 Subject: [PATCH 30/56] Extracted shared component --- .../src/components/tables/M3uTableUtils.jsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 frontend/src/components/tables/M3uTableUtils.jsx diff --git a/frontend/src/components/tables/M3uTableUtils.jsx b/frontend/src/components/tables/M3uTableUtils.jsx new file mode 100644 index 00000000..226912a1 --- /dev/null +++ b/frontend/src/components/tables/M3uTableUtils.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Center, Group, Text } from '@mantine/core'; +import { + ArrowDownWideNarrow, + ArrowUpDown, + ArrowUpNarrowWide, +} from 'lucide-react'; + +export const makeHeaderCellRenderer = (sorting, onSortingChange) => (header) => { + let sortingIcon = ArrowUpDown; + if (sorting[0]?.id === header.id) { + sortingIcon = + sorting[0].desc === false ? ArrowUpNarrowWide : ArrowDownWideNarrow; + } + + return ( + + + {header.column.columnDef.header} + + {header.column.columnDef.sortable && ( +
+ {React.createElement(sortingIcon, { + onClick: () => onSortingChange(header.id), + size: 14, + })} +
+ )} +
+ ); +}; + +export const makeSortingChangeHandler = + (sorting, setSorting, onDataSort) => (column) => { + const sortField = sorting[0]?.id; + const sortDirection = sorting[0]?.desc; + + const newSorting = []; + if (sortField === column) { + if (sortDirection === false) { + newSorting[0] = { id: column, desc: true }; + } + // third click → clear (empty array) + } else { + newSorting[0] = { id: column, desc: false }; + } + + setSorting(newSorting); + if (newSorting.length > 0) { + onDataSort(newSorting[0].id, newSorting[0].desc); + } + }; + From 4eb4b9c221809d7e14377a2769312ef30158750c Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:51:06 -0700 Subject: [PATCH 31/56] Added tests for components --- .../__tests__/StreamConnectionCard.test.jsx | 4 + .../__tests__/VodConnectionCard.test.jsx | 3 +- .../__tests__/ChannelTableHeader.test.jsx | 693 +++++++++ .../__tests__/EditableCell.test.jsx | 10 +- .../__tests__/CustomTable.test.jsx | 230 +++ .../__tests__/CustomTableBody.test.jsx | 325 ++++ .../__tests__/CustomTableHeader.test.jsx | 304 ++++ .../MultiSelectHeaderWrapper.test.jsx | 360 +++++ .../CustomTable/__tests__/index.test.jsx | 846 +++++++++++ .../__tests__/ChannelTableStreams.test.jsx | 796 ++++++++++ .../tables/__tests__/ChannelsTable.test.jsx | 1070 +++++++++++++ .../tables/__tests__/EPGsTable.test.jsx | 1347 +++++++++++++++++ .../tables/__tests__/LogosTable.test.jsx | 1054 +++++++++++++ .../tables/__tests__/M3UsTable.test.jsx | 1260 +++++++++++++++ .../__tests__/OutputProfilesTable.test.jsx | 524 +++++++ .../__tests__/StreamProfilesTable.test.jsx | 540 +++++++ .../tables/__tests__/StreamsTable.test.jsx | 833 ++++++++++ .../tables/__tests__/UserAgentsTable.test.jsx | 348 +++++ .../tables/__tests__/UsersTable.test.jsx | 674 +++++++++ .../tables/__tests__/VODLogosTable.test.jsx | 1026 +++++++++++++ 20 files changed, 12242 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx create mode 100644 frontend/src/components/tables/CustomTable/__tests__/CustomTable.test.jsx create mode 100644 frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx create mode 100644 frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx create mode 100644 frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx create mode 100644 frontend/src/components/tables/CustomTable/__tests__/index.test.jsx create mode 100644 frontend/src/components/tables/__tests__/ChannelTableStreams.test.jsx create mode 100644 frontend/src/components/tables/__tests__/ChannelsTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/EPGsTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/LogosTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/M3UsTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/StreamsTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/UsersTable.test.jsx create mode 100644 frontend/src/components/tables/__tests__/VODLogosTable.test.jsx diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx index bde17ac1..5d43a74e 100644 --- a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -19,12 +19,16 @@ vi.mock('../../../store/useVideoStore', () => ({ vi.mock('../../../store/users.jsx', () => ({ default: vi.fn(), })); +vi.mock('../../../store/outputProfiles.jsx', () => ({ + default: vi.fn(), +})); // ── dateTimeUtils ───────────────────────────────────────────────────────────── vi.mock('../../../utils/dateTimeUtils.js', () => ({ toFriendlyDuration: vi.fn(() => '1h 23m'), formatExactDuration: vi.fn((s) => `${s.toFixed(1)} seconds`), useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })), + formatDuration: vi.fn(() => '5m 30s'), })); // ── networkUtils ────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx index 7b0014e7..6013ff31 100644 --- a/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx @@ -7,6 +7,7 @@ vi.mock('../../../utils/dateTimeUtils.js', () => ({ fromNow: vi.fn(() => '5 minutes ago'), toFriendlyDuration: vi.fn((secs) => (secs ? `${secs}s` : null)), useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })), + formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)), })); // ── VodConnectionCardUtils ──────────────────────────────────────────────────── @@ -18,8 +19,6 @@ vi.mock('../../../utils/cards/VodConnectionCardUtils.js', () => ({ currentTime: 0, percentage: 0, })), - formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)), - formatTime: vi.fn((secs) => `${secs}s`), getEpisodeDisplayTitle: vi.fn(() => 'S01E02 — Pilot'), getEpisodeSubtitle: vi.fn(() => ['Test Series', 'Season 1']), getMovieDisplayTitle: vi.fn(() => 'Test Movie (2022)'), diff --git a/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx b/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx new file mode 100644 index 00000000..6979046b --- /dev/null +++ b/frontend/src/components/tables/ChannelsTable/__tests__/ChannelTableHeader.test.jsx @@ -0,0 +1,693 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() })); +vi.mock('../../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../../utils/tables/ChannelsTableUtils.js', () => ({ + addChannelProfile: vi.fn(), + deleteChannelProfile: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../../forms/AssignChannelNumbers', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../forms/GroupManager', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../modals/ProfileModal', () => ({ + default: ({ opened, onClose }) => + opened ? ( +
+ +
+ ) : null, + renderProfileOption: vi.fn(() => () => null), +})); + +vi.mock('../../../modals/EPGMatchModal', () => ({ + default: ({ opened, onClose }) => + opened ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color, variant }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), + Item: ({ children, onClick, disabled }) => ( + + ), + Divider: () =>
, + Label: ({ children }) =>
{children}
, + } + ), + MenuDivider: () =>
, + MenuDropdown: ({ children }) => ( +
{children}
+ ), + MenuItem: ({ children, onClick, disabled }) => ( + + ), + MenuLabel: ({ children }) =>
{children}
, + MenuTarget: ({ children }) =>
{children}
, + Popover: ({ children, opened }) => ( +
+ {children} +
+ ), + PopoverDropdown: ({ children }) => ( +
{children}
+ ), + PopoverTarget: ({ children }) =>
{children}
, + Select: ({ value, onChange, data }) => ( + + ), + Text: ({ children, c }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder }) => ( + + ), + Tooltip: ({ children, label }) =>
{children}
, + useMantineTheme: () => ({ + tailwind: { + green: { 5: 'green.5' }, + yellow: { 5: 'yellow.5' }, + }, + palette: { custom: {} }, + }), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDown01: () => , + Binary: () => , + CircleCheck: () => , + EllipsisVertical: () => , + Eye: () => , + EyeOff: () => , + Filter: () => , + Lock: () => , + LockOpen: () => , + Pin: () => , + PinOff: () => , + Settings: () => , + Square: () => , + SquareCheck: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../../store/channels'; +import useChannelsTableStore from '../../../../store/channelsTable'; +import useAuthStore from '../../../../store/auth'; +import useWarningsStore from '../../../../store/warnings'; +import * as ChannelsTableUtils from '../../../../utils/tables/ChannelsTableUtils.js'; +import ChannelTableHeader from '../ChannelTableHeader'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const ADMIN = 10; +const STANDARD = 1; + +const makeProfiles = () => ({ + 0: { id: 0, name: 'All Channels' }, + 1: { id: 1, name: 'Profile A' }, + 2: { id: 2, name: 'Profile B' }, +}); + +const makeTable = (overrides = {}) => ({ + headerPinned: false, + setHeaderPinned: vi.fn(), + selectedTableIds: [], + setSelectedTableIds: vi.fn(), + ...overrides, +}); + +const makeDefaultProps = (overrides = {}) => ({ + rows: [], + editChannel: vi.fn(), + deleteChannels: vi.fn(), + selectedTableIds: [], + table: makeTable(), + showDisabled: true, + setShowDisabled: vi.fn(), + showOnlyStreamlessChannels: false, + setShowOnlyStreamlessChannels: vi.fn(), + showOnlyStaleChannels: false, + setShowOnlyStaleChannels: vi.fn(), + showOnlyOverriddenChannels: false, + setShowOnlyOverriddenChannels: vi.fn(), + visibilityFilter: 'active', + setVisibilityFilter: vi.fn(), + ...overrides, +}); + +const setupMocks = ({ + userLevel = ADMIN, + profiles = makeProfiles(), + selectedProfileId = '0', + isUnlocked = false, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), +} = {}) => { + const mockSetSelectedProfileId = vi.fn(); + const mockSetIsUnlocked = vi.fn(); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + profiles, + selectedProfileId, + setSelectedProfileId: mockSetSelectedProfileId, + }) + ); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + isUnlocked, + setIsUnlocked: mockSetIsUnlocked, + }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: { user_level: userLevel } }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + return { mockSetSelectedProfileId, mockSetIsUnlocked }; +}; + +describe('ChannelTableHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ChannelsTableUtils.addChannelProfile).mockResolvedValue(undefined); + vi.mocked(ChannelsTableUtils.deleteChannelProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the profile select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('profile-select')).toBeInTheDocument(); + }); + + it('renders profile options in the select', () => { + setupMocks(); + render(); + expect(screen.getByRole('option', { name: 'All Channels' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Profile A' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Profile B' })).toBeInTheDocument(); + }); + + it('renders the Edit button', () => { + setupMocks(); + render(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + }); + + it('renders the Delete button', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('renders the Add button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add')).toBeInTheDocument(); + }); + + it('does not show Editing Mode text when not unlocked', () => { + setupMocks({ isUnlocked: false }); + render(); + expect(screen.queryByText('Editing Mode')).not.toBeInTheDocument(); + }); + + it('shows Editing Mode text when unlocked', () => { + setupMocks({ isUnlocked: true }); + render(); + expect(screen.getByText('Editing Mode')).toBeInTheDocument(); + }); + }); + + // ── Profile select ───────────────────────────────────────────────────────── + + describe('profile select', () => { + it('calls setSelectedProfileId when profile is changed', () => { + const { mockSetSelectedProfileId } = setupMocks(); + render(); + const select = screen.getByTestId('profile-select'); + fireEvent.change(select, { target: { value: '1' } }); + expect(mockSetSelectedProfileId).toHaveBeenCalledWith('1'); + }); + }); + + // ── Filter menu ──────────────────────────────────────────────────────────── + + describe('filter menu', () => { + it('calls setShowDisabled when Hide/Show Disabled is clicked', () => { + const props = makeDefaultProps({ showDisabled: true }); + setupMocks({ selectedProfileId: '1' }); + render(); + fireEvent.click(screen.getByText('Hide Disabled')); + expect(props.setShowDisabled).toHaveBeenCalledWith(false); + }); + + it('shows "Show Disabled" when showDisabled is false', () => { + const props = makeDefaultProps({ showDisabled: false }); + setupMocks(); + render(); + expect(screen.getByText('Show Disabled')).toBeInTheDocument(); + }); + + it('calls setShowOnlyStreamlessChannels when Only Empty Channels is clicked', () => { + const props = makeDefaultProps({ showOnlyStreamlessChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Only Empty Channels')); + expect(props.setShowOnlyStreamlessChannels).toHaveBeenCalledWith(true); + }); + + it('clears stale toggle when enabling streamless-only', () => { + const props = makeDefaultProps({ + showOnlyStreamlessChannels: false, + showOnlyStaleChannels: true, + }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Only Empty Channels')); + expect(props.setShowOnlyStaleChannels).toHaveBeenCalledWith(false); + }); + + it('calls setShowOnlyStaleChannels when Has Stale Streams is clicked', () => { + const props = makeDefaultProps({ showOnlyStaleChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Stale Streams')); + expect(props.setShowOnlyStaleChannels).toHaveBeenCalledWith(true); + }); + + it('clears streamless toggle when enabling stale-only', () => { + const props = makeDefaultProps({ + showOnlyStaleChannels: false, + showOnlyStreamlessChannels: true, + }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Stale Streams')); + expect(props.setShowOnlyStreamlessChannels).toHaveBeenCalledWith(false); + }); + + it('calls setShowOnlyOverriddenChannels when Has Overrides is clicked', () => { + const props = makeDefaultProps({ showOnlyOverriddenChannels: false }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Has Overrides')); + expect(props.setShowOnlyOverriddenChannels).toHaveBeenCalledWith(true); + }); + + it('calls setVisibilityFilter with "hidden" when Hidden Only is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Hidden Only')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('hidden'); + }); + + it('calls setVisibilityFilter with "all" when Show All is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Show All')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('all'); + }); + + it('calls setVisibilityFilter with "active" when Active Only is clicked', () => { + const props = makeDefaultProps(); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Active Only')); + expect(props.setVisibilityFilter).toHaveBeenCalledWith('active'); + }); + }); + + // ── Edit / Delete / Add buttons ──────────────────────────────────────────── + + describe('edit / delete / add buttons', () => { + it('Edit button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Edit')).toBeDisabled(); + }); + + it('Edit button is enabled when rows are selected and user is admin', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + expect(screen.getByText('Edit')).not.toBeDisabled(); + }); + + it('Edit button is disabled for non-admin users even with selection', () => { + setupMocks({ userLevel: STANDARD }); + render( + + ); + expect(screen.getByText('Edit')).toBeDisabled(); + }); + + it('calls editChannel when Edit is clicked', () => { + const editChannel = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Edit')); + expect(editChannel).toHaveBeenCalled(); + }); + + it('Delete button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeDisabled(); + }); + + it('calls deleteChannels when Delete is clicked', () => { + const deleteChannels = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Delete')); + expect(deleteChannels).toHaveBeenCalled(); + }); + + it('Add button is disabled for non-admin users', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Add')).toBeDisabled(); + }); + + it('calls editChannel with forceAdd option when Add is clicked', () => { + const editChannel = vi.fn(); + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Add')); + expect(editChannel).toHaveBeenCalledWith(null, { forceAdd: true }); + }); + }); + + // ── Overflow menu (ellipsis) ─────────────────────────────────────────────── + + describe('overflow menu', () => { + it('calls setHeaderPinned when Pin/Unpin Headers is clicked', () => { + const setHeaderPinned = vi.fn(); + const table = makeTable({ headerPinned: false, setHeaderPinned }); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Pin Headers')); + expect(setHeaderPinned).toHaveBeenCalledWith(true); + }); + + it('shows "Unpin Headers" when headerPinned is true', () => { + const table = makeTable({ headerPinned: true, setHeaderPinned: vi.fn() }); + setupMocks(); + render(); + expect(screen.getByText('Unpin Headers')).toBeInTheDocument(); + }); + + it('calls setIsUnlocked when Lock/Unlock Table is clicked', () => { + const { mockSetIsUnlocked } = setupMocks({ isUnlocked: false }); + render(); + fireEvent.click(screen.getByText('Unlock for Editing')); + expect(mockSetIsUnlocked).toHaveBeenCalledWith(true); + }); + + it('shows "Lock Table" when isUnlocked is true', () => { + setupMocks({ isUnlocked: true }); + render(); + expect(screen.getByText('Lock Table')).toBeInTheDocument(); + }); + + it('Assign #s menu item is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Assign #s').closest('button')).toBeDisabled(); + }); + + it('opens AssignChannelNumbersForm when Assign #s is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Assign #s')); + expect(screen.getByTestId('assign-numbers-modal')).toBeInTheDocument(); + }); + + it('closes AssignChannelNumbersForm when onClose fires', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + fireEvent.click(screen.getByText('Assign #s')); + fireEvent.click(screen.getByText('Close Assign')); + expect(screen.queryByTestId('assign-numbers-modal')).not.toBeInTheDocument(); + }); + + it('opens EPGMatchModal when Auto-Match EPG is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Auto-Match EPG')); + expect(screen.getByTestId('epg-match-modal')).toBeInTheDocument(); + }); + + it('shows selected count in Auto-Match label when rows are selected', () => { + setupMocks({ userLevel: ADMIN }); + render( + + ); + expect(screen.getByText('Auto-Match (2 selected)')).toBeInTheDocument(); + }); + + it('opens GroupManager when Edit Groups is clicked', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Edit Groups')); + expect(screen.getByTestId('group-manager-modal')).toBeInTheDocument(); + }); + + it('closes GroupManager when onClose fires', () => { + setupMocks({ userLevel: ADMIN }); + render(); + fireEvent.click(screen.getByText('Edit Groups')); + fireEvent.click(screen.getByText('Close Group Manager')); + expect(screen.queryByTestId('group-manager-modal')).not.toBeInTheDocument(); + }); + }); + + // ── CreateProfilePopover ─────────────────────────────────────────────────── + + describe('CreateProfilePopover', () => { + it('calls addChannelProfile with the typed name on submit', async () => { + setupMocks({ userLevel: ADMIN }); + render(); + + const input = screen.getByTestId('text-input'); + fireEvent.change(input, { target: { value: 'New Profile' } }); + + // Click the CircleCheck action icon inside the popover dropdown + const actionIcons = screen.getAllByTestId('action-icon'); + // The submit button is the one inside the popover dropdown (last small one) + const submitIcon = actionIcons.find((btn) => + btn.querySelector('[data-testid="icon-circle-check"]') + ); + fireEvent.click(submitIcon); + + await waitFor(() => { + expect(ChannelsTableUtils.addChannelProfile).toHaveBeenCalledWith({ + name: 'New Profile', + }); + }); + }); + }); + + // ── Delete profile ───────────────────────────────────────────────────────── + + describe('delete profile', () => { + it('opens confirmation dialog when deleteProfile is triggered and warning not suppressed', async () => { + const isWarningSuppressed = vi.fn(() => false); + setupMocks({ isWarningSuppressed }); + + // We need to trigger deleteProfile — it's called by the ProfileModal's + // onDeleteProfile callback; we can spy on renderProfileOption to invoke it + // directly. Instead, we verify the confirmation dialog renders when the + // warning is not suppressed by calling executeDeleteProfile via the dialog. + // We simulate this via the ConfirmationDialog confirm flow. + render(); + // Dialog is not open by default + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannelProfile directly when warning is suppressed', async () => { + const isWarningSuppressed = vi.fn(() => true); + setupMocks({ isWarningSuppressed }); + render(); + // When warning suppressed, executeDeleteProfile runs immediately + // (tested indirectly - no dialog shown) + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannelProfile when confirmation dialog is confirmed', async () => { + const isWarningSuppressed = vi.fn(() => false); + setupMocks({ isWarningSuppressed }); + + // We can't easily open the dialog without triggering deleteProfile from + // a child; render the component and verify the ConfirmationDialog is + // wired with the correct title. + render(); + // The dialog title should reference "Profile Deletion" when opened + // This verifies the dialog props are set correctly + expect( + screen.queryByText('Confirm Profile Deletion') + ).not.toBeInTheDocument(); + }); + }); + + // ── ProfileModal ─────────────────────────────────────────────────────────── + + describe('ProfileModal', () => { + it('does not show ProfileModal on initial render', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('profile-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Unlock for Editing disabled for non-admin ────────────────────────────── + + describe('admin-only controls', () => { + it('Unlock for Editing menu item is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Unlock for Editing').closest('button')).toBeDisabled(); + }); + + it('Edit Groups is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Edit Groups').closest('button')).toBeDisabled(); + }); + + it('Auto-Match EPG is disabled for non-admin', () => { + setupMocks({ userLevel: STANDARD }); + render(); + expect(screen.getByText('Auto-Match EPG').closest('button')).toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx b/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx index a67cbe35..f004509a 100644 --- a/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx +++ b/frontend/src/components/tables/ChannelsTable/__tests__/EditableCell.test.jsx @@ -8,12 +8,16 @@ vi.mock('../../../../utils/notificationUtils.js', () => ({ })); // Mock other heavy dependencies so the import doesn't pull in stores. -vi.mock('../../../../api', () => ({ default: {} })); vi.mock('../../../../store/channelsTable', () => ({ default: { getState: vi.fn() } })); vi.mock('../../../../store/logos', () => ({ default: vi.fn() })); vi.mock('../../../../utils/forms/ChannelUtils.js', () => ({ - OVERRIDABLE_FIELDS: new Set(['name', 'channel_number', 'tvg_id']), - normalizeFieldValue: (v) => v, + requeryChannels: vi.fn(), + updateChannel: vi.fn(), +})); +vi.mock('../../../../utils/tables/ChannelsTableUtils.js', () => ({ + buildInlinePatch: vi.fn(), + getEpgOptions: vi.fn(), + getLogoOptions: vi.fn(), })); import { notifyInlineSaveError } from '../EditableCell.jsx'; diff --git a/frontend/src/components/tables/CustomTable/__tests__/CustomTable.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/CustomTable.test.jsx new file mode 100644 index 00000000..b9620452 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/CustomTable.test.jsx @@ -0,0 +1,230 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CustomTable from '../CustomTable'; + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../CustomTableHeader', () => ({ + default: ({ headerPinned, enableDragDrop }) => ( +
+ ), +})); + +vi.mock('../CustomTableBody', () => ({ + default: ({ enableDragDrop }) => ( +
+ ), +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style }) => ( +
+ {children} +
+ ), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeHeader = (id, size, grow = false, minSize = 50) => ({ + id, + getSize: () => size, + column: { + columnDef: { grow, minSize }, + }, +}); + +const makeTable = (overrides = {}) => { + const headers = [makeHeader('col1', 100), makeHeader('col2', 200)]; + + return { + tableSize: 'default', + filters: {}, + allRowIds: [], + headerCellRenderFns: {}, + selectedTableIds: [], + tableCellProps: vi.fn(), + headerPinned: false, + enableDragDrop: false, + expandedRowIds: [], + expandedRowRenderer: null, + bodyCellRenderFns: {}, + getRowStyles: vi.fn(), + selectedTableIdsSet: new Set(), + handleRowClickRef: { current: vi.fn() }, + onSelectAllChange: null, + renderBodyCell: null, + getState: () => ({ columnSizing: {} }), + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + getRowModel: () => ({ rows: [] }), + ...overrides, + }; +}; + +describe('CustomTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders CustomTableHeader and CustomTableBody', () => { + render(); + expect(screen.getByTestId('custom-table-header')).toBeInTheDocument(); + expect(screen.getByTestId('custom-table-body')).toBeInTheDocument(); + }); + + it('applies default table-size class when tableSize is default', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-default'); + }); + + it('applies compact table-size class when tableSize is compact', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-compact'); + }); + + it('applies large table-size class when tableSize is large', () => { + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-large'); + }); + + it('uses default table size when tableSize is undefined', () => { + const table = makeTable(); + table.tableSize = undefined; + render(); + const box = screen.getByTestId('table-box'); + expect(box.className).toContain('table-size-default'); + }); + }); + + // ── Min table width ──────────────────────────────────────────────────────── + + describe('minTableWidth calculation', () => { + it('calculates minTableWidth from fixed-width columns', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('col2', 200, false), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('300px'); + }); + + it('uses minSize for grow columns instead of full size', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('grow-col', 500, true, 80), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + // col1 (100) + grow-col minSize (80) = 180 + expect(box.style.minWidth).toBe('180px'); + }); + + it('returns minWidth of 0 when no header groups exist', () => { + const table = makeTable({ + getHeaderGroups: () => [], + getFlatHeaders: () => [], + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('0px'); + }); + + it('returns minWidth of 0 when header group has no headers', () => { + const table = makeTable({ + getHeaderGroups: () => [{ headers: [] }], + getFlatHeaders: () => [], + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.minWidth).toBe('0px'); + }); + }); + + // ── Column size CSS vars ─────────────────────────────────────────────────── + + describe('columnSizeVars', () => { + it('injects CSS custom properties for fixed-width columns', () => { + const headers = [ + makeHeader('col1', 120, false), + makeHeader('col2', 80, false), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.getPropertyValue('--header-col1-size')).toBe('120px'); + expect(box.style.getPropertyValue('--header-col2-size')).toBe('80px'); + }); + + it('does not inject CSS custom properties for grow columns', () => { + const headers = [ + makeHeader('col1', 100, false), + makeHeader('grow-col', 500, true, 80), + ]; + const table = makeTable({ + getHeaderGroups: () => [{ headers }], + getFlatHeaders: () => headers, + }); + render(); + const box = screen.getByTestId('table-box'); + expect(box.style.getPropertyValue('--header-grow-col-size')).toBe(''); + expect(box.style.getPropertyValue('--header-col1-size')).toBe('100px'); + }); + }); + + // ── Props forwarding ─────────────────────────────────────────────────────── + + describe('props forwarding', () => { + it('passes headerPinned to CustomTableHeader', () => { + render(); + const header = screen.getByTestId('custom-table-header'); + expect(header.dataset.headerPinned).toBe('true'); + }); + + it('passes enableDragDrop to CustomTableHeader', () => { + render(); + const header = screen.getByTestId('custom-table-header'); + expect(header.dataset.enableDragDrop).toBe('true'); + }); + + it('passes enableDragDrop to CustomTableBody', () => { + render(); + const body = screen.getByTestId('custom-table-body'); + expect(body.dataset.enableDragDrop).toBe('true'); + }); + + it('passes false for enableDragDrop by default', () => { + render(); + expect( + screen.getByTestId('custom-table-header').dataset.enableDragDrop + ).toBe('false'); + expect( + screen.getByTestId('custom-table-body').dataset.enableDragDrop + ).toBe('false'); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx new file mode 100644 index 00000000..24ed850e --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/CustomTableBody.test.jsx @@ -0,0 +1,325 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() })); + +// ── @dnd-kit/sortable ───────────────────────────────────────────────────────── +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: vi.fn(() => ({ + attributes: { role: 'button' }, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + })), +})); + +// ── @dnd-kit/utilities ──────────────────────────────────────────────────────── +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: vi.fn(() => ''), + }, + }, +})); + +// ── @mantine/core ───────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style, onClick, onMouseDown, ...rest }) => ( +
+ {children} +
+ ), + Flex: ({ children, align, style }) => ( +
+ {children} +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + GripVertical: ({ size, opacity }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../../store/channelsTable'; +import { useSortable } from '@dnd-kit/sortable'; +import CustomTableBody from '../CustomTableBody'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeCell = (id, columnId, grow = false, maxSize = null) => ({ + id: `${id}-${columnId}`, + column: { + id: columnId, + columnDef: { + grow, + ...(maxSize && { maxSize }), + }, + }, +}); + +const makeRow = (id, cells = [], originalId = null) => ({ + id: `row-${id}`, + original: { id: originalId ?? id }, + getVisibleCells: () => cells, +}); + +const defaultProps = (overrides = {}) => { + const row1 = makeRow(1, [makeCell(1, 'name'), makeCell(1, 'actions')]); + const row2 = makeRow(2, [makeCell(2, 'name'), makeCell(2, 'actions')]); + + return { + getRowModel: vi.fn(() => ({ rows: [row1, row2] })), + expandedRowIds: [], + expandedRowRenderer: vi.fn(() =>
), + renderBodyCell: vi.fn(({ cell }) => ( + {cell.id} + )), + getRowStyles: null, + tableCellProps: null, + enableDragDrop: false, + selectedTableIdsSet: null, + handleRowClickRef: null, + ...overrides, + }; +}; + +const setupMocks = ({ isUnlocked = false } = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ isUnlocked }) + ); +}; + +describe('CustomTableBody', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders a tbody container', () => { + render(); + expect(document.querySelector('.tbody')).toBeInTheDocument(); + }); + + it('renders a row for each entry in getRowModel', () => { + render(); + expect(document.querySelectorAll('.tr')).toHaveLength(2); + }); + + it('renders no rows when getRowModel returns empty', () => { + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [] })), + }); + render(); + expect(document.querySelectorAll('.tr')).toHaveLength(0); + }); + + it('applies tr-even class to even-indexed rows', () => { + render(); + const rows = document.querySelectorAll('.tr'); + expect(rows[0].classList.contains('tr-even')).toBe(true); + }); + + it('applies tr-odd class to odd-indexed rows', () => { + render(); + const rows = document.querySelectorAll('.tr'); + expect(rows[1].classList.contains('tr-odd')).toBe(true); + }); + + it('calls renderBodyCell for each visible cell', () => { + const renderBodyCell = vi.fn(({ cell }) => {cell.id}); + render(); + // 2 rows × 2 cells each = 4 calls + expect(renderBodyCell).toHaveBeenCalledTimes(4); + }); + + it('renders td containers for each visible cell', () => { + render(); + expect(document.querySelectorAll('.td')).toHaveLength(4); + }); + }); + + // ── Row styles ───────────────────────────────────────────────────────────── + + describe('row styles', () => { + it('applies custom className from getRowStyles', () => { + const getRowStyles = vi.fn(() => ({ className: 'custom-row-class' })); + render(); + expect(document.querySelector('.custom-row-class')).toBeInTheDocument(); + }); + }); + + // ── Row click ────────────────────────────────────────────────────────────── + + describe('row click', () => { + it('calls handleRowClickRef.current with row id when row is clicked', () => { + const handleRowClick = vi.fn(); + const handleRowClickRef = { current: handleRowClick }; + const row = makeRow(1, [makeCell(1, 'name')], 42); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + handleRowClickRef, + }); + render(); + fireEvent.click(document.querySelector('.tr')); + expect(handleRowClick).toHaveBeenCalledWith(42, expect.any(Object)); + }); + + it('does not throw when handleRowClickRef is null', () => { + render( + + ); + expect(() => + fireEvent.click(document.querySelector('.tr')) + ).not.toThrow(); + }); + + it('prevents default on mousedown with shift key', () => { + render(); + const tr = document.querySelector('.tr'); + const event = new MouseEvent('mousedown', { + shiftKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + tr.dispatchEvent(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + // ── Expanded rows ────────────────────────────────────────────────────────── + + describe('expanded rows', () => { + it('renders expanded row content when row is in expandedRowIds', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() => ( +
Expanded!
+ )); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [1], + expandedRowRenderer, + }); + render(); + expect(screen.getByTestId('expanded-content')).toBeInTheDocument(); + }); + + it('does not render expanded row content when row is not expanded', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() => ( +
Expanded!
+ )); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [], + expandedRowRenderer, + }); + render(); + expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument(); + }); + + it('calls expandedRowRenderer with the row when expanded', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const expandedRowRenderer = vi.fn(() =>
); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + expandedRowIds: [1], + expandedRowRenderer, + }); + render(); + expect(expandedRowRenderer).toHaveBeenCalledWith({ row }); + }); + }); + + // ── Drag and drop ────────────────────────────────────────────────────────── + + describe('drag and drop', () => { + it('does not render grip handle when enableDragDrop is false', () => { + render(); + expect(screen.queryByTestId('grip-vertical')).not.toBeInTheDocument(); + }); + + it('does not render grip handle when enableDragDrop is true but table is locked', () => { + setupMocks({ isUnlocked: false }); + render(); + expect(screen.queryByTestId('grip-vertical')).not.toBeInTheDocument(); + }); + + it('renders grip handle when enableDragDrop is true and table is unlocked', () => { + setupMocks({ isUnlocked: true }); + vi.mocked(useSortable).mockReturnValue({ + attributes: { role: 'button' }, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + }); + const row = makeRow(1, [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + enableDragDrop: true, + }); + render(); + expect(screen.getByTestId('grip-vertical')).toBeInTheDocument(); + }); + + it('calls useSortable with row id', () => { + const row = makeRow('abc', [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + }); + render(); + expect(useSortable).toHaveBeenCalledWith( + expect.objectContaining({ id: 'row-abc' }) + ); + }); + + it('disables useSortable when enableDragDrop is false', () => { + const row = makeRow(1, [makeCell(1, 'name')], 1); + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + enableDragDrop: false, + }); + render(); + expect(useSortable).toHaveBeenCalledWith( + expect.objectContaining({ disabled: true }) + ); + }); + }); + + // ── Memoization comparator ───────────────────────────────────────────────── + + describe('MemoizedTableRow comparator', () => { + it('does not re-render when row.original reference is unchanged', () => { + const renderBodyCell = vi.fn(({ cell }) => {cell.id}); + const original = { id: 1 }; + const row = { id: 'row-1', original, getVisibleCells: () => [] }; + const props = defaultProps({ + getRowModel: vi.fn(() => ({ rows: [row] })), + renderBodyCell, + }); + const { rerender } = render(); + const callCount = renderBodyCell.mock.calls.length; + + // Rerender with same original reference + rerender(); + expect(renderBodyCell.mock.calls.length).toBe(callCount); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx new file mode 100644 index 00000000..b29bf7ef --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/CustomTableHeader.test.jsx @@ -0,0 +1,304 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../../store/channelsTable', () => ({ default: vi.fn() })); + +// ── @tanstack/react-table ───────────────────────────────────────────────────── +vi.mock('@tanstack/react-table', () => ({ + flexRender: vi.fn((content) => + typeof content === 'string' ? content : content?.() + ), +})); + +// ── MultiSelectHeaderWrapper ────────────────────────────────────────────────── +vi.mock('../MultiSelectHeaderWrapper', () => ({ + default: ({ children }) => ( +
{children}
+ ), +})); + +// ── @mantine/core ───────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, className, style, 'data-header-pinned': pinned }) => ( +
+ {children} +
+ ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, indeterminate, onChange, size }) => ( + { + if (el) el.indeterminate = !!indeterminate; + }} + onChange={onChange} + data-size={size} + /> + ), + Flex: ({ children, align, style }) => ( +
+ {children} +
+ ), +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../../store/channelsTable'; +import CustomTableHeader from '../CustomTableHeader'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a minimal header group structure that CustomTableHeader expects. + * Each header entry maps an id to a column definition. + */ +const makeHeader = ( + id, + { grow = false, maxSize, canResize = false, isResizing = false, style } = {} +) => ({ + id, + column: { + id, + columnDef: { id, header: id.toUpperCase(), grow, maxSize, style }, + getCanResize: () => canResize, + getIsResizing: () => isResizing, + }, + getContext: () => ({}), + getResizeHandler: () => vi.fn(), + getSize: () => 100, +}); + +const makeHeaderGroups = (headers) => [{ id: 'hg-0', headers }]; + +const defaultProps = (overrides = {}) => ({ + getHeaderGroups: () => + makeHeaderGroups([makeHeader('name'), makeHeader('status')]), + allRowIds: ['1', '2', '3'], + selectedTableIds: [], + headerCellRenderFns: {}, + onSelectAllChange: vi.fn(), + tableCellProps: vi.fn(() => ({})), + headerPinned: true, + enableDragDrop: false, + ...overrides, +}); + +const setupStore = ({ isUnlocked = false } = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ isUnlocked }) + ); +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('CustomTableHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupStore(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders a thead element', () => { + render(); + expect(screen.getByTestId('thead')).toBeInTheDocument(); + }); + + it('renders header cells for each header in the group', () => { + render( + + makeHeaderGroups([makeHeader('name'), makeHeader('status')]), + })} + /> + ); + // flexRender returns the header string (id.toUpperCase()) for unknown headers + expect(screen.getByText('NAME')).toBeInTheDocument(); + expect(screen.getByText('STATUS')).toBeInTheDocument(); + }); + + it('wraps each cell in MultiSelectHeaderWrapper', () => { + render(); + expect( + screen.getAllByTestId('multi-select-wrapper').length + ).toBeGreaterThan(0); + }); + }); + + // ── headerPinned ────────────────────────────────────────────────────────── + + describe('headerPinned', () => { + it('sets data-header-pinned="true" when headerPinned is true', () => { + render(); + expect(screen.getByTestId('thead')).toHaveAttribute( + 'data-header-pinned', + 'true' + ); + }); + + it('sets data-header-pinned="false" when headerPinned is false', () => { + render(); + expect(screen.getByTestId('thead')).toHaveAttribute( + 'data-header-pinned', + 'false' + ); + }); + }); + + // ── select column ───────────────────────────────────────────────────────── + + describe('select column checkbox', () => { + const selectHeaderGroups = () => makeHeaderGroups([makeHeader('select')]); + + it('renders checkbox for the select column', () => { + render( + selectHeaderGroups(), + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).toBeInTheDocument(); + }); + + it('checkbox is unchecked when no rows are selected', () => { + render( + selectHeaderGroups(), + allRowIds: ['1', '2'], + selectedTableIds: [], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked(); + }); + + it('checkbox is unchecked when allRowIds is empty', () => { + render( + selectHeaderGroups(), + allRowIds: [], + selectedTableIds: [], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).not.toBeChecked(); + }); + + it('checkbox is checked when all rows are selected', () => { + render( + selectHeaderGroups(), + allRowIds: ['1', '2'], + selectedTableIds: ['1', '2'], + })} + /> + ); + expect(screen.getByTestId('select-all-checkbox')).toBeChecked(); + }); + + it('calls onSelectAllChange when checkbox is changed', () => { + const onSelectAllChange = vi.fn(); + render( + selectHeaderGroups(), + onSelectAllChange, + })} + /> + ); + fireEvent.click(screen.getByTestId('select-all-checkbox')); + expect(onSelectAllChange).toHaveBeenCalled(); + }); + }); + + // ── custom headerCellRenderFns ───────────────────────────────────────────── + + describe('headerCellRenderFns', () => { + it('uses custom render function when provided for a column id', () => { + const customRender = vi.fn(() => ( + Custom Name + )); + render( + makeHeaderGroups([makeHeader('name')]), + headerCellRenderFns: { name: customRender }, + })} + /> + ); + expect(customRender).toHaveBeenCalled(); + expect(screen.getByTestId('custom-header')).toBeInTheDocument(); + }); + + it('falls back to flexRender when no custom render fn provided', () => { + render( + makeHeaderGroups([makeHeader('name')]), + headerCellRenderFns: {}, + })} + /> + ); + expect(screen.getByText('NAME')).toBeInTheDocument(); + }); + }); + + // ── resize handle ───────────────────────────────────────────────────────── + + describe('resize handle', () => { + it('renders resize handle when column canResize is true', () => { + render( + + makeHeaderGroups([makeHeader('name', { canResize: true })]), + })} + /> + ); + // The resizer div is rendered — check for the class + const resizers = document.querySelectorAll('.resizer'); + expect(resizers.length).toBeGreaterThan(0); + }); + + it('does not render resize handle when column canResize is false', () => { + render( + + makeHeaderGroups([makeHeader('name', { canResize: false })]), + })} + /> + ); + const resizers = document.querySelectorAll('.resizer'); + expect(resizers.length).toBe(0); + }); + + it('applies isResizing class when column is being resized', () => { + render( + + makeHeaderGroups([ + makeHeader('name', { canResize: true, isResizing: true }), + ]), + })} + /> + ); + expect(document.querySelector('.resizer.isResizing')).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx new file mode 100644 index 00000000..74870c39 --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/MultiSelectHeaderWrapper.test.jsx @@ -0,0 +1,360 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; + +// ── Mantine core mock ────────────────────────────────────────────────────────── +// We need MultiSelect to be identifiable by reference (element.type === MultiSelect), +// so we export it from the mock so the component under test can compare against it. +vi.mock('@mantine/core', async () => { + const MockMultiSelect = ({ + value = [], + data = [], + onChange, + label + }) => ( +
+ {label && } + onChange?.(e.target.value ? [e.target.value] : [])} + /> + {(data || []).map((opt) => ( + + ))} +
+ ); + MockMultiSelect.displayName = 'MultiSelect'; + + return { + MultiSelect: MockMultiSelect, + Box: ({ children, style, ...props }) => ( +
+ {children} +
+ ), + Flex: ({ children, style }) => ( +
+ {children} +
+ ), + Pill: ({ + children, + onRemove, + onClick, + withRemoveButton, + removeButtonProps, + }) => ( + + {children} + {withRemoveButton && ( + + )} + + ), + Tooltip: ({ children, label }) => ( +
+ {label} + {children} +
+ ), + }; +}); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import { MultiSelect } from '@mantine/core'; +import MultiSelectHeaderWrapper from '../MultiSelectHeaderWrapper'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeData = (n = 3) => + Array.from({ length: n }, (_, i) => ({ + value: `val-${i}`, + label: `Label ${i}`, + })); + +describe('MultiSelectHeaderWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Non-MultiSelect passthrough ──────────────────────────────────────────── + + describe('non-MultiSelect children', () => { + it('renders a plain div child unchanged', () => { + render( + +
Hello
+
+ ); + expect(screen.getByTestId('plain-child')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); + + it('renders a plain text node unchanged', () => { + render( + {'just text'} + ); + expect(screen.getByText('just text')).toBeInTheDocument(); + }); + + it('recursively passes through nested non-MultiSelect elements', () => { + render( + +
+ Nested +
+
+ ); + expect(screen.getByTestId('nested')).toBeInTheDocument(); + }); + }); + + // ── MultiSelect with no selections ──────────────────────────────────────── + + describe('MultiSelect with no selections', () => { + it('renders the MultiSelect directly when value is empty', () => { + render( + + + + ); + expect(screen.getByTestId('multiselect')).toBeInTheDocument(); + }); + + it('does not render pills when value is empty', () => { + render( + + + + ); + expect(screen.queryByTestId('pill')).not.toBeInTheDocument(); + }); + + it('does not render a tooltip when value is empty', () => { + render( + + + + ); + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument(); + }); + + it('handles undefined value as no selections', () => { + render( + + + + ); + expect(screen.queryByTestId('pill')).not.toBeInTheDocument(); + }); + }); + + // ── MultiSelect with one selection ──────────────────────────────────────── + + describe('MultiSelect with one selection', () => { + const data = makeData(3); + const value = ['val-0']; + + it('renders a pill showing the first selected label', () => { + render( + + + + ); + const pill = screen.getByTestId('pill'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveTextContent('Label 0'); + }); + + it('renders exactly one pill for a single selection', () => { + render( + + + + ); + expect(screen.getAllByTestId('pill')).toHaveLength(1); + }); + + it('renders a tooltip wrapping the pill area', () => { + render( + + + + ); + expect(screen.getByTestId('tooltip')).toBeInTheDocument(); + }); + + it('still renders the underlying MultiSelect', () => { + render( + + + + ); + expect(screen.getByTestId('multiselect')).toBeInTheDocument(); + }); + + it('falls back to the raw value when label is not found in data', () => { + render( + + + + ); + const pill = screen.getByTestId('pill'); + expect(pill).toBeInTheDocument(); + expect(pill).toHaveTextContent('unknown-val'); + }); + + it('calls onChange with remaining values when the remove button is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + fireEvent.click(screen.getByTestId('pill-remove')); + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + // ── MultiSelect with multiple selections ────────────────────────────────── + + describe('MultiSelect with multiple selections', () => { + const data = makeData(5); + const value = ['val-0', 'val-1', 'val-2']; + + it('renders two pills: first label and overflow count', () => { + render( + + + + ); + expect(screen.getAllByTestId('pill')).toHaveLength(2); + }); + + it('shows the first label in the first pill', () => { + render( + + + + ); + const pills = screen.getAllByTestId('pill'); + expect(pills[0]).toHaveTextContent('Label 0'); + }); + + it('shows "+N" overflow count in the second pill', () => { + render( + + + + ); + // 3 selections → "+2" + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('calls onChange with slice(1) when first pill remove is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + fireEvent.click(removeButtons[0]); + expect(onChange).toHaveBeenCalledWith(['val-1', 'val-2']); + }); + + it('calls onChange with [] when overflow pill remove is clicked', () => { + const onChange = vi.fn(); + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + fireEvent.click(removeButtons[1]); + expect(onChange).toHaveBeenCalledWith([]); + }); + }); + + // ── Tooltip with more than 10 selections ────────────────────────────────── + + describe('tooltip overflow for > 10 selections', () => { + it('shows "+N more" text in tooltip area when more than 10 values are selected', () => { + const data = makeData(15); + const value = data.map((d) => d.value); + + render( + + + + ); + expect(screen.getByText('+5 more')).toBeInTheDocument(); + }); + + it('does not show "+N more" when selections are 10 or fewer', () => { + const data = makeData(10); + const value = data.map((d) => d.value); + + render( + + + + ); + expect(screen.queryByText(/more$/)).not.toBeInTheDocument(); + }); + }); + + // ── Recursive enhancement ────────────────────────────────────────────────── + + describe('recursive MultiSelect enhancement', () => { + it('enhances a MultiSelect nested inside a wrapper div', () => { + render( + +
+ +
+
+ ); + expect(screen.getByTestId('pill')).toBeInTheDocument(); + expect(screen.getByTestId('pill')).toHaveTextContent('Label 0'); + }); + }); + + // ── onChange absence ────────────────────────────────────────────────────── + + describe('onChange not provided', () => { + it('does not throw when onChange is undefined and remove is clicked', () => { + render( + + + + ); + expect(() => + fireEvent.click(screen.getByTestId('pill-remove')) + ).not.toThrow(); + }); + + it('does not throw when clearAll is triggered without onChange', () => { + render( + + + + ); + const removeButtons = screen.getAllByTestId('pill-remove'); + expect(() => fireEvent.click(removeButtons[1])).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx b/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx new file mode 100644 index 00000000..cfbe315e --- /dev/null +++ b/frontend/src/components/tables/CustomTable/__tests__/index.test.jsx @@ -0,0 +1,846 @@ +import React from 'react'; +import { + renderHook, + act, + render, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── @tanstack/react-table ────────────────────────────────────────────────────── +vi.mock('@tanstack/react-table', () => ({ + getCoreRowModel: vi.fn(() => 'mock-core-row-model'), + useReactTable: vi.fn(), + flexRender: vi.fn(() => ), +})); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../../hooks/useTablePreferences', () => ({ + default: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../CustomTable', () => ({ + default: () =>
, +})); + +vi.mock('../CustomTableHeader', () => ({ + default: () =>
, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Center: ({ children, style, onClick }) => ( +
+ {children} +
+ ), + Checkbox: ({ checked, onChange }) => ( + {})} + /> + ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronRight: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import { useTable } from '../'; +import { useReactTable, flexRender } from '@tanstack/react-table'; +import useTablePreferences from '../../../../hooks/useTablePreferences'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupMocks = ({ headerPinned = false, tableSize = 'default' } = {}) => { + vi.mocked(useReactTable).mockReturnValue({ + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + }); + vi.mocked(useTablePreferences).mockReturnValue({ + headerPinned, + setHeaderPinned: vi.fn(), + tableSize, + setTableSize: vi.fn(), + }); +}; + +const makeRow = (id) => ({ original: { id } }); + +const makeCell = (columnId) => ({ + column: { id: columnId, columnDef: {} }, + getContext: () => ({}), +}); + +const makeClickEvent = (overrides = {}) => ({ + shiftKey: false, + ctrlKey: false, + metaKey: false, + target: { closest: () => null }, + ...overrides, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('useTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up any body class/style mutations made by keyboard handlers + document.body.classList.remove('shift-key-active'); + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + document.body.style.removeProperty('-ms-user-select'); + document.body.style.removeProperty('cursor'); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('starts with an empty selectedTableIds array', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + expect(result.current.selectedTableIds).toEqual([]); + }); + + it('starts with an empty expandedRowIds array', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + expect(result.current.expandedRowIds).toEqual([]); + }); + + it('returns renderBodyCell as a function', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(typeof result.current.renderBodyCell).toBe('function'); + }); + + it('exposes allRowIds in the returned object', () => { + setupMocks(); + const allRowIds = [10, 20, 30]; + const { result } = renderHook(() => + useTable({ allRowIds, columns: [], data: [] }) + ); + expect(result.current.allRowIds).toEqual(allRowIds); + }); + + it('returns headerPinned and tableSize from useTablePreferences', () => { + setupMocks({ headerPinned: true, tableSize: 'compact' }); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('compact'); + }); + + it('passes headerCellRenderFns through to the returned object', () => { + setupMocks(); + const headerCellRenderFns = { name: vi.fn() }; + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [], headerCellRenderFns }) + ); + expect(result.current.headerCellRenderFns).toBe(headerCellRenderFns); + }); + + it('defaults to an empty bodyCellRenderFns when not provided', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + expect(result.current.bodyCellRenderFns).toEqual({}); + }); + }); + + // ── Keyboard event handling ──────────────────────────────────────────────── + + describe('keyboard event handling', () => { + it('adds shift-key-active class and disables text selection on Shift keydown', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(true); + expect(document.body.style.userSelect).toBe('none'); + }); + + it('removes shift-key-active class and restores text selection on Shift keyup', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + fireEvent.keyUp(window, { key: 'Shift' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + expect(document.body.style.userSelect).toBe(''); + }); + + it('removes shift-key-active class on window blur', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'Shift' }); + fireEvent.blur(window); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + }); + + it('does not add shift-key-active class for non-Shift keydown', () => { + setupMocks(); + renderHook(() => useTable({ allRowIds: [], columns: [], data: [] })); + + fireEvent.keyDown(window, { key: 'a' }); + + expect(document.body.classList.contains('shift-key-active')).toBe(false); + }); + }); + + // ── onSelectAllChange ────────────────────────────────────────────────────── + + describe('onSelectAllChange', () => { + it('selects all allRowIds when checked', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + + expect(result.current.selectedTableIds).toEqual([1, 2, 3]); + }); + + it('clears selectedTableIds when unchecked', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + await act(async () => { + result.current.onSelectAllChange({ target: { checked: false } }); + }); + + expect(result.current.selectedTableIds).toEqual([]); + }); + + it('calls onRowSelectionChange callback with all ids when selecting all', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + + expect(onRowSelectionChange).toHaveBeenCalledWith([1, 2]); + }); + + it('calls onRowSelectionChange with empty array when deselecting all', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.onSelectAllChange({ target: { checked: true } }); + }); + await act(async () => { + result.current.onSelectAllChange({ target: { checked: false } }); + }); + + expect(onRowSelectionChange).toHaveBeenLastCalledWith([]); + }); + }); + + // ── updateSelectedTableIds ───────────────────────────────────────────────── + + describe('updateSelectedTableIds', () => { + it('updates selectedTableIds to the given array', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([2, 3]); + }); + + expect(result.current.selectedTableIds).toEqual([2, 3]); + }); + + it('calls onRowSelectionChange with the new ids', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2, 3], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + + expect(onRowSelectionChange).toHaveBeenCalledWith([1]); + }); + + it('does not throw when onRowSelectionChange is not provided', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + await expect( + act(async () => result.current.updateSelectedTableIds([1])) + ).resolves.not.toThrow(); + }); + }); + + // ── handleRowClickRef ────────────────────────────────────────────────────── + + describe('handleRowClickRef', () => { + it('does nothing when the click target is an interactive element', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + const button = document.createElement('button'); + await act(async () => { + result.current.handleRowClickRef.current(1, { + shiftKey: true, + ctrlKey: false, + metaKey: false, + target: { + closest: (sel) => (sel.includes('button') ? button : null), + }, + }); + }); + + expect(onRowSelectionChange).not.toHaveBeenCalled(); + }); + + it('ctrl+click adds an unselected row to selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toContain(2); + }); + + it('ctrl+click removes an already-selected row from selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([2]); + }); + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + + expect(result.current.selectedTableIds).not.toContain(2); + }); + + it('meta+click adds an unselected row to selectedTableIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }) + ); + + await act(async () => { + result.current.handleRowClickRef.current( + 3, + makeClickEvent({ metaKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toContain(3); + }); + + it('plain click (no modifier key) does not change selection', async () => { + setupMocks(); + const onRowSelectionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1, 2], + columns: [], + data: [], + onRowSelectionChange, + }) + ); + + await act(async () => { + result.current.handleRowClickRef.current(1, makeClickEvent()); + }); + + expect(onRowSelectionChange).not.toHaveBeenCalled(); + }); + + it('shift+click selects the range between lastClickedId and the clicked row', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3, 4, 5], columns: [], data: [] }) + ); + + // Ctrl+click row 2 to establish lastClickedId + await act(async () => { + result.current.handleRowClickRef.current( + 2, + makeClickEvent({ ctrlKey: true }) + ); + }); + // Shift+click row 4 to select range [2, 3, 4] + await act(async () => { + result.current.handleRowClickRef.current( + 4, + makeClickEvent({ shiftKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toEqual( + expect.arrayContaining([2, 3, 4]) + ); + expect(result.current.selectedTableIds).toHaveLength(3); + }); + + it('shift+click preserves rows selected outside the shift-click range', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2, 3, 4, 5], columns: [], data: [] }) + ); + + // Pre-select row 1 (will be outside the upcoming range) + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + // Ctrl+click row 5 to set lastClickedId=5 (also adds 5 to selection) + await act(async () => { + result.current.handleRowClickRef.current( + 5, + makeClickEvent({ ctrlKey: true }) + ); + }); + // Shift+click row 3 → range is [3, 4, 5]; row 1 is preserved + await act(async () => { + result.current.handleRowClickRef.current( + 3, + makeClickEvent({ shiftKey: true }) + ); + }); + + expect(result.current.selectedTableIds).toEqual( + expect.arrayContaining([1, 3, 4, 5]) + ); + }); + }); + + // ── renderBodyCell ───────────────────────────────────────────────────────── + + describe('renderBodyCell', () => { + describe('bodyCellRenderFns override', () => { + it('calls the custom render fn and renders its output for the matching column id', () => { + setupMocks(); + const customRenderFn = vi.fn( + () => + ); + const { result } = renderHook(() => + useTable({ + allRowIds: [], + columns: [], + data: [], + bodyCellRenderFns: { 'my-col': customRenderFn }, + }) + ); + + const row = makeRow(1); + const cell = makeCell('my-col'); + const { getByTestId } = render( + result.current.renderBodyCell({ row, cell }) + ); + + expect(customRenderFn).toHaveBeenCalledWith({ row, cell }); + expect(getByTestId('custom-cell')).toBeInTheDocument(); + }); + }); + + describe('select column', () => { + it('renders a Checkbox for the select column', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).toBeInTheDocument(); + }); + + it('checkbox is unchecked for an unselected row', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + + it('checkbox is checked when the row is pre-selected', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + await act(async () => { + result.current.updateSelectedTableIds([1]); + }); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('select'), + }) + ); + + expect(getByTestId('checkbox')).toBeChecked(); + }); + + it('checking the checkbox adds the row to selectedTableIds', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).toContain(1); + }); + + it('unchecking the checkbox removes the row from selectedTableIds', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + // Pre-select row 1 so checkbox renders as checked + await act(async () => { + tableRef.current.updateSelectedTableIds([1]); + }); + + // Click to uncheck + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).not.toContain(1); + }); + + it('checking a row does not affect other selected rows', async () => { + setupMocks(); + const tableRef = { current: null }; + + function TestWrapper() { + const table = useTable({ allRowIds: [1, 2, 3], columns: [], data: [] }); + tableRef.current = table; + return table.renderBodyCell({ row: makeRow(1), cell: makeCell('select') }); + } + + const user = userEvent.setup(); + const { getByTestId } = render(); + + // Pre-select rows 2 and 3 + await act(async () => { + tableRef.current.updateSelectedTableIds([2, 3]); + }); + + // Check row 1 + await user.click(getByTestId('checkbox')); + + expect(tableRef.current.selectedTableIds).toEqual( + expect.arrayContaining([1, 2, 3]) + ); + }); + }); + + describe('expand column', () => { + it('renders ChevronRight for a non-expanded row', () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const { getByTestId, queryByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + expect(getByTestId('icon-chevron-right')).toBeInTheDocument(); + expect(queryByTestId('icon-chevron-down')).not.toBeInTheDocument(); + }); + + it('clicking the expand cell adds the row id to expandedRowIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const rendered = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + await act(async () => { + fireEvent.click(rendered.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toContain(1); + }); + + it('clicking an already-expanded row clears expandedRowIds', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + expect(result.current.expandedRowIds).toEqual([1]); + rendered1.unmount(); + + // Click again to collapse + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered2.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toEqual([]); + }); + + it('shows ChevronDown after the row is expanded', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1], columns: [], data: [] }) + ); + + // Expand the row + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + rendered1.unmount(); + + // Re-render with updated state — should now show ChevronDown + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + expect(rendered2.getByTestId('icon-chevron-down')).toBeInTheDocument(); + expect( + rendered2.queryByTestId('icon-chevron-right') + ).not.toBeInTheDocument(); + }); + + it('only one row can be expanded at a time (prior expanded row is collapsed)', async () => { + setupMocks(); + const { result } = renderHook(() => + useTable({ allRowIds: [1, 2], columns: [], data: [] }) + ); + + // Expand row 1 + const rendered1 = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered1.getByTestId('center')); + }); + expect(result.current.expandedRowIds).toEqual([1]); + rendered1.unmount(); + + // Expand row 2 — row 1 should no longer be expanded + const rendered2 = render( + result.current.renderBodyCell({ + row: makeRow(2), + cell: makeCell('expand'), + }) + ); + await act(async () => { + fireEvent.click(rendered2.getByTestId('center')); + }); + + expect(result.current.expandedRowIds).toEqual([2]); + }); + + it('calls onRowExpansionChange with the new expanded ids', async () => { + setupMocks(); + const onRowExpansionChange = vi.fn(); + const { result } = renderHook(() => + useTable({ + allRowIds: [1], + columns: [], + data: [], + onRowExpansionChange, + }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('expand'), + }) + ); + + await act(async () => { + fireEvent.click(getByTestId('center')); + }); + + expect(onRowExpansionChange).toHaveBeenCalledWith([1]); + }); + }); + + describe('default column', () => { + it('calls flexRender for an unrecognized column id', () => { + setupMocks(); + vi.mocked(flexRender).mockReturnValue( + + ); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + + const cell = makeCell('some-data-column'); + render( + result.current.renderBodyCell({ row: makeRow(1), cell }) + ); + + expect(flexRender).toHaveBeenCalledWith( + cell.column.columnDef.cell, + cell.getContext() + ); + }); + + it('renders the output returned by flexRender', () => { + setupMocks(); + vi.mocked(flexRender).mockReturnValue( + + ); + const { result } = renderHook(() => + useTable({ allRowIds: [], columns: [], data: [] }) + ); + + const { getByTestId } = render( + result.current.renderBodyCell({ + row: makeRow(1), + cell: makeCell('data-col'), + }) + ); + + expect(getByTestId('flex-rendered')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/ChannelTableStreams.test.jsx b/frontend/src/components/tables/__tests__/ChannelTableStreams.test.jsx new file mode 100644 index 00000000..9b175ca4 --- /dev/null +++ b/frontend/src/components/tables/__tests__/ChannelTableStreams.test.jsx @@ -0,0 +1,796 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ChannelStreams from '../ChannelTableStreams'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channelsTable', () => ({ default: vi.fn() })); +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => `${path}?output_format=mpegts`), +})); + +vi.mock('../../../utils/tables/ChannelTableStreamsUtils.js', () => ({ + categorizeStreamStats: vi.fn(() => ({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + })), + formatStatKey: vi.fn((key) => key), + formatStatValue: vi.fn((key, value) => String(value)), + getChannelStreamStats: vi.fn().mockResolvedValue([]), + reorderChannelStreams: vi.fn().mockResolvedValue(undefined), +})); + +// ── @dnd-kit mocks ───────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + closestCenter: vi.fn(), + DndContext: vi.fn(({ children, onDragEnd }) => ( +
+ {children} +
+ )), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useDraggable: vi.fn(() => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + })), + useSensor: vi.fn((sensor) => sensor), + useSensors: vi.fn((...sensors) => sensors), +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + arrayMove: vi.fn((arr, from, to) => { + const next = [...arr]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }), + SortableContext: ({ children }) => ( +
{children}
+ ), + useSortable: vi.fn(() => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + })), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { Transform: { toString: vi.fn(() => '') } }, +})); + +// ── zustand/shallow ──────────────────────────────────────────────────────────── +vi.mock('zustand/shallow', () => ({ + shallow: (a, b) => a === b, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, ...rest }) => ( + + ), + Badge: ({ children, color, onClick, style }) => ( + + {children} + + ), + Box: ({ children, style, className, ...rest }) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, leftSection }) => ( + + ), + Center: ({ children }) =>
{children}
, + Collapse: ({ children, in: open }) => + open ?
{children}
: null, + Flex: ({ children, style }) => ( +
{children}
+ ), + Group: ({ children }) =>
{children}
, + Text: ({ children, size }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, + useMantineTheme: vi.fn(() => ({ + tailwind: { red: { 6: '#fa5252' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronRight: () => , + Eye: () => , + GripHorizontal: () => , + SquareMinus: ({ onClick, disabled, color }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsTableStore from '../../../store/channelsTable'; +import usePlaylistsStore from '../../../store/playlists'; +import useVideoStore from '../../../store/useVideoStore'; +import useSettingsStore from '../../../store/settings'; +import useAuthStore from '../../../store/auth'; +import { buildLiveStreamUrl } from '../../../utils/components/FloatingVideoUtils.js'; +import * as ChannelTableStreamsUtils from '../../../utils/tables/ChannelTableStreamsUtils.js'; +import { copyToClipboard } from '../../../utils'; +import { DndContext } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; + +// ── Factories ────────────────────────────────────────────────────────────────── + +const makeStream = (overrides = {}) => ({ + id: 's-1', + name: 'Stream One', + m3u_account: 'acc-1', + url: 'http://example.com/stream', + stream_hash: 'hash-abc', + quality: '1080p', + stream_stats: null, + stream_stats_updated_at: null, + is_stale: false, + ...overrides, +}); + +const makeChannel = (streams = [makeStream()]) => ({ + id: 'ch-1', + name: 'HBO', + streams, +}); + +const makePlaylists = () => [{ id: 'acc-1', name: 'My M3U' }]; + +/** Wire all store mocks with sensible defaults */ +const setupMocks = ({ + streams = [makeStream()], + playlists = makePlaylists(), + isAdmin = true, + isVideoVisible = false, + envMode = 'production', +} = {}) => { + const mockPatchChannelStreamStats = vi.fn(); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => { + if (typeof sel === 'function') { + const storeState = { + getChannelStreams: () => streams, + patchChannelStreamStats: mockPatchChannelStreamStats, + }; + return sel(storeState); + } + }); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => sel({ playlists })); + + const mockShowVideo = vi.fn(); + vi.mocked(useVideoStore).mockImplementation((sel) => { + const state = { showVideo: mockShowVideo, isVisible: isVideoVisible }; + return sel(state); + }); + // Also expose getState for the ref-based metadata read + useVideoStore.getState = vi.fn(() => ({ metadata: null })); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: envMode } }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: { user_level: isAdmin ? 10 : 1 } }) + ); + + return { mockShowVideo, mockPatchChannelStreamStats }; +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('ChannelStreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ChannelTableStreamsUtils.getChannelStreamStats).mockResolvedValue( + [] + ); + vi.mocked(ChannelTableStreamsUtils.reorderChannelStreams).mockResolvedValue( + undefined + ); + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue({ + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }); + }); + + // ── Rendering ──────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders stream name', () => { + setupMocks(); + render(); + expect(screen.getByText('Stream One')).toBeInTheDocument(); + }); + + it('renders the M3U account name badge', () => { + setupMocks(); + render(); + expect(screen.getByText('My M3U')).toBeInTheDocument(); + }); + + it('renders "Unknown" account badge when m3u_account has no matching playlist', () => { + setupMocks({ playlists: [] }); + render(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('renders the quality badge when stream has quality', () => { + setupMocks(); + render(); + expect(screen.getByText('1080p')).toBeInTheDocument(); + }); + + it('does not render quality badge when stream has no quality', () => { + const streams = [makeStream({ quality: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('1080p')).not.toBeInTheDocument(); + }); + + it('renders the URL badge when stream has a url', () => { + setupMocks(); + render(); + expect(screen.getByText('URL')).toBeInTheDocument(); + }); + + it('does not render URL badge when stream has no url', () => { + const streams = [makeStream({ url: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('URL')).not.toBeInTheDocument(); + }); + + it('renders "No Data" when there are no streams', () => { + setupMocks({ streams: [] }); + render(); + expect(screen.getByText('No Data')).toBeInTheDocument(); + }); + + it('renders the preview Eye action icon when stream has a url', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the drag handle grip icon', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-grip')).toBeInTheDocument(); + }); + + it('renders the remove stream icon', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument(); + }); + + it('renders multiple streams when channel has multiple', () => { + const streams = [ + makeStream({ id: 's-1', name: 'Stream One' }), + makeStream({ id: 's-2', name: 'Stream Two' }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Stream One')).toBeInTheDocument(); + expect(screen.getByText('Stream Two')).toBeInTheDocument(); + }); + }); + + // ── Stats fetching on mount ─────────────────────────────────────────────── + + describe('stats fetching', () => { + it('calls getChannelStreamStats on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalledWith('ch-1', null, undefined); + }); + }); + + it('passes the latest stream_stats_updated_at as the "since" cursor', async () => { + const streams = [ + makeStream({ stream_stats_updated_at: '2024-01-01T00:00:00Z' }), + ]; + setupMocks({ streams }); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalledWith('ch-1', '2024-01-01T00:00:00Z', undefined); + }); + }); + + it('calls patchChannelStreamStats when getChannelStreamStats returns updates', async () => { + const updates = [ + { + id: 's-1', + stream_stats: { resolution: '1080p' }, + stream_stats_updated_at: 't2', + }, + ]; + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue(updates); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect(mockPatchChannelStreamStats).toHaveBeenCalledWith( + 'ch-1', + updates + ); + }); + }); + + it('does not call patchChannelStreamStats when getChannelStreamStats returns empty', async () => { + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue([]); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalled(); + }); + expect(mockPatchChannelStreamStats).not.toHaveBeenCalled(); + }); + + it('does not call patchChannelStreamStats when getChannelStreamStats returns null', async () => { + vi.mocked( + ChannelTableStreamsUtils.getChannelStreamStats + ).mockResolvedValue(null); + const { mockPatchChannelStreamStats } = setupMocks(); + render(); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.getChannelStreamStats + ).toHaveBeenCalled(); + }); + expect(mockPatchChannelStreamStats).not.toHaveBeenCalled(); + }); + }); + + // ── Preview stream (Eye button) ─────────────────────────────────────────── + + describe('preview stream', () => { + it('calls showVideo with correct url when Eye button is clicked', () => { + const { mockShowVideo } = setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith( + '/proxy/ts/stream/hash-abc' + ); + expect(mockShowVideo).toHaveBeenCalledWith( + '/proxy/ts/stream/hash-abc?output_format=mpegts', + 'live', + expect.objectContaining({ name: 'Stream One', streamId: 's-1' }) + ); + }); + + it('uses stream_hash over id in the video url', () => { + const streams = [makeStream({ id: 's-99', stream_hash: 'special-hash' })]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith( + '/proxy/ts/stream/special-hash' + ); + }); + + it('uses stream id when stream_hash is absent', () => { + const streams = [makeStream({ id: 's-1', stream_hash: null })]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(buildLiveStreamUrl).toHaveBeenCalledWith('/proxy/ts/stream/s-1'); + }); + + it('prefixes hostname in dev mode', () => { + const { mockShowVideo } = setupMocks({ envMode: 'dev' }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + const calledUrl = mockShowVideo.mock.calls[0][0]; + expect(calledUrl).toContain(':5656'); + }); + + it('does not prefix hostname in production mode', () => { + const { mockShowVideo } = setupMocks({ envMode: 'production' }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + const calledUrl = mockShowVideo.mock.calls[0][0]; + expect(calledUrl).not.toContain(':5656'); + }); + }); + + // ── URL badge copy-to-clipboard ─────────────────────────────────────────── + + describe('URL badge copy to clipboard', () => { + it('calls copyToClipboard with the stream url when URL badge is clicked', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('URL')); + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith( + 'http://example.com/stream', + expect.objectContaining({ successTitle: 'URL Copied' }) + ); + }); + }); + }); + + // ── Remove stream ───────────────────────────────────────────────────────── + + describe('remove stream', () => { + it('removes stream from the list when remove icon is clicked', async () => { + const streams = [ + makeStream({ id: 's-1', name: 'Stream One' }), + makeStream({ id: 's-2', name: 'Stream Two' }), + ]; + setupMocks({ streams }); + render(); + + expect(screen.getByText('Stream One')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('icon-square-minus')[0]); + + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', ['s-2']); + }); + }); + + it('calls reorderChannelStreams with an empty array when last stream is removed', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-square-minus')); + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', []); + }); + }); + }); + + // ── Stream stats display ────────────────────────────────────────────────── + + describe('basic stream stats', () => { + it('renders video stats section when video_codec is present', () => { + const streams = [ + makeStream({ + stream_stats: { video_codec: 'h264', resolution: '1920x1080' }, + }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Video:')).toBeInTheDocument(); + }); + + it('renders resolution badge', () => { + const streams = [ + makeStream({ stream_stats: { resolution: '1920x1080' } }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('1920x1080')).toBeInTheDocument(); + }); + + it('renders video_bitrate badge with kbps suffix', () => { + const streams = [makeStream({ stream_stats: { video_bitrate: 5000 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('5000 kbps')).toBeInTheDocument(); + }); + + it('renders fps badge', () => { + const streams = [makeStream({ stream_stats: { source_fps: 29.97 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('29.97 FPS')).toBeInTheDocument(); + }); + + it('renders codec badge uppercased', () => { + const streams = [makeStream({ stream_stats: { video_codec: 'h264' } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('H264')).toBeInTheDocument(); + }); + + it('renders audio section when audio_codec is present', () => { + const streams = [makeStream({ stream_stats: { audio_codec: 'aac' } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Audio:')).toBeInTheDocument(); + expect(screen.getByText('AAC')).toBeInTheDocument(); + }); + + it('renders audio channels badge', () => { + const streams = [makeStream({ stream_stats: { audio_channels: 2 } })]; + setupMocks({ streams }); + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('renders output bitrate section when ffmpeg_output_bitrate is present', () => { + const streams = [ + makeStream({ stream_stats: { ffmpeg_output_bitrate: 3000 } }), + ]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Output Bitrate:')).toBeInTheDocument(); + expect(screen.getByText('3000 kbps')).toBeInTheDocument(); + }); + + it('renders last updated timestamp when stream_stats_updated_at is set', () => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: { video_bitrate: 5000 }, + audio: {}, + technical: {}, + other: {}, + } + ); + const streams = [ + makeStream({ + stream_stats: { video_bitrate: 5000 }, + stream_stats_updated_at: '2024-01-15T10:30:00Z', + }), + ]; + setupMocks({ streams }); + render(); + // Expand advanced stats first so the timestamp is visible + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); + }); + }); + + // ── Advanced stats toggle ───────────────────────────────────────────────── + + describe('advanced stats toggle', () => { + const makeStreamWithAdvancedStats = () => + makeStream({ + stream_stats: { video_bitrate: 4000 }, + }); + + beforeEach(() => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: { video_bitrate: 4000 }, + audio: {}, + technical: {}, + other: {}, + } + ); + }); + + it('shows "Show Advanced Stats" button when advanced stats exist', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + expect(screen.getByText('Show Advanced Stats')).toBeInTheDocument(); + }); + + it('does not show advanced stats toggle when no advanced stats exist', () => { + vi.mocked(ChannelTableStreamsUtils.categorizeStreamStats).mockReturnValue( + { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + } + ); + const streams = [makeStream({ stream_stats: null })]; + setupMocks({ streams }); + render(); + expect(screen.queryByText('Show Advanced Stats')).not.toBeInTheDocument(); + }); + + it('toggles to "Hide Advanced Stats" after clicking Show', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByText('Hide Advanced Stats')).toBeInTheDocument(); + }); + + it('opens the Collapse panel when Show Advanced Stats is clicked', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + expect(screen.queryByTestId('collapse-open')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + }); + + it('closes the Collapse panel when Hide Advanced Stats is clicked', () => { + const streams = [makeStreamWithAdvancedStats()]; + setupMocks({ streams }); + render(); + fireEvent.click(screen.getByText('Show Advanced Stats')); + fireEvent.click(screen.getByText('Hide Advanced Stats')); + expect(screen.queryByTestId('collapse-open')).not.toBeInTheDocument(); + }); + }); + + // ── DnD reorder ─────────────────────────────────────────────────────────── + + describe('drag and drop reorder', () => { + it('calls reorderChannelStreams with new order after drag end', async () => { + const streams = [ + makeStream({ id: 's-1', name: 'First' }), + makeStream({ id: 's-2', name: 'Second' }), + ]; + vi.mocked(arrayMove).mockImplementation((arr, from, to) => { + const next = [...arr]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + setupMocks({ streams }); + + // Capture the onDragEnd handler from DndContext + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-2' } }); + }); + + await waitFor(() => { + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).toHaveBeenCalledWith('ch-1', expect.any(Array)); + }); + }); + + it('does not reorder when active and over are the same', async () => { + const streams = [makeStream({ id: 's-1' }), makeStream({ id: 's-2' })]; + setupMocks({ streams }); + + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-1' } }); + }); + + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).not.toHaveBeenCalled(); + }); + + it('does not reorder when user is not an admin', async () => { + const streams = [makeStream({ id: 's-1' }), makeStream({ id: 's-2' })]; + setupMocks({ streams, isAdmin: false }); + + let capturedOnDragEnd; + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await act(async () => { + capturedOnDragEnd({ active: { id: 's-1' }, over: { id: 's-2' } }); + }); + + expect( + ChannelTableStreamsUtils.reorderChannelStreams + ).not.toHaveBeenCalled(); + }); + }); + + // ── m3u account map ─────────────────────────────────────────────────────── + + describe('m3u account map', () => { + it('handles null playlists gracefully', () => { + setupMocks({ playlists: null }); + expect(() => + render() + ).not.toThrow(); + }); + + it('handles playlists with missing ids gracefully', () => { + setupMocks({ playlists: [{ name: 'No ID Playlist' }] }); + expect(() => + render() + ).not.toThrow(); + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + }); + + // ── stale row ───────────────────────────────────────────────────────────── + + describe('stale stream row', () => { + it('applies stale-stream-row class when is_stale is true', () => { + const streams = [makeStream({ is_stale: true })]; + setupMocks({ streams }); + const { container } = render( + + ); + expect(container.querySelector('.stale-stream-row')).toBeInTheDocument(); + }); + + it('does not apply stale-stream-row class when is_stale is false', () => { + setupMocks(); + const { container } = render(); + expect( + container.querySelector('.stale-stream-row') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx new file mode 100644 index 00000000..a3e20609 --- /dev/null +++ b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx @@ -0,0 +1,1070 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── DnD Kit ──────────────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }) =>
{children}
, + PointerSensor: class PointerSensor {}, + closestCenter: vi.fn(), + useSensor: vi.fn(), + useSensors: vi.fn(() => []), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }) =>
{children}
, + verticalListSortingStrategy: vi.fn(), +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/channelsTable', () => { + const mock = vi.fn(); + mock.getState = vi.fn(() => ({ selectedChannelIds: [] })); + mock.setState = vi.fn(); + return { default: mock }; +}); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => [{}, vi.fn()]), +})); +vi.mock('../../../hooks/useSmartLogos', () => ({ + useChannelLogoSelection: vi.fn(() => ({ ensureLogosLoaded: vi.fn() })), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), + useDebounce: vi.fn((val) => val), +})); +vi.mock('../../../utils/forms/ChannelUtils.js', () => ({ + listOverriddenFields: vi.fn(() => []), + requeryChannels: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => path), +})); +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + getShowVideoUrl: vi.fn(() => '/proxy/ts/stream/uuid-1'), +})); +vi.mock('../../../utils/tables/ChannelsTableUtils.js', () => ({ + buildEPGUrl: vi.fn(() => 'http://localhost/output/epg'), + buildFetchParams: vi.fn(() => new URLSearchParams()), + buildHDHRUrl: vi.fn(() => 'http://localhost/hdhr'), + buildM3UUrl: vi.fn(() => 'http://localhost/output/m3u'), + deleteChannel: vi.fn().mockResolvedValue(undefined), + deleteChannels: vi.fn().mockResolvedValue(undefined), + epgUrlBase: 'http://localhost/output/epg', + getAllChannelIds: vi.fn().mockResolvedValue([1, 2]), + hdhrUrlBase: 'http://localhost/hdhr', + m3uUrlBase: 'http://localhost/output/m3u', + queryChannels: vi.fn().mockResolvedValue(undefined), + reorderChannel: vi.fn().mockResolvedValue(undefined), + updateProfileChannel: vi.fn().mockResolvedValue(undefined), + updateProfileChannels: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); +vi.mock('../ChannelsTable/ChannelsTableOnboarding', () => ({ + default: ({ editChannel }) => ( +
+ +
+ ), +})); +vi.mock('../ChannelsTable/ChannelTableHeader', () => ({ + default: ({ + editChannel, + deleteChannels, + showDisabled, + setShowDisabled, + showOnlyStreamlessChannels, + setShowOnlyStreamlessChannels, + showOnlyStaleChannels, + setShowOnlyStaleChannels, + showOnlyOverriddenChannels, + setShowOnlyOverriddenChannels, + }) => ( +
+ + + + + + +
+ ), +})); +vi.mock('../ChannelsTable/EditableCell', () => ({ + EditableEPGCell: () => , + EditableGroupCell: () => , + EditableLogoCell: () => , + EditableNumberCell: () => , + EditableTextCell: () => , +})); +vi.mock('../ChannelTableStreams', () => ({ + default: () =>
, +})); +vi.mock('../../forms/Channel', () => ({ + default: ({ isOpen, onClose, channel }) => + isOpen ? ( +
+ {channel?.name ?? 'new'} + +
+ ) : null, +})); +vi.mock('../../forms/ChannelBatch', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); +vi.mock('../../forms/Recording', () => ({ + default: ({ isOpen, onClose, channel }) => + isOpen ? ( +
+ {channel?.name} + +
+ ) : null, +})); +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); +vi.mock('../../LazyLogo', () => ({ + default: ({ alt }) => {alt}, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Box: ({ children, style, role, 'aria-label': ariaLabel }) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Flex: ({ children, style }) =>
{children}
, + Group: ({ children, style }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) =>
{children}
, + Label: ({ children }) =>
{children}
, + Item: ({ children, onClick, disabled, leftSection }) => ( + + ), + } + ), + MenuDropdown: ({ children }) =>
{children}
, + MenuItem: ({ children, onClick, disabled, leftSection }) => ( + + ), + MenuTarget: ({ children }) =>
{children}
, + MultiSelect: ({ value, data }) => ( + + ), + NativeSelect: ({ value, data, onChange }) => ( + + ), + NumberInput: ({ value, onChange }) => ( + onChange(Number(e.target.value))} /> + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Popover: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) =>
{children}
, + } + ), + PopoverDropdown: ({ children, onClick, onMouseDown }) => ( +
+ {children} +
+ ), + PopoverTarget: ({ children }) =>
{children}
, + Select: ({ value, onChange, data, placeholder }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, label, disabled }) => ( + + ), + Text: ({ children, style }) => ( + {children} + ), + TextInput: ({ value, placeholder, onChange, readOnly }) => ( + {})} + readOnly={readOnly} + /> + ), + Tooltip: ({ children, label }) =>
{children}
, + UnstyledButton: ({ children, onClick }) => ( + + ), + useMantineTheme: vi.fn(() => ({ + tailwind: { yellow: { 3: '#fde047' }, red: { 6: '#dc2626' }, green: { 5: '#22c55e' } }, + palette: { + custom: { greenMain: '#22c55e', indigoMain: '#6366f1', greyBorder: '#71717a' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDownWideNarrow: () => , + ArrowUpDown: () => , + ArrowUpNarrowWide: () => , + CirclePlay: () => , + Copy: () => , + EllipsisVertical: () => , + EyeOff: () => , + Pencil: () => , + ScanEye: () => , + ScreenShare: () => , + Scroll: () => , + Search: () => , + SquareMinus: () => , + SquarePen: () => , + Tv2: () => , +})); + +// ── CSS import ───────────────────────────────────────────────────────────────── +vi.mock('../table.css', () => ({})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useChannelsTableStore from '../../../store/channelsTable'; +import useAuthStore from '../../../store/auth'; +import useEPGsStore from '../../../store/epgs'; +import useSettingsStore from '../../../store/settings'; +import useVideoStore from '../../../store/useVideoStore'; +import useOutputProfilesStore from '../../../store/outputProfiles'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { useTable } from '../CustomTable'; +import { deleteChannel, deleteChannels, queryChannels, getAllChannelIds } from '../../../utils/tables/ChannelsTableUtils.js'; +import { requeryChannels } from '../../../utils/forms/ChannelUtils.js'; +import { copyToClipboard } from '../../../utils'; +import { buildLiveStreamUrl } from '../../../utils/components/FloatingVideoUtils.js'; +import { USER_LEVELS } from '../../../constants'; +import ChannelsTable from '../ChannelsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeChannel = (overrides = {}) => ({ + id: 1, + uuid: 'uuid-1', + name: 'Test Channel', + channel_number: 101, + effective_channel_number: 101, + effective_name: 'Test Channel', + streams: [{ id: 10, is_stale: false }], + hidden_from_output: false, + channel_group_id: null, + logo_id: null, + epg_data_id: null, + ...overrides, +}); + +const makeAdminUser = () => ({ id: 99, user_level: USER_LEVELS.ADMIN }); +const makeStandardUser = () => ({ id: 88, user_level: USER_LEVELS.STANDARD }); + +let capturedTableOptions = null; + +const makeDefaultTableInstance = (overrides = {}) => ({ + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [], + ...overrides, +}); + +const setupMocks = ({ + channels = [makeChannel()], + authUser = makeAdminUser(), + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + selectedProfileId = '0', + profiles = { 0: { name: 'Default', channels: new Set() } }, + pageCount = 1, + totalCount = 1, + allQueryIds = [1], + channelGroups = {}, + epgs = {}, + hasUnassignedEPGChannels = false, + tableOverrides = {}, +} = {}) => { + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + channels, + pageCount, + totalCount, + allQueryIds, + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [], + hasUnassignedEPGChannels, + setSelectedChannelIds: vi.fn(), + setExpandedChannelId: vi.fn(), + setPagination: vi.fn(), + setSorting: vi.fn(), + setAllQueryIds: vi.fn(), + }) + ); + vi.mocked(useChannelsTableStore).getState.mockReturnValue({ + selectedChannelIds: [], + }); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelIds: channels.map((c) => c.id), + profiles, + selectedProfileId, + channelGroups, + }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: authUser }) + ); + + vi.mocked(useEPGsStore).mockImplementation((sel) => + sel({ epgs, tvgsById: {}, tvgsLoaded: true }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: 'production' } }) + ); + + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo: vi.fn() }) + ); + + vi.mocked(useOutputProfilesStore).mockImplementation((sel) => + sel({ profiles: [] }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([{}, vi.fn()]); + + const tableInstance = makeDefaultTableInstance(tableOverrides); + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return tableInstance; + }); + + return { tableInstance }; +}; + +const getActionsCol = () => + capturedTableOptions.columns.find((c) => c.id === 'actions'); + +const getCol = (id) => + capturedTableOptions.columns.find((c) => c.id === id); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('ChannelsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(deleteChannel).mockResolvedValue(undefined); + vi.mocked(deleteChannels).mockResolvedValue(undefined); + vi.mocked(queryChannels).mockResolvedValue(undefined); + vi.mocked(getAllChannelIds).mockResolvedValue([1]); + vi.mocked(requeryChannels).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders "Channels" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Channels')).toBeInTheDocument(); + }); + + it('renders ChannelTableHeader', () => { + setupMocks(); + render(); + expect(screen.getByTestId('channel-table-header')).toBeInTheDocument(); + }); + + it('renders CustomTable when channels exist', () => { + setupMocks({ channels: [makeChannel()] }); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders onboarding when channels array is empty and no channelIds', async () => { + setupMocks({ + channels: [], + tableOverrides: { getRowModel: () => ({ rows: [] }) }, + }); + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelIds: [], profiles: {}, selectedProfileId: '0', channelGroups: {} }) + ); + render(); + await waitFor(() => expect(screen.getByTestId('onboarding')).toBeInTheDocument()); + }); + + it('renders pagination controls when channels exist', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + + it('does not render confirmation dialog initially', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('does not render channel form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('channel-form')).not.toBeInTheDocument(); + }); + + it('renders HDHR, M3U, and EPG link buttons', () => { + setupMocks(); + render(); + expect(screen.getByText('HDHR')).toBeInTheDocument(); + expect(screen.getByText('M3U')).toBeInTheDocument(); + expect(screen.getByText('EPG')).toBeInTheDocument(); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls queryChannels on mount', async () => { + setupMocks(); + render(); + await waitFor(() => expect(queryChannels).toHaveBeenCalled()); + }); + + it('calls getAllChannelIds on mount', async () => { + setupMocks(); + render(); + await waitFor(() => expect(getAllChannelIds).toHaveBeenCalled()); + }); + + it('calls onReady after successful fetch when tvgsLoaded is true', async () => { + setupMocks(); + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes manualPagination: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualPagination).toBe(true); + }); + + it('passes manualSorting: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes manualFiltering: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualFiltering).toBe(true); + }); + + it('passes enableRowSelection: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(true); + }); + + it('passes channels as data', () => { + const channels = [makeChannel({ id: 5 })]; + setupMocks({ channels }); + render(); + expect(capturedTableOptions.data).toBe(channels); + }); + }); + + // ── Add Channel form ─────────────────────────────────────────────────────── + + describe('Add Channel form', () => { + it('opens channel form with no channel when "Add Channel" is clicked in header', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('header-add-channel')); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + expect(screen.getByTestId('channel-form-name')).toHaveTextContent('new'); + }); + + it('closes channel form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('header-add-channel')); + fireEvent.click(screen.getByTestId('channel-form-close')); + expect(screen.queryByTestId('channel-form')).not.toBeInTheDocument(); + }); + + it('opens channel form with no channel from onboarding', async () => { + setupMocks({ + channels: [], + tableOverrides: { getRowModel: () => ({ rows: [] }) }, + }); + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelIds: [], profiles: {}, selectedProfileId: '0', channelGroups: {} }) + ); + render(); + const addBtn = await screen.findByTestId('onboarding-add'); + fireEvent.click(addBtn); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + }); + }); + + // ── ChannelRowActions: edit ──────────────────────────────────────────────── + + describe('ChannelRowActions — edit button', () => { + const renderActionCell = (channel, tableInstance) => { + const col = getActionsCol(); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + }; + + it('edit button is disabled for non-admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeStandardUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled for admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + + it('clicking edit button opens channel form populated with the channel', () => { + const channel = makeChannel({ name: 'HBO' }); + const { tableInstance } = setupMocks(); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + expect(screen.getByTestId('channel-form')).toBeInTheDocument(); + expect(screen.getByTestId('channel-form-name')).toHaveTextContent('HBO'); + }); + }); + + // ── ChannelRowActions: delete ────────────────────────────────────────────── + + describe('ChannelRowActions — delete button', () => { + const renderActionCell = (channel, tableInstance) => { + const col = getActionsCol(); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + }; + + it('delete button is disabled for non-admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeStandardUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled for admin users', () => { + const channel = makeChannel(); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + + it('opens confirmation dialog when delete is clicked and warning is not suppressed', () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Channel Deletion' + ); + }); + + it('calls deleteChannel directly when warning is suppressed', async () => { + const channel = makeChannel({ id: 7 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => true), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(7)); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('calls deleteChannel when confirm dialog is confirmed', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(5)); + }); + + it('calls requeryChannels after deleteChannel succeeds', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(requeryChannels).toHaveBeenCalled()); + }); + + it('closes dialog after confirming delete', async () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes dialog on Cancel', () => { + const channel = makeChannel({ id: 5 }); + const { tableInstance } = setupMocks({ + isWarningSuppressed: vi.fn(() => false), + }); + render(); + const { getByTestId } = renderActionCell(channel, tableInstance); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Bulk delete ──────────────────────────────────────────────────────────── + + describe('Bulk delete via ChannelTableHeader', () => { + it('opens confirmation dialog with "Bulk" in title', () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Bulk Channel Deletion' + ); + }); + + it('calls deleteChannels with selected ids when bulk confirm is clicked', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(deleteChannels).toHaveBeenCalledWith([1, 2]) + ); + }); + + it('skips dialog and calls deleteChannels directly when warning is suppressed', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds: vi.fn(), + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => true), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + await waitFor(() => + expect(deleteChannels).toHaveBeenCalledWith([1, 2]) + ); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── ChannelRowActions: preview ───────────────────────────────────────────── + + describe('ChannelRowActions — preview button', () => { + it('calls showVideo when preview button is clicked', () => { + const channel = makeChannel({ uuid: 'uuid-abc', name: 'ESPN' }); + const showVideoMock = vi.fn(); + const { tableInstance } = setupMocks(); + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo: showVideoMock }) + ); + vi.mocked(buildLiveStreamUrl).mockReturnValue('/proxy/ts/stream/uuid-abc'); + render(); + const col = getActionsCol(); + const { getByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + fireEvent.click(getByTestId('icon-circle-play').closest('button')); + expect(showVideoMock).toHaveBeenCalledWith( + expect.stringContaining('uuid-abc'), + 'live', + expect.objectContaining({ name: 'ESPN' }) + ); + }); + }); + + // ── Recording form ───────────────────────────────────────────────────────── + + describe('Recording form', () => { + it('opens recording form when Record menu item is clicked', () => { + const channel = makeChannel({ name: 'CNN', id: 3 }); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + // Record menu item is the second menu-item (after Copy URL) + const menuItems = getAllByTestId('menu-item'); + const recordItem = menuItems.find((el) => el.textContent.includes('Record')); + fireEvent.click(recordItem); + expect(screen.getByTestId('recording-form')).toBeInTheDocument(); + expect(screen.getByTestId('recording-form-channel')).toHaveTextContent('CNN'); + }); + + it('closes recording form when onClose is called', () => { + const channel = makeChannel({ name: 'CNN', id: 3 }); + const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + const menuItems = getAllByTestId('menu-item'); + const recordItem = menuItems.find((el) => el.textContent.includes('Record')); + fireEvent.click(recordItem); + fireEvent.click(screen.getByTestId('recording-form-close')); + expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument(); + }); + }); + + // ── Copy URL ─────────────────────────────────────────────────────────────── + + describe('"Copy URL" menu item', () => { + it('calls copyToClipboard when "Copy URL" is clicked', () => { + const channel = makeChannel({ uuid: 'uuid-1' }); + const { tableInstance } = setupMocks(); + render(); + const col = getActionsCol(); + const { getAllByTestId } = render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); + const menuItems = getAllByTestId('unstyled-button'); + const copyBtn = menuItems.find((el) => el.textContent.includes('Copy URL')); + fireEvent.click(copyBtn); + expect(copyToClipboard).toHaveBeenCalled(); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('renders pagination string', () => { + setupMocks({ totalCount: 50 }); + render(); + expect(screen.getByText('1 to 25 of 50')).toBeInTheDocument(); + }); + + it('clicking next page triggers fetchData with updated page', async () => { + setupMocks({ totalCount: 100, pageCount: 4 }); + render(); + fireEvent.click(screen.getByTestId('pagination-next')); + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2)); + }); + }); + + // ── enabled column ───────────────────────────────────────────────────────── + + describe('ChannelEnabledSwitch (enabled column)', () => { + const renderEnabledCell = (channel) => { + const col = getCol('enabled'); + const tableInstance = makeDefaultTableInstance({ + getState: () => ({ selectedTableIds: [] }), + }); + return render(col.cell({ row: { original: channel }, table: tableInstance })); + }; + + it('renders a Switch for the enabled column', () => { + setupMocks({ selectedProfileId: '0' }); + render(); + const { container } = renderEnabledCell(makeChannel()); + const { getByTestId } = within(container); + expect(getByTestId('switch')).toBeInTheDocument(); + }); + + it('switch is disabled when selectedProfileId is "0"', () => { + setupMocks({ selectedProfileId: '0' }); + render(); + const { container } = renderEnabledCell(makeChannel()); + const { getByTestId } = within(container); + expect(getByTestId('switch')).toBeDisabled(); + }); + }); + + // ── channel_number column ────────────────────────────────────────────────── + + describe('channel_number column', () => { + it('accessorFn returns effective_channel_number when present', () => { + setupMocks(); + render(); + const col = getCol('channel_number'); + const result = col.accessorFn({ + effective_channel_number: 999, + channel_number: 100, + }); + expect(result).toBe(999); + }); + + it('accessorFn falls back to channel_number when effective_channel_number is null', () => { + setupMocks(); + render(); + const col = getCol('channel_number'); + const result = col.accessorFn({ + effective_channel_number: null, + channel_number: 42, + }); + expect(result).toBe(42); + }); + }); + + // ── name column ──────────────────────────────────────────────────────────── + + describe('name column', () => { + it('accessorFn returns effective_name when present', () => { + setupMocks(); + render(); + const col = getCol('name'); + const result = col.accessorFn({ effective_name: 'Override Name', name: 'Original' }); + expect(result).toBe('Override Name'); + }); + + it('accessorFn falls back to name when effective_name is null', () => { + setupMocks(); + render(); + const col = getCol('name'); + const result = col.accessorFn({ effective_name: null, name: 'Original' }); + expect(result).toBe('Original'); + }); + }); + + // ── rowClassMap ──────────────────────────────────────────────────────────── + + describe('rowClassMap / getRowStyles', () => { + it('returns no-streams-row class for channels without streams', () => { + const channel = makeChannel({ id: 10, streams: [] }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toEqual({ + className: 'no-streams-row', + }); + }); + + it('returns empty object for channels with active streams', () => { + const channel = makeChannel({ + id: 11, + streams: [{ id: 20, is_stale: false }], + }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toEqual({}); + }); + + it('returns stale-streams-row class for channels with stale streams', () => { + const channel = makeChannel({ + id: 12, + streams: [{ id: 21, is_stale: true }], + }); + setupMocks({ channels: [channel] }); + render(); + const { getRowStyles } = capturedTableOptions; + expect(getRowStyles({ original: channel })).toMatchObject({ + className: expect.stringMatching(/stale/i), + }); + }); + }); + + // ── expandedRowRenderer ──────────────────────────────────────────────────── + + describe('expandedRowRenderer', () => { + it('renders ChannelTableStreams for the expanded row', () => { + const channel = makeChannel({ id: 1, streams: [{ id: 10, is_stale: false }] }); + setupMocks({ channels: [channel] }); + render(); + const { expandedRowRenderer } = capturedTableOptions; + const { getByTestId } = render( + expandedRowRenderer({ row: { id: '1', original: channel } }) + ); + expect(getByTestId('channel-table-streams')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/EPGsTable.test.jsx b/frontend/src/components/tables/__tests__/EPGsTable.test.jsx new file mode 100644 index 00000000..c1e29943 --- /dev/null +++ b/frontend/src/components/tables/__tests__/EPGsTable.test.jsx @@ -0,0 +1,1347 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import EPGsTable from '../EPGsTable'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn((val) => `formatted:${val}`), + useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY HH:mm' })), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/tables/EPGsTableUtils.js', () => ({ + deleteEpg: vi.fn().mockResolvedValue(undefined), + formatStatusText: vi.fn((s) => + s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown' + ), + getProgressInfo: vi.fn(() => null), + getProgressLabel: vi.fn(() => null), + getSortedEpgs: vi.fn((epgs) => Object.values(epgs)), + refreshEpg: vi.fn().mockResolvedValue(undefined), + updateEpg: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../M3uTableUtils.jsx', () => ({ + makeHeaderCellRenderer: vi.fn(() => (header) => ( + + {header.column.columnDef.header} + + )), + makeSortingChangeHandler: vi.fn(() => vi.fn()), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/EPG', () => ({ + default: ({ isOpen, onClose, epg }) => + isOpen ? ( +
+ {epg?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../forms/DummyEPG', () => ({ + default: ({ isOpen, onClose, epg }) => + isOpen ? ( +
+ {epg?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: ({ table }) => ( +
+ {table?.getRowModel?.().rows.map((row) => ( +
+ {row.getVisibleCells().map((cell) => ( +
+ {cell.column.id === 'actions' + ? table.bodyCellRenderFns?.actions?.({ cell, row }) + : cell.column.columnDef.cell + ? cell.column.columnDef.cell(cell.getContext()) + : cell.getValue?.()} +
+ ))} +
+ ))} +
+ ), + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ + children, + onClick, + leftSection, + rightSection, + disabled, + loading, + }) => ( + + ), + Checkbox: ({ checked, onChange, label, disabled }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), + Item: ({ children, onClick }) => ( + + ), + } + ), + MenuDropdown: ({ children }) => ( +
{children}
+ ), + MenuItem: ({ children, onClick }) => ( + + ), + MenuTarget: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Paper: ({ children, style }) =>
{children}
, + Progress: ({ value }) =>
, + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, size, c, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + colors: { red: { 6: '#fa5252' }, green: { 6: '#40c057' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + RefreshCcw: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useEPGsStore from '../../../store/epgs'; +import useWarningsStore from '../../../store/warnings'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as EPGsTableUtils from '../../../utils/tables/EPGsTableUtils.js'; +import { useTable, CustomTable } from '../CustomTable'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { makeSortingChangeHandler } from '../M3uTableUtils.jsx'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeEpg = (overrides = {}) => ({ + id: 'epg-1', + name: 'Test EPG', + source_type: 'xmltv', + url: 'http://example.com/epg.xml', + status: 'idle', + last_message: null, + is_active: true, + updated_at: '2024-01-01T12:00:00Z', + ...overrides, +}); + +const makeDummyEpg = (overrides = {}) => + makeEpg({ + id: 'epg-dummy', + name: 'Dummy EPG', + source_type: 'dummy', + ...overrides, + }); + +// Captures the useTable options passed by EPGsTable so we can invoke +// renderBodyCell and renderHeaderCell in tests. +let capturedTableOptions = null; + +/** Wire stores and the useTable spy */ +const setupMocks = ({ + epgs = { 'epg-1': makeEpg() }, + refreshProgress = {}, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + tableSize = 'default', +} = {}) => { + vi.mocked(useEPGsStore).mockImplementation((sel) => + sel({ epgs, refreshProgress }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + // Capture the options EPGsTable passes to useTable so tests can call + // renderBodyCell / renderHeaderCell manually. + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + bodyCellRenderFns: opts.bodyCellRenderFns ?? {}, + getHeaderGroups: () => [], + }; + }); +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Build a minimal row/cell pair like TanStack Table would provide */ +const makeRowContext = (epgObj) => { + const row = { + id: epgObj.id, + original: epgObj, + getIsSelected: vi.fn(() => false), + getVisibleCells: vi.fn(() => []), + }; + const cell = { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => epgObj.is_active), + row, + getContext: vi.fn(() => ({})), + }; + return { row, cell }; +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('EPGsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(EPGsTableUtils.deleteEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.refreshEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.updateEpg).mockResolvedValue(undefined); + vi.mocked(EPGsTableUtils.getSortedEpgs).mockImplementation((epgs) => + Object.values(epgs) + ); + }); + + // ── Header renders ───────────────────────────────────────────────────────── + + describe('header / top toolbar', () => { + it('renders the "EPGs" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('EPGs')).toBeInTheDocument(); + }); + + it('renders the "Add EPG" menu button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add EPG')).toBeInTheDocument(); + }); + + it('renders both menu items in the Add EPG menu', () => { + setupMocks(); + render(); + expect(screen.getByText('Standard EPG Source')).toBeInTheDocument(); + expect(screen.getByText('Dummy EPG Source')).toBeInTheDocument(); + }); + }); + + // ── Add EPG modal flows ──────────────────────────────────────────────────── + + describe('add EPG modals', () => { + it('opens the standard EPG form when "Standard EPG Source" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + expect(screen.getByTestId('epg-form')).toBeInTheDocument(); + }); + + it('passes null epg to EPGForm when creating new standard EPG', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + expect(screen.getByTestId('epg-form-epg')).toHaveTextContent('new'); + }); + + it('closes the standard EPG form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Standard EPG Source')); + fireEvent.click(screen.getByTestId('epg-form-close')); + expect(screen.queryByTestId('epg-form')).not.toBeInTheDocument(); + }); + + it('opens the dummy EPG form when "Dummy EPG Source" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + expect(screen.getByTestId('dummy-epg-form')).toBeInTheDocument(); + }); + + it('passes null epg to DummyEPGForm when creating new dummy EPG', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + expect(screen.getByTestId('dummy-epg-form-epg')).toHaveTextContent('new'); + }); + + it('closes the dummy EPG form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Dummy EPG Source')); + fireEvent.click(screen.getByTestId('dummy-epg-form-close')); + expect(screen.queryByTestId('dummy-epg-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit EPG (RowActions) ────────────────────────────────────────────────── + + describe('edit EPG via RowActions', () => { + it('opens the standard EPG form with the correct epg when edit is clicked', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + // Invoke the actions cell renderer directly + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('epg-form')).toBeInTheDocument(); + expect(screen.getByTestId('epg-form-epg')).toHaveTextContent('Test EPG'); + }); + + it('opens the dummy EPG form when editing a dummy EPG', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('dummy-epg-form')).toBeInTheDocument(); + }); + }); + + // ── Delete EPG (with confirmation) ──────────────────────────────────────── + + describe('delete EPG', () => { + it('opens the confirmation dialog when delete is clicked (warning not suppressed)', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('shows the correct title in the confirmation dialog', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm EPG Source Deletion' + ); + }); + + it('calls deleteEpg when delete is confirmed', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(EPGsTableUtils.deleteEpg).toHaveBeenCalledWith('epg-1'); + }); + }); + + it('closes the dialog after successful delete', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('closes the dialog when Cancel is clicked without deleting', () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + expect(EPGsTableUtils.deleteEpg).not.toHaveBeenCalled(); + }); + + it('skips confirmation and calls deleteEpg immediately when warning is suppressed', async () => { + const epg = makeEpg(); + setupMocks({ + epgs: { 'epg-1': epg }, + isWarningSuppressed: vi.fn(() => true), + }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.deleteEpg).toHaveBeenCalledWith('epg-1'); + }); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + // ── Refresh EPG ──────────────────────────────────────────────────────────── + + describe('refresh EPG', () => { + it('calls refreshEpg with the correct id when refresh is clicked', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + // refreshEPG passes force=false (the default) through to refreshEpg + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalledWith('epg-1', false); + }); + }); + + it('shows a notification after refreshEpg resolves', async () => { + const epg = makeEpg(); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'EPG refresh initiated' }) + ); + }); + }); + + it('disables the refresh button for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + + it('disables the refresh button for inactive EPGs', () => { + const epg = makeEpg({ is_active: false }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ + cell, + row, + }); + const { getByTestId } = render(rendered); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + }); + + // ── Toggle active (Switch) ───────────────────────────────────────────────── + + describe('toggle active', () => { + it('calls updateEpg with flipped is_active when Switch is toggled', async () => { + const epg = makeEpg({ is_active: true }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + // The switch is rendered inside the is_active column cell renderer. + // Find the column definition and call its cell renderer. + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { + original: epg, + getIsSelected: vi.fn(() => false), + }; + const cell = { getValue: vi.fn(() => epg.is_active) }; + const { getByTestId } = render(isActiveCol.cell({ row, cell })); + fireEvent.click(getByTestId('switch')); + + await waitFor(() => { + expect(EPGsTableUtils.updateEpg).toHaveBeenCalledWith( + { is_active: false }, + epg, + true + ); + }); + }); + + it('does not call updateEpg when epg object is invalid', async () => { + setupMocks(); + render(); + + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { original: { source_type: 'xmltv', id: undefined, is_active: true }, getIsSelected: vi.fn(() => false) }; + const cell = { getValue: vi.fn(() => false) }; + + // Should not throw even with an invalid (no id) epg, and should not call updateEpg + render(isActiveCol.cell({ row, cell })); + expect(EPGsTableUtils.updateEpg).not.toHaveBeenCalled(); + }); + + it('disables the Switch for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const isActiveCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + const row = { original: epg, getIsSelected: vi.fn(() => false) }; + const cell = { getValue: vi.fn(() => true) }; + const { getByTestId } = render(isActiveCol.cell({ row, cell })); + expect(getByTestId('switch')).toBeDisabled(); + }); + }); + + // ── Status cell ──────────────────────────────────────────────────────────── + + describe('status cell', () => { + const statuses = ['idle', 'fetching', 'parsing', 'error', 'success']; + statuses.forEach((status) => { + it(`renders formatted status text for status="${status}"`, () => { + const epg = makeEpg({ status }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const statusCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'status' + ); + const row = { original: epg }; + const { container } = render(statusCol.cell({ row })); + expect(container).toBeInTheDocument(); + expect(EPGsTableUtils.formatStatusText).toHaveBeenCalledWith(status); + }); + }); + + it('renders "idle" status for dummy EPG regardless of actual status', () => { + const epg = makeDummyEpg({ status: 'fetching' }); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const statusCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'status' + ); + const row = { original: epg }; + render(statusCol.cell({ row })); + expect(EPGsTableUtils.formatStatusText).toHaveBeenCalledWith('idle'); + }); + }); + + // ── Updated_at cell ──────────────────────────────────────────────────────── + + describe('updated_at cell', () => { + it('renders "Never" when updated_at is null', () => { + const epg = makeEpg({ updated_at: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const updatedCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'updated_at' + ); + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(updatedCol.cell({ cell })); + expect(getByText('Never')).toBeInTheDocument(); + }); + + it('renders the formatted date when updated_at has a value', () => { + setupMocks(); + render(); + + const updatedCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'updated_at' + ); + const cell = { getValue: vi.fn(() => '2024-01-01T12:00:00Z') }; + const { getByText } = render(updatedCol.cell({ cell })); + expect(getByText(/formatted:/)).toBeInTheDocument(); + }); + }); + + // ── Status message cell ──────────────────────────────────────────────────── + + describe('status message cell', () => { + it('returns null for dummy EPGs', () => { + const epg = makeDummyEpg(); + setupMocks({ epgs: { 'epg-dummy': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { container } = render(
{msgCol.cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('renders error message when status is error', () => { + const epg = makeEpg({ status: 'error', last_message: 'Something broke' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('Something broke')).toBeInTheDocument(); + }); + + it('renders success message when status is success and last_message is set', () => { + const epg = makeEpg({ status: 'success', last_message: 'All good' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('All good')).toBeInTheDocument(); + }); + + it('renders fallback success message when status is success and last_message is null', () => { + const epg = makeEpg({ status: 'success', last_message: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('EPG data refreshed successfully')).toBeInTheDocument(); + }); + + it('renders idle last_message when status is idle and message is set', () => { + const epg = makeEpg({ status: 'idle', last_message: 'Previous result' }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('Previous result')).toBeInTheDocument(); + }); + + it('renders progress display when refreshProgress is active', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Downloading'); + vi.mocked(EPGsTableUtils.getProgressInfo).mockReturnValue(null); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { 'epg-1': { action: 'downloading', progress: 50 } }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByTestId, getByText } = render(msgCol.cell({ row })); + expect(getByText(/Downloading: 50%/)).toBeInTheDocument(); + expect(getByTestId('progress')).toBeInTheDocument(); + }); + + it('renders speed when progress has speed', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Downloading'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'downloading', progress: 30, speed: 512 }, + }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText(/Speed: 512 KB\/s/)).toBeInTheDocument(); + }); + + it('renders additionalInfo when getProgressInfo returns a value', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue( + 'Parsing Programs' + ); + vi.mocked(EPGsTableUtils.getProgressInfo).mockReturnValue( + '5,000 / 10,000' + ); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_programs', progress: 50 }, + }, + }); + render(); + + const msgCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'last_message' + ); + const row = { original: epg }; + const { getByText } = render(msgCol.cell({ row })); + expect(getByText('5,000 / 10,000')).toBeInTheDocument(); + }); + }); + + // ── URL cell ─────────────────────────────────────────────────────────────── + + describe('URL / api_key / file_path cell', () => { + it('renders the url when present', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { original: makeEpg() }; + const cell = { getValue: vi.fn(() => 'http://example.com/epg.xml') }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('http://example.com/epg.xml')).toBeInTheDocument(); + }); + + it('falls back to password when url is empty and password is set', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { + original: { ...makeEpg(), url: null, password: 'MY-KEY-123' }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('MY-KEY-123')).toBeInTheDocument(); + }); + + it('falls back to file_path when url and api_key are both absent', () => { + setupMocks(); + render(); + + const urlCol = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'url' + ); + const row = { + original: { + ...makeEpg(), + url: null, + api_key: null, + file_path: '/data/epg.xml', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(urlCol.cell({ cell, row })); + expect(getByText('/data/epg.xml')).toBeInTheDocument(); + }); + }); + + // ── Sorting integration ──────────────────────────────────────────────────── + + describe('sorting integration', () => { + it('calls getSortedEpgs when sorting handler fires', () => { + setupMocks(); + render(); + // The real makeSortingChangeHandler is mocked; verify it was called + // with sorting state and a setter from the component + expect(makeSortingChangeHandler).toHaveBeenCalled(); + }); + }); + + // ── Data initialization from epgs store ─────────────────────────────────── + + describe('data initialization', () => { + it('passes the epgs as data to useTable (active-first, then alphabetical)', () => { + const epgs = { + 'epg-2': makeEpg({ id: 'epg-2', name: 'Zebra', is_active: false }), + 'epg-1': makeEpg({ id: 'epg-1', name: 'Alpha', is_active: true }), + }; + setupMocks({ epgs }); + render(); + // Active EPG should come first in the sorted data passed to useTable + const data = capturedTableOptions.data; + expect(data[0].is_active).toBe(true); + }); + + it('places inactive EPGs after active ones', () => { + const epgs = { + 'epg-a': makeEpg({ id: 'epg-a', name: 'Active', is_active: true }), + 'epg-b': makeEpg({ id: 'epg-b', name: 'Inactive', is_active: false }), + }; + setupMocks({ epgs }); + render(); + const data = capturedTableOptions.data; + expect(data[data.length - 1].is_active).toBe(false); + }); + + it('sorts two active EPGs alphabetically by name', () => { + const epgs = { + 'epg-z': makeEpg({ id: 'epg-z', name: 'Zebra', is_active: true }), + 'epg-a': makeEpg({ id: 'epg-a', name: 'Alpha', is_active: true }), + }; + setupMocks({ epgs }); + render(); + const data = capturedTableOptions.data; + expect(data[0].name).toBe('Alpha'); + expect(data[1].name).toBe('Zebra'); + }); + }); + + // ── Source type cell ─────────────────────────────────────────────────────── + + describe('source type cell', () => { + const getTypeCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'source_type'); + + it('renders "XMLTV" for source_type xmltv', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'xmltv') } }) + ); + expect(getByText('XMLTV')).toBeInTheDocument(); + }); + + it('renders "Schedules Direct" for source_type schedules_direct', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'schedules_direct') } }) + ); + expect(getByText('Schedules Direct')).toBeInTheDocument(); + }); + + it('renders "Custom Dummy" for source_type dummy', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'dummy') } }) + ); + expect(getByText('Custom Dummy')).toBeInTheDocument(); + }); + + it('renders the raw value for an unknown source_type', () => { + setupMocks(); + render(); + const { getByText } = render( + getTypeCol().cell({ cell: { getValue: vi.fn(() => 'unknown_type') } }) + ); + expect(getByText('unknown_type')).toBeInTheDocument(); + }); + }); + + // ── URL cell – schedules_direct scenarios ────────────────────────────────── + + describe('URL cell - schedules_direct', () => { + const getUrlCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + + it('shows "User: " for schedules_direct with a username', () => { + setupMocks(); + render(); + const row = { + original: { + ...makeEpg(), + source_type: 'schedules_direct', + username: 'myuser', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('User: myuser')).toBeInTheDocument(); + }); + + it('shows "(credentials set)" for schedules_direct with no username', () => { + setupMocks(); + render(); + const row = { + original: { + ...makeEpg(), + source_type: 'schedules_direct', + username: '', + }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('(credentials set)')).toBeInTheDocument(); + }); + + it('falls back to password when url is empty and password is set', () => { + setupMocks(); + render(); + const row = { + original: { ...makeEpg(), url: null, password: 'secret123' }, + }; + const cell = { getValue: vi.fn(() => null) }; + const { getByText } = render(getUrlCol().cell({ cell, row })); + expect(getByText('secret123')).toBeInTheDocument(); + }); + }); + + // ── Schedules Direct early refresh dialog ────────────────────────────────── + + describe('Schedules Direct early refresh', () => { + const makeRecentSdEpg = (overrides = {}) => + makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + // 30 minutes ago — well within the 2-hour window + updated_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + ...overrides, + }); + + const makeOldSdEpg = (overrides = {}) => + makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + // 3 hours ago — outside the 2-hour window + updated_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + ...overrides, + }); + + it('opens the SD early-refresh confirmation dialog when the EPG was refreshed recently', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Refresh Schedules Direct Early?' + ); + }); + + it('shows "Refresh Anyway" as the confirm label', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Refresh Anyway'); + }); + + it('skips the SD dialog and calls refreshEpg directly when EPG was refreshed more than 2 hours ago', async () => { + const epg = makeOldSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('skips the SD dialog when the SD EPG has no updated_at', async () => { + const epg = makeEpg({ + id: 'sd-1', + source_type: 'schedules_direct', + updated_at: null, + }); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls refreshEpg with force=true when "Refresh Anyway" is confirmed', async () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(EPGsTableUtils.refreshEpg).toHaveBeenCalledWith('sd-1', true); + }); + }); + + it('shows a notification after force-refreshing', async () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'EPG refresh initiated' }) + ); + }); + }); + + it('closes the SD dialog without calling refreshEpg when cancelled', () => { + const epg = makeRecentSdEpg(); + setupMocks({ epgs: { 'sd-1': epg } }); + render(); + + const { row, cell } = makeRowContext(epg); + const rendered = capturedTableOptions.bodyCellRenderFns.actions({ cell, row }); + const { getByTestId } = render(rendered); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(EPGsTableUtils.refreshEpg).not.toHaveBeenCalled(); + }); + }); + + // ── Status message cell - progress edge cases ────────────────────────────── + + describe('status message cell - progress edge cases', () => { + const getMsgCol = () => + capturedTableOptions.columns.find((c) => c.accessorKey === 'last_message'); + + it('renders nothing when getProgressLabel returns null for active progress', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue(null); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { 'epg-1': { action: 'unknown_action', progress: 50 } }, + }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('shows progress when progress.status is "in_progress" even at 100%', () => { + const epg = makeEpg(); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Processing'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'processing', progress: 100, status: 'in_progress' }, + }, + }); + render(); + + const row = { original: epg }; + const { getByText } = render(getMsgCol().cell({ row })); + expect(getByText(/Processing: 100%/)).toBeInTheDocument(); + }); + + it('shows progress for parsing_channels action when epg.status is "parsing"', () => { + const epg = makeEpg({ status: 'parsing' }); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_channels', progress: 100 }, + }, + }); + render(); + + const row = { original: epg }; + const { getByText } = render(getMsgCol().cell({ row })); + expect(getByText(/Parsing Channels: 100%/)).toBeInTheDocument(); + }); + + it('does not show progress for parsing_channels action when epg.status is not "parsing"', () => { + const epg = makeEpg({ status: 'idle' }); + vi.mocked(EPGsTableUtils.getProgressLabel).mockReturnValue('Parsing Channels'); + setupMocks({ + epgs: { 'epg-1': epg }, + refreshProgress: { + 'epg-1': { action: 'parsing_channels', progress: 100 }, + }, + }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + // condition fails (100 < 100=false, no in_progress, parsing_channels + idle=false) + // falls through to the idle-message branch; no last_message → returns null + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it('renders nothing when there is no active progress and no status message', () => { + const epg = makeEpg({ status: 'idle', last_message: null }); + setupMocks({ epgs: { 'epg-1': epg } }); + render(); + + const row = { original: epg }; + const { container } = render(
{getMsgCol().cell({ row })}
); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + }); + + // ── Table structure ──────────────────────────────────────────────────────── + + describe('table structure', () => { + it('renders the CustomTable component', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('passes enablePagination: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes manualSorting: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes renderTopToolbar: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.renderTopToolbar).toBe(false); + }); + + it('actions column size is 75 in compact mode', () => { + setupMocks({ tableSize: 'compact' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(75); + }); + + it('actions column size is 100 in default mode', () => { + setupMocks({ tableSize: 'default' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(100); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/LogosTable.test.jsx b/frontend/src/components/tables/__tests__/LogosTable.test.jsx new file mode 100644 index 00000000..995e9d84 --- /dev/null +++ b/frontend/src/components/tables/__tests__/LogosTable.test.jsx @@ -0,0 +1,1054 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/logos', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/tables/LogosTableUtils.js', () => ({ + cleanupUnusedLogos: vi.fn(), + deleteLogo: vi.fn(), + deleteLogos: vi.fn(), + generateUsageLabel: vi.fn((names, count) => `${count} channel${count !== 1 ? 's' : ''}`), + getFilteredLogos: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/Logo', () => ({ + default: ({ isOpen, onClose, logo, onSuccess }) => + isOpen ? ( +
+ {logo?.name ?? 'new'} + + + + + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + showDeleteFileOption, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + + {showDeleteFileOption && ( + + )} +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading, color, variant }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, onChange, label, disabled }) => ( + + ), + Group: ({ children, style }) =>
{children}
, + Image: ({ src, alt, fallbackSrc, style, onMouseEnter, onMouseLeave }) => ( + {alt} + ), + LoadingOverlay: ({ visible }) => (visible ?
: null), + NativeSelect: ({ value, data, onChange, style }) => ( + + ), + Pagination: ({ total, value, onChange, style }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Select: ({ value, onChange, data, style }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, size, c, style }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder, style }) => ( + + ), + Tooltip: ({ children }) =>
{children}
, + useMantineTheme: vi.fn(() => ({ + tailwind: { + yellow: { 3: '#fbbf24' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ExternalLink: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + Trash: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useLogosStore from '../../../store/logos'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as LogosTableUtils from '../../../utils/tables/LogosTableUtils.js'; +import { useTable } from '../CustomTable'; +import LogosTable from '../LogosTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'Test Logo', + url: 'http://example.com/logo.png', + cache_url: '/cached/logo.png', + channel_count: 0, + channel_names: [], + is_used: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + logos = { 1: makeLogo() }, + storeLoading = false, + tableSize = 'default', +} = {}) => { + const mockFetchAllLogos = vi.fn().mockResolvedValue(undefined); + const mockUpdateLogo = vi.fn(); + const mockAddLogo = vi.fn(); + + vi.mocked(useLogosStore).mockImplementation((sel) => { + const state = { + logos, + fetchAllLogos: mockFetchAllLogos, + updateLogo: mockUpdateLogo, + addLogo: mockAddLogo, + isLoading: storeLoading, + }; + return typeof sel === 'function' ? sel(state) : state; + }); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ suppressWarning: vi.fn(), isWarningSuppressed: vi.fn(() => false) }) + ); + + vi.mocked(useLocalStorage).mockImplementation((key, defaultVal) => { + if (key === 'table-size') return [tableSize, vi.fn()]; + if (key === 'logos-page-size') return [25, vi.fn()]; + return [defaultVal, vi.fn()]; + }); + + vi.mocked(LogosTableUtils.getFilteredLogos).mockReturnValue(Object.values(logos)); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + }; + }); + + return { mockFetchAllLogos, mockUpdateLogo, mockAddLogo }; +}; + +// ── Column helpers ───────────────────────────────────────────────────────────── +const getActionsCell = (logo) => { + const col = capturedTableOptions.columns.find((c) => c.id === 'actions'); + return col.cell({ row: { id: logo.id, original: logo } }); +}; + +const getSelectCell = (logo) => { + const col = capturedTableOptions.columns.find((c) => c.id === 'select'); + return col.cell({ row: { id: logo.id, original: logo } }); +}; + +const getSelectHeader = () => { + const col = capturedTableOptions.columns.find((c) => c.id === 'select'); + return col.header({ table: {} }); +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('LogosTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(LogosTableUtils.deleteLogo).mockResolvedValue(undefined); + vi.mocked(LogosTableUtils.deleteLogos).mockResolvedValue(undefined); + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 3, + local_files_deleted: 0, + }); + window.open = vi.fn(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the name filter input', () => { + setupMocks(); + render(); + expect(screen.getByPlaceholderText('Filter by name...')).toBeInTheDocument(); + }); + + it('renders the usage filter select with all options', () => { + setupMocks(); + render(); + const select = screen.getByTestId('usage-select'); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('all'); + }); + + it('renders the "Add Logo" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Logo')).toBeInTheDocument(); + }); + + it('"Delete" button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete').closest('button')).toBeDisabled(); + }); + + it('"Cleanup Unused" button is disabled when there are no unused logos', () => { + setupMocks({ logos: { 1: makeLogo({ is_used: true }) } }); + render(); + expect(screen.getByText(/Cleanup Unused/).closest('button')).toBeDisabled(); + }); + + it('"Cleanup Unused" button shows count and is enabled when unused logos exist', () => { + setupMocks({ + logos: { + 1: makeLogo({ id: 1, is_used: false }), + 2: makeLogo({ id: 2, name: 'Logo 2', is_used: false }), + }, + }); + render(); + const btn = screen.getByText(/Cleanup Unused/).closest('button'); + expect(btn).not.toBeDisabled(); + expect(btn).toHaveTextContent('(2)'); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders loading overlay when store is loading', () => { + setupMocks({ storeLoading: true }); + render(); + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); + }); + + it('does not render loading overlay when not loading', () => { + setupMocks({ storeLoading: false }); + render(); + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + it('renders pagination controls', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + + it('renders the page size selector', () => { + setupMocks(); + render(); + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + + it('shows "1 to 1 of 1" pagination string for a single logo', () => { + setupMocks({ logos: { 1: makeLogo() } }); + render(); + expect(screen.getByText('1 to 1 of 1')).toBeInTheDocument(); + }); + + it('"Prev" pagination button is disabled on first page', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination-prev')).toBeDisabled(); + }); + + it('"Next" pagination button is disabled when on the only page', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination-next')).toBeDisabled(); + }); + }); + + // ── Add Logo ─────────────────────────────────────────────────────────────── + + describe('Add Logo', () => { + it('opens logo form with no selected logo when "Add Logo" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + expect(screen.getByTestId('logo-form')).toBeInTheDocument(); + expect(screen.getByTestId('logo-form-name')).toHaveTextContent('new'); + }); + + it('closes the logo form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit Logo ────────────────────────────────────────────────────────────── + + describe('Edit Logo', () => { + it('opens the logo form populated with the logo when edit action is clicked', () => { + const logo = makeLogo({ name: 'My Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getActionIcon } = render(getActionsCell(logo)); + fireEvent.click(getActionIcon('icon-square-pen').closest('button')); + + expect(screen.getByTestId('logo-form')).toBeInTheDocument(); + expect(screen.getByTestId('logo-form-name')).toHaveTextContent('My Logo'); + }); + + it('closes the logo form when onClose is called after edit', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getActionIcon } = render(getActionsCell(logo)); + fireEvent.click(getActionIcon('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('logo-form-close')); + + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + }); + + // ── onLogoSuccess ────────────────────────────────────────────────────────── + + describe('onLogoSuccess', () => { + it('calls updateLogo when result type is "update"', () => { + const { mockUpdateLogo } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-update')); + expect(mockUpdateLogo).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, name: 'Updated' }) + ); + }); + + it('calls addLogo when result type is "create"', () => { + const { mockAddLogo } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-create')); + expect(mockAddLogo).toHaveBeenCalledWith( + expect.objectContaining({ id: 99, name: 'New' }) + ); + }); + + it('calls fetchAllLogos when the logo is missing from the result', async () => { + const { mockFetchAllLogos } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-no-logo')); + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('does nothing when result is null', () => { + const { mockUpdateLogo, mockAddLogo, mockFetchAllLogos } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Logo')); + fireEvent.click(screen.getByTestId('logo-form-success-null')); + expect(mockUpdateLogo).not.toHaveBeenCalled(); + expect(mockAddLogo).not.toHaveBeenCalled(); + expect(mockFetchAllLogos).not.toHaveBeenCalled(); + }); + }); + + // ── Delete single logo ───────────────────────────────────────────────────── + + describe('Delete single logo', () => { + it('opens "Delete Logo" confirmation dialog when delete action icon is clicked', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Logo'); + }); + + it('calls deleteLogo with the logo id on confirm', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, false) + ); + }); + + it('calls fetchAllLogos after a successful delete', async () => { + const logo = makeLogo(); + const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows a success notification after delete', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ) + ); + }); + + it('shows an error notification when deleteLogo rejects', async () => { + vi.mocked(LogosTableUtils.deleteLogo).mockRejectedValue(new Error('fail')); + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes the dialog on cancel without calling deleteLogo', () => { + const logo = makeLogo(); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(LogosTableUtils.deleteLogo).not.toHaveBeenCalled(); + }); + + it('shows "Also Delete Files" option for /data/logos URLs', () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-ok-with-files')).toBeInTheDocument(); + }); + + it('does not show "Also Delete Files" option for external URLs', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + + expect(screen.queryByTestId('confirm-ok-with-files')).not.toBeInTheDocument(); + }); + + it('calls deleteLogo with deleteFile=true when confirmed with file deletion', async () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { container: actionsContainer } = render(getActionsCell(logo)); + fireEvent.click(within(actionsContainer).getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok-with-files')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogo).toHaveBeenCalledWith(logo.id, true) + ); + }); + }); + + // ── Bulk delete ──────────────────────────────────────────────────────────── + + describe('Bulk delete', () => { + it('"Delete" button is disabled when no rows are selected', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete').closest('button')).toBeDisabled(); + }); + + it('enables "Delete" button and shows count after a row is selected', () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + + expect(screen.getByText(/Delete \(1\)/).closest('button')).not.toBeDisabled(); + }); + + it('opens "Delete Multiple Logos" dialog after selecting rows and clicking Delete', () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Delete Multiple Logos'); + }); + + it('calls deleteLogos with selected ids on confirm', async () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(LogosTableUtils.deleteLogos).toHaveBeenCalledWith([1], false) + ); + }); + + it('calls fetchAllLogos after bulk delete', async () => { + const logo = makeLogo({ id: 1 }); + const { mockFetchAllLogos } = setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows success notification after bulk delete', async () => { + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green' }) + ) + ); + }); + + it('shows error notification when deleteLogos rejects', async () => { + vi.mocked(LogosTableUtils.deleteLogos).mockRejectedValue(new Error('fail')); + const logo = makeLogo({ id: 1 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const { getByTestId: getCheckbox } = render(getSelectCell(logo)); + fireEvent.click(getCheckbox('checkbox')); + fireEvent.click(screen.getByText(/Delete \(1\)/)); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ) + ); + }); + + it('select-all header checkbox selects all logos', () => { + const logos = { + 1: makeLogo({ id: 1 }), + 2: makeLogo({ id: 2, name: 'Logo 2' }), + }; + setupMocks({ logos }); + render(); + + const { getByTestId: getHeaderCheckbox } = render(getSelectHeader()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + expect(screen.getByText(/Delete \(2\)/).closest('button')).not.toBeDisabled(); + }); + }); + + // ── Cleanup unused ───────────────────────────────────────────────────────── + + describe('Cleanup unused', () => { + const withUnused = () => ({ logos: { 1: makeLogo({ is_used: false }) } }); + + it('opens the cleanup confirmation dialog when the button is clicked', () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup Unused Logos'); + }); + + it('calls cleanupUnusedLogos(false) on confirm', async () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(false) + ); + }); + + it('calls cleanupUnusedLogos(true) when confirmed with file deletion', async () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok-with-files')); + await waitFor(() => + expect(LogosTableUtils.cleanupUnusedLogos).toHaveBeenCalledWith(true) + ); + }); + + it('calls fetchAllLogos after successful cleanup', async () => { + const { mockFetchAllLogos } = setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(mockFetchAllLogos).toHaveBeenCalled()); + }); + + it('shows "Cleanup Complete" success notification with deleted count', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 3, + local_files_deleted: 0, + }); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: 'green', + title: 'Cleanup Complete', + message: expect.stringContaining('3'), + }) + ) + ); + }); + + it('includes local file count in success notification when files were deleted', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockResolvedValue({ + deleted_count: 5, + local_files_deleted: 2, + }); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('2 local files'), + }) + ) + ); + }); + + it('shows "Cleanup Failed" error notification when cleanup rejects', async () => { + vi.mocked(LogosTableUtils.cleanupUnusedLogos).mockRejectedValue(new Error('Server error')); + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Cleanup Failed' }) + ) + ); + }); + + it('closes the cleanup dialog on cancel', () => { + setupMocks(withUnused()); + render(); + fireEvent.click(screen.getByText(/Cleanup Unused/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Name filter (debounced) ──────────────────────────────────────────────── + + describe('name filter (debounced)', () => { + it('reflects the typed value in the filter input immediately', () => { + setupMocks(); + render(); + const input = screen.getByPlaceholderText('Filter by name...'); + fireEvent.change(input, { target: { value: 'sports' } }); + expect(input).toHaveValue('sports'); + }); + + it('does not pass filter value to getFilteredLogos immediately after typing', () => { + setupMocks(); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByPlaceholderText('Filter by name...'), { + target: { value: 'sports' }, + }); + + expect(LogosTableUtils.getFilteredLogos).not.toHaveBeenCalledWith( + expect.anything(), + 'sports', + expect.anything() + ); + }); + + it('passes filter value to getFilteredLogos after the 300ms debounce fires', async () => { + setupMocks(); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByPlaceholderText('Filter by name...'), { + target: { value: 'sports' }, + }); + + await waitFor( + () => + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith( + expect.anything(), + 'sports', + expect.anything() + ), + { timeout: 600 } + ); + }); + }); + + // ── Usage filter ─────────────────────────────────────────────────────────── + + describe('usage filter', () => { + it('calls getFilteredLogos with "used" when filter is changed to "Used only"', () => { + const logos = { 1: makeLogo() }; + setupMocks({ logos }); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'used' } }); + + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'used'); + }); + + it('calls getFilteredLogos with "unused" when filter is changed to "Unused only"', () => { + const logos = { 1: makeLogo() }; + setupMocks({ logos }); + render(); + vi.mocked(LogosTableUtils.getFilteredLogos).mockClear(); + + fireEvent.change(screen.getByTestId('usage-select'), { target: { value: 'unused' } }); + + expect(LogosTableUtils.getFilteredLogos).toHaveBeenCalledWith(logos, '', 'unused'); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('column cell renderers', () => { + it('renders the preview image with cache_url as src and /logo.png as fallback', () => { + const logo = makeLogo({ cache_url: '/cached/test.png', name: 'My Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'cache_url'); + const { getByAltText } = render( + col.cell({ getValue: () => logo.cache_url, row: { original: logo } }) + ); + + expect(getByAltText('My Logo')).toHaveAttribute('src', '/cached/test.png'); + expect(getByAltText('My Logo')).toHaveAttribute('data-fallback', '/logo.png'); + }); + + it('renders the logo name in the name column', () => { + const logo = makeLogo({ name: 'Channel 4 Logo' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'name'); + const { getByText } = render(col.cell({ getValue: () => logo.name })); + expect(getByText('Channel 4 Logo')).toBeInTheDocument(); + }); + + it('renders an "Unused" badge when channel_count is 0', () => { + const logo = makeLogo({ channel_count: 0 }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count'); + const { getByText } = render(col.cell({ getValue: () => 0, row: { original: logo } })); + expect(getByText('Unused')).toBeInTheDocument(); + }); + + it('renders a usage label badge when channel_count is > 0', () => { + const logo = makeLogo({ channel_count: 2, channel_names: ['Channel: HBO', 'Channel: CNN'] }); + vi.mocked(LogosTableUtils.generateUsageLabel).mockReturnValue('2 channels'); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'channel_count'); + const { getByText } = render(col.cell({ getValue: () => 2, row: { original: logo } })); + expect(getByText('2 channels')).toBeInTheDocument(); + }); + + it('renders the URL text in the URL column', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByText } = render(col.cell({ getValue: () => logo.url })); + expect(getByText('http://example.com/logo.png')).toBeInTheDocument(); + }); + + it('renders external link icon for http URLs', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByTestId } = render(col.cell({ getValue: () => logo.url })); + expect(getByTestId('icon-external-link')).toBeInTheDocument(); + }); + + it('does not render external link icon for non-http URLs', () => { + const logo = makeLogo({ url: '/data/logos/test.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { queryByTestId } = render(col.cell({ getValue: () => logo.url })); + expect(queryByTestId('icon-external-link')).not.toBeInTheDocument(); + }); + + it('opens URL in a new tab when external link button is clicked', () => { + const logo = makeLogo({ url: 'http://example.com/logo.png' }); + setupMocks({ logos: { 1: logo } }); + render(); + + const col = capturedTableOptions.columns.find((c) => c.accessorKey === 'url'); + const { getByTestId } = render(col.cell({ getValue: () => logo.url })); + fireEvent.click(getByTestId('icon-external-link').closest('button')); + + expect(window.open).toHaveBeenCalledWith('http://example.com/logo.png', '_blank'); + }); + }); + + // ── Pagination controls ──────────────────────────────────────────────────── + + describe('pagination controls', () => { + it('updates pagination when page size is changed', () => { + setupMocks(); + render(); + fireEvent.change(screen.getByTestId('native-select'), { target: { value: '50' } }); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + + it('shows "Page Size" label', () => { + setupMocks(); + render(); + expect(screen.getByText('Page Size')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/tables/__tests__/M3UsTable.test.jsx b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx new file mode 100644 index 00000000..f0f44a66 --- /dev/null +++ b/frontend/src/components/tables/__tests__/M3UsTable.test.jsx @@ -0,0 +1,1260 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + useDateTimeFormat: vi.fn(() => ({ + fullDateFormat: 'MM/DD/YYYY', + fullDateTimeFormat: 'MM/DD/YYYY HH:mm', + })), + format: vi.fn((val) => `formatted:${val}`), + diff: vi.fn(() => 30), + getNow: vi.fn(() => '2024-06-01T12:00:00Z'), +})); + +vi.mock('../../../utils/tables/M3UsTableUtils.js', () => ({ + deletePlaylist: vi.fn().mockResolvedValue(undefined), + getExpirationInfo: vi.fn(() => ({ color: 'green.5', label: '30d left' })), + getExpirationTooltip: vi.fn(() => 'expiration-tooltip'), + getPlaylistAutoCreatedChannelsCount: vi.fn().mockResolvedValue({ + count: 0, + sample_names: [], + }), + getSortedPlaylists: vi.fn((playlists) => + playlists.filter((p) => p.locked === false) + ), + getStatusColor: vi.fn(() => 'green.5'), + getStatusContent: vi.fn(() => ({ type: 'default', label: 'Idle' })), + formatStatusText: vi.fn((s) => + s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown' + ), + refreshPlaylist: vi.fn().mockResolvedValue(undefined), + updatePlaylist: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../M3uTableUtils.jsx', () => ({ + makeHeaderCellRenderer: vi.fn(() => (header) => ( + + {header.column.columnDef.header} + + )), + makeSortingChangeHandler: vi.fn(() => vi.fn()), +})); + +vi.mock('../../../helpers', () => ({ + TableHelper: { + defaultProperties: { mantineTableProps: { striped: true } }, + }, +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/M3U', () => ({ + default: ({ isOpen, onClose, m3uAccount }) => + isOpen ? ( +
+ {m3uAccount?.name ?? 'new'} + + +
+ ) : null, +})); + +vi.mock('../../ServerGroupsManagerModal', () => ({ + default: ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../../components/ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
+ {typeof message === 'string' ? message : 'rich-message'} +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange }) => ( + + ), + Text: ({ children, size, c, fw, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + colors: { red: { 6: '#fa5252' }, green: { 6: '#40c057' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + // Icons used by M3UsTable + RefreshCcw: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + // Icons required by src/config/navigation.js (pulled in via store/auth) + Blocks: () => , + ChartLine: () => , + Database: () => , + Download: () => , + FileImage: () => , + LayoutGrid: () => , + ListOrdered: () => , + Logs: () => , + MonitorCog: () => , + Package: () => , + Play: () => , + PlugZap: () => , + Settings: () => , + User: () => , + Video: () => , + Webhook: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useWarningsStore from '../../../store/warnings'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import * as M3UsTableUtils from '../../../utils/tables/M3UsTableUtils.js'; +import * as DateTimeUtils from '../../../utils/dateTimeUtils.js'; +import { useTable } from '../CustomTable'; +import M3UTable from '../M3UsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test M3U', + account_type: 'M3U', + server_url: 'http://example.com/playlist.m3u', + file_path: null, + status: 'success', + last_message: 'Loaded 500 streams', + max_streams: 5, + profiles: [], + is_active: true, + locked: false, + updated_at: '2024-01-01T12:00:00Z', + earliest_expiration: '2024-12-01T00:00:00Z', + all_expirations: [], + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + playlists = [makePlaylist()], + refreshProgress = {}, + editPlaylistId = null, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + tableSize = 'default', +} = {}) => { + const mockSetRefreshProgress = vi.fn(); + const mockSetEditPlaylistId = vi.fn(); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ + playlists, + refreshProgress, + setRefreshProgress: mockSetRefreshProgress, + editPlaylistId, + setEditPlaylistId: mockSetEditPlaylistId, + }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { getRowModel: () => ({ rows: [] }), getHeaderGroups: () => [] }; + }); + + return { mockSetRefreshProgress, mockSetEditPlaylistId }; +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (playlist) => ({ + row: { id: String(playlist.id), original: playlist }, + cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn() }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('M3UTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(M3UsTableUtils.deletePlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.refreshPlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.updatePlaylist).mockResolvedValue(undefined); + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({ + count: 0, + sample_names: [], + }); + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Idle' }); + vi.mocked(DateTimeUtils.format).mockImplementation((val) => `formatted:${val}`); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "M3U Accounts" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('M3U Accounts')).toBeInTheDocument(); + }); + + it('renders the "Add M3U" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add M3U')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the M3U form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + + it('filters out locked playlists from the table data', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Unlocked', locked: false }), + makePlaylist({ id: 2, name: 'Locked', locked: true }), + ], + }); + render(); + expect(capturedTableOptions.data.every((p) => p.locked === false)).toBe(true); + }); + + it('places active playlists before inactive ones', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Inactive', is_active: false }), + makePlaylist({ id: 2, name: 'Active', is_active: true }), + ], + }); + render(); + expect(capturedTableOptions.data[0].name).toBe('Active'); + expect(capturedTableOptions.data[1].name).toBe('Inactive'); + }); + + it('sorts playlists alphabetically within the same active group', () => { + setupMocks({ + playlists: [ + makePlaylist({ id: 1, name: 'Zebra', is_active: true }), + makePlaylist({ id: 2, name: 'Alpha', is_active: true }), + ], + }); + render(); + expect(capturedTableOptions.data[0].name).toBe('Alpha'); + expect(capturedTableOptions.data[1].name).toBe('Zebra'); + }); + }); + + // ── Add M3U ──────────────────────────────────────────────────────────────── + + describe('Add M3U', () => { + it('opens M3UForm with no account when "Add M3U" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('new'); + }); + + it('closes the form when onClose(null) is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + fireEvent.click(screen.getByTestId('m3u-form-close')); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + + it('keeps form open and updates playlist when onClose receives a new playlist', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add M3U')); + fireEvent.click(screen.getByTestId('m3u-form-close-with-playlist')); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('New Playlist'); + }); + }); + + // ── Edit playlist via RowActions ─────────────────────────────────────────── + + describe('edit playlist via RowActions', () => { + it('opens M3UForm populated with the playlist when edit icon is clicked', () => { + const playlist = makePlaylist({ name: 'My M3U' }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('My M3U'); + }); + + it('closes the form after editing when onClose(null) is called', () => { + const playlist = makePlaylist({ name: 'My M3U' }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('m3u-form-close')); + + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + }); + + // ── editPlaylistId (from notifications) ─────────────────────────────────── + + describe('editPlaylistId from store', () => { + it('auto-opens M3UForm for the matching playlist', () => { + const playlists = [makePlaylist({ id: 42, name: 'Notification Playlist' })]; + setupMocks({ playlists, editPlaylistId: 42 }); + render(); + expect(screen.getByTestId('m3u-form')).toBeInTheDocument(); + expect(screen.getByTestId('m3u-form-account')).toHaveTextContent('Notification Playlist'); + }); + + it('calls setEditPlaylistId(null) after handling editPlaylistId', () => { + const playlists = [makePlaylist({ id: 42 })]; + const { mockSetEditPlaylistId } = setupMocks({ playlists, editPlaylistId: 42 }); + render(); + expect(mockSetEditPlaylistId).toHaveBeenCalledWith(null); + }); + + it('does not open form when editPlaylistId does not match any playlist', () => { + setupMocks({ playlists: [makePlaylist({ id: 1 })], editPlaylistId: 999 }); + render(); + expect(screen.queryByTestId('m3u-form')).not.toBeInTheDocument(); + }); + }); + + // ── Refresh playlist ─────────────────────────────────────────────────────── + + describe('refresh playlist', () => { + it('calls setRefreshProgress with initializing state immediately', async () => { + const playlist = makePlaylist({ id: 1 }); + const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + expect(mockSetRefreshProgress).toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: 'initializing', progress: 0 }) + ); + }); + + it('calls refreshPlaylist with the playlist id', async () => { + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => + expect(M3UsTableUtils.refreshPlaylist).toHaveBeenCalledWith(1) + ); + }); + + it('sets error progress when refreshPlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.refreshPlaylist).mockRejectedValue(new Error('fail')); + const playlist = makePlaylist({ id: 1 }); + const { mockSetRefreshProgress } = setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-refresh').closest('button')); + + await waitFor(() => + expect(mockSetRefreshProgress).toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: 'error', status: 'error' }) + ) + ); + }); + + it('disables the refresh button when playlist is inactive', () => { + const playlist = makePlaylist({ id: 1, is_active: false }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-refresh').closest('button')).toBeDisabled(); + }); + + it('enables the refresh button when playlist is active', () => { + const playlist = makePlaylist({ id: 1, is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-refresh').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete playlist ──────────────────────────────────────────────────────── + + describe('delete playlist', () => { + const openDeleteDialog = async (playlist) => { + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + await waitFor(() => screen.getByTestId('confirmation-dialog')); + }; + + it('opens "Confirm M3U Account Deletion" dialog', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm M3U Account Deletion' + ); + }); + + it('shows rich message content in the dialog', async () => { + const playlist = makePlaylist({ name: 'My Channel List' }); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirm-message')).toHaveTextContent('rich-message'); + }); + + it('calls deletePlaylist with the correct id on confirm', async () => { + const playlist = makePlaylist({ id: 7 }); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(7) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes dialog on cancel without calling deletePlaylist', async () => { + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + expect(M3UsTableUtils.deletePlaylist).not.toHaveBeenCalled(); + }); + + it('skips dialog and deletes directly when warning suppressed and 0 auto-channels', async () => { + const playlist = makePlaylist({ id: 5 }); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + + const { row, cell } = makeRowCtx(playlist); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(M3UsTableUtils.deletePlaylist).toHaveBeenCalledWith(5) + ); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('opens dialog when warning suppressed but auto-channels count > 0', async () => { + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockResolvedValue({ + count: 3, + sample_names: ['Ch 1'], + }); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('opens dialog when auto-channel count fetch fails', async () => { + vi.mocked(M3UsTableUtils.getPlaylistAutoCreatedChannelsCount).mockRejectedValue( + new Error('Network error') + ); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist], isWarningSuppressed: vi.fn(() => true) }); + render(); + await openDeleteDialog(playlist); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('does not throw when deletePlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.deletePlaylist).mockRejectedValue(new Error('server error')); + const playlist = makePlaylist(); + setupMocks({ playlists: [playlist] }); + render(); + await openDeleteDialog(playlist); + await expect( + act(async () => fireEvent.click(screen.getByTestId('confirm-ok'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Toggle active ────────────────────────────────────────────────────────── + + describe('toggle active', () => { + const renderSwitch = (playlist) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => playlist.is_active }, + row: { original: playlist }, + }); + }; + + it('calls updatePlaylist with is_active:false when toggling an active playlist', async () => { + const playlist = makePlaylist({ is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith( + { is_active: false }, + playlist, + true + ) + ); + }); + + it('calls updatePlaylist with is_active:true when toggling an inactive playlist', async () => { + const playlist = makePlaylist({ is_active: false }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(M3UsTableUtils.updatePlaylist).toHaveBeenCalledWith( + { is_active: true }, + playlist, + true + ) + ); + }); + + it('does not throw when updatePlaylist rejects', async () => { + vi.mocked(M3UsTableUtils.updatePlaylist).mockRejectedValue(new Error('toggle error')); + const playlist = makePlaylist({ is_active: true }); + setupMocks({ playlists: [playlist] }); + render(); + + const { getByTestId } = render(renderSwitch(playlist)); + await expect( + act(async () => fireEvent.click(getByTestId('active-switch'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Column: Type ─────────────────────────────────────────────────────────── + + describe('Type column', () => { + it('renders "XC" for Xtream Codes type', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('account_type').cell({ cell: { getValue: () => 'XC' } }) + ); + expect(getByText('XC')).toBeInTheDocument(); + }); + + it('renders "M3U" for standard type', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('account_type').cell({ cell: { getValue: () => 'M3U' } }) + ); + expect(getByText('M3U')).toBeInTheDocument(); + }); + }); + + // ── Column: URL / File ───────────────────────────────────────────────────── + + describe('URL / File column', () => { + it('renders server_url when present', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ server_url: 'http://example.com/list.m3u' }); + const { getByText } = render( + getCol('server_url').cell({ + cell: { getValue: () => playlist.server_url }, + row: { original: playlist }, + }) + ); + expect(getByText('http://example.com/list.m3u')).toBeInTheDocument(); + }); + + it('falls back to file_path when server_url is empty', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ server_url: '', file_path: '/files/list.m3u' }); + const { getByText } = render( + getCol('server_url').cell({ + cell: { getValue: () => '' }, + row: { original: playlist }, + }) + ); + expect(getByText('/files/list.m3u')).toBeInTheDocument(); + }); + }); + + // ── Column: Status ───────────────────────────────────────────────────────── + + describe('Status column', () => { + it('returns null when status value is empty', () => { + setupMocks(); + render(); + expect(getCol('status').cell({ cell: { getValue: () => null } })).toBeNull(); + }); + + it('renders formatted status text', () => { + vi.mocked(M3UsTableUtils.formatStatusText).mockReturnValue('Success'); + setupMocks(); + render(); + const { getByText } = render( + getCol('status').cell({ cell: { getValue: () => 'success' } }) + ); + expect(getByText('Success')).toBeInTheDocument(); + }); + }); + + // ── Column: Status Message ───────────────────────────────────────────────── + + describe('Status Message column', () => { + it('returns null when last_message is empty and no active progress', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'idle' }); + expect( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ).toBeNull(); + }); + + it('renders the last_message text for a generic status', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'idle' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Loaded 200 streams' }, + row: { original: playlist }, + }) + ); + expect(getByText('Loaded 200 streams')).toBeInTheDocument(); + }); + + it('shows progress UI when active progress (< 100) exists', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'initializing' }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ); + expect(getByText('Initializing refresh...')).toBeInTheDocument(); + }); + + it('bypasses progress UI when progress equals 100', () => { + const playlist = makePlaylist({ id: 1, status: 'success' }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Done' }, + row: { original: playlist }, + }) + ); + expect(getByText('Done')).toBeInTheDocument(); + }); + }); + + // ── Column: Max Streams ──────────────────────────────────────────────────── + + describe('Max Streams column', () => { + const renderMaxStreams = (playlist) => { + setupMocks({ playlists: [playlist] }); + render(); + return getCol('max_streams').cell({ row: { original: playlist } }); + }; + + it('renders max_streams when no active profiles', () => { + const { getByText } = render( + renderMaxStreams(makePlaylist({ max_streams: 10, profiles: [] })) + ); + expect(getByText('10')).toBeInTheDocument(); + }); + + it('renders "∞" when max_streams is 0 and no active profiles', () => { + const { getByText } = render( + renderMaxStreams(makePlaylist({ max_streams: 0, profiles: [] })) + ); + expect(getByText('∞')).toBeInTheDocument(); + }); + + it('renders the sum of active profile max_streams', () => { + const playlist = makePlaylist({ + profiles: [ + { name: 'P1', max_streams: 3, is_active: true }, + { name: 'P2', max_streams: 5, is_active: true }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('8')).toBeInTheDocument(); + }); + + it('renders "∞" when any active profile has max_streams 0', () => { + const playlist = makePlaylist({ + profiles: [ + { name: 'P1', max_streams: 0, is_active: true }, + { name: 'P2', max_streams: 5, is_active: true }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('∞')).toBeInTheDocument(); + }); + }); + + // ── Column: Expiration ───────────────────────────────────────────────────── + + describe('Expiration column', () => { + it('returns null when earliest_expiration is absent', () => { + setupMocks(); + render(); + expect( + getCol('earliest_expiration').cell({ + cell: { getValue: () => null }, + row: { original: makePlaylist({ earliest_expiration: null }) }, + }) + ).toBeNull(); + }); + + it('renders the expiration label from getExpirationInfo', () => { + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'orange.5', + label: '7d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-12-01T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + expect(getByText('7d left')).toBeInTheDocument(); + }); + }); + + // ── Column: Updated ──────────────────────────────────────────────────────── + + describe('Updated column', () => { + it('renders "Never" when updated_at is absent', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('updated_at').cell({ cell: { getValue: () => null } }) + ); + expect(getByText('Never')).toBeInTheDocument(); + }); + + it('renders the formatted date string when updated_at is present', () => { + vi.mocked(DateTimeUtils.format).mockReturnValue('formatted:2024-01-01T12:00:00Z'); + setupMocks(); + render(); + const { getByText } = render( + getCol('updated_at').cell({ cell: { getValue: () => '2024-01-01T12:00:00Z' } }) + ); + expect(getByText('formatted:2024-01-01T12:00:00Z')).toBeInTheDocument(); + }); + }); + + // ── generateStatusString content types ──────────────────────────────────── + + describe('generateStatusString via status message column', () => { + const renderProgressCell = (contentOverride, progressData = { progress: 50 }) => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue(contentOverride); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: progressData } }); + render(); + return getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }); + }; + + it('renders download progress with speed and time', () => { + const { getByText } = render( + renderProgressCell({ + type: 'downloading', + progress: 45, + speed: '1.2 MB/s', + timeRemaining: '2m', + }, { progress: 45 }) + ); + expect(getByText(/Downloading/)).toBeInTheDocument(); + expect(getByText('45%')).toBeInTheDocument(); + expect(getByText('1.2 MB/s')).toBeInTheDocument(); + }); + + it('renders parsing progress', () => { + const { getByText } = render( + renderProgressCell({ + type: 'parsing', + progress: 60, + elapsedTime: '5s', + timeRemaining: '3s', + streamsProcessed: '300/500', + }, { progress: 60 }) + ); + expect(getByText(/Parsing/)).toBeInTheDocument(); + expect(getByText('60%')).toBeInTheDocument(); + }); + + it('renders groups processing progress', () => { + const { getByText } = render( + renderProgressCell({ + type: 'groups', + progress: 30, + elapsedTime: '2s', + groupsProcessed: '10/50', + }, { progress: 30 }) + ); + expect(getByText(/Processing groups/)).toBeInTheDocument(); + }); + + it('renders error message from getStatusContent', () => { + const { getByText } = render( + renderProgressCell({ type: 'error', error: 'Connection refused' }) + ); + expect(getByText('Connection refused')).toBeInTheDocument(); + }); + + it('falls back to "Unknown error occurred" when error text is absent', () => { + const { getByText } = render( + renderProgressCell({ type: 'error', error: null }) + ); + expect(getByText('Unknown error occurred')).toBeInTheDocument(); + }); + + it('returns "Idle" string when progress is 100', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'downloading', progress: 100 }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 100 } } }); + render(); + // progress === 100 → generateStatusString returns the string 'Idle' + const result = getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }); + // The status message cell bypasses progress when progress === 100, so + // generateStatusString is not called. Verify via the progress-bypass path: + // (this is tested via "bypasses progress UI when progress equals 100" already) + expect(result).toBeNull(); // last_message is null, progress=100 so falls through to null + }); + + it('renders content.label for default type', () => { + vi.mocked(M3UsTableUtils.getStatusContent).mockReturnValue({ type: 'default', label: 'Queued' }); + const playlist = makePlaylist({ id: 1 }); + setupMocks({ playlists: [playlist], refreshProgress: { 1: { progress: 50 } } }); + render(); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => null }, + row: { original: playlist }, + }) + ); + expect(getByText('Queued')).toBeInTheDocument(); + }); + + it('renders downloading timeRemaining when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'downloading', + progress: 45, + speed: '1.2 MB/s', + timeRemaining: '30s left', + }, { progress: 45 }) + ); + expect(getByText('30s left')).toBeInTheDocument(); + }); + + it('renders groups elapsedTime and groupsProcessed when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'groups', + progress: 30, + elapsedTime: '4s', + groupsProcessed: '20/80', + }, { progress: 30 }) + ); + expect(getByText('4s')).toBeInTheDocument(); + expect(getByText('20/80')).toBeInTheDocument(); + }); + + it('renders parsing elapsedTime, timeRemaining and streamsProcessed when present', () => { + const { getByText } = render( + renderProgressCell({ + type: 'parsing', + progress: 60, + elapsedTime: '10s', + timeRemaining: '5s', + streamsProcessed: '600/1000', + }, { progress: 60 }) + ); + expect(getByText('10s')).toBeInTheDocument(); + expect(getByText('5s')).toBeInTheDocument(); + expect(getByText('600/1000')).toBeInTheDocument(); + }); + }); + + // ── Status Message column – error / success styling ──────────────────────── + + describe('Status Message column – error/success text', () => { + it('renders error-styled text when status is "error"', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'error' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Parse failure' }, + row: { original: playlist }, + }) + ); + expect(getByText('Parse failure')).toBeInTheDocument(); + }); + + it('renders success-styled text when status is "success"', () => { + setupMocks(); + render(); + const playlist = makePlaylist({ id: 1, status: 'success' }); + const { getByText } = render( + getCol('last_message').cell({ + cell: { getValue: () => 'Loaded 500 streams' }, + row: { original: playlist }, + }) + ); + expect(getByText('Loaded 500 streams')).toBeInTheDocument(); + }); + }); + + // ── Server Groups modal ──────────────────────────────────────────────────── + + describe('Server Groups modal', () => { + it('renders the "Server Groups" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Server Groups')).toBeInTheDocument(); + }); + + it('opens the Server Groups modal when "Server Groups" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Server Groups')); + expect(screen.getByTestId('server-groups-modal')).toBeInTheDocument(); + }); + + it('closes the Server Groups modal when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Server Groups')); + fireEvent.click(screen.getByTestId('server-groups-close')); + expect(screen.queryByTestId('server-groups-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Max Streams column – inactive profiles excluded ──────────────────────── + + describe('Max Streams column – profile filtering', () => { + const renderMaxStreams = (playlist) => { + setupMocks({ playlists: [playlist] }); + render(); + return getCol('max_streams').cell({ row: { original: playlist } }); + }; + + it('excludes inactive profiles from the sum', () => { + // Need 2+ active profiles to trigger the sum branch; inactive ones must be ignored + const playlist = makePlaylist({ + profiles: [ + { name: 'A1', max_streams: 3, is_active: true }, + { name: 'A2', max_streams: 5, is_active: true }, + { name: 'Inactive', max_streams: 100, is_active: false }, + ], + }); + const { getByText } = render(renderMaxStreams(playlist)); + // 3 + 5 = 8, not 3 + 5 + 100 = 108 + expect(getByText('8')).toBeInTheDocument(); + }); + + it('uses playlist max_streams directly when there is exactly one active profile', () => { + const playlist = makePlaylist({ + max_streams: 7, + profiles: [{ name: 'Solo', max_streams: 7, is_active: true }], + }); + const { getByText } = render(renderMaxStreams(playlist)); + expect(getByText('7')).toBeInTheDocument(); + }); + }); + + // ── Expiration column – bold label when daysLeft ≤ 7 ────────────────────── + + describe('Expiration column – bold label', () => { + it('renders with bold weight (600) when daysLeft is 7 or less', () => { + vi.mocked(DateTimeUtils.diff).mockReturnValue(5); + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'red.6', + label: '5d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-06-06T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + const el = getByText('5d left'); + // The Text mock passes `fw` as data-fw + expect(el).toHaveAttribute('data-fw', '600'); + }); + + it('renders with normal weight (400) when daysLeft > 7', () => { + vi.mocked(DateTimeUtils.diff).mockReturnValue(30); + vi.mocked(M3UsTableUtils.getExpirationInfo).mockReturnValue({ + color: 'green.5', + label: '30d left', + }); + setupMocks(); + render(); + const { getByText } = render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-07-01T00:00:00Z' }, + row: { original: makePlaylist() }, + }) + ); + expect(getByText('30d left')).toHaveAttribute('data-fw', '400'); + }); + + it('passes all_expirations to getExpirationTooltip', () => { + const expirations = ['2024-12-01', '2025-01-01']; + setupMocks(); + render(); + const playlist = makePlaylist({ all_expirations: expirations }); + render( + getCol('earliest_expiration').cell({ + cell: { getValue: () => '2024-12-01T00:00:00Z' }, + row: { original: playlist }, + }) + ); + expect(M3UsTableUtils.getExpirationTooltip).toHaveBeenCalledWith( + expirations, + expect.any(String), + expect.any(String) + ); + }); + }); + + // ── Table structure (useTable options) ──────────────────────────────────── + + describe('table structure', () => { + it('passes enablePagination: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowVirtualization: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowVirtualization).toBe(true); + }); + + it('passes enableRowSelection: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes renderTopToolbar: false to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.renderTopToolbar).toBe(false); + }); + + it('passes manualSorting: true to useTable', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(true); + }); + + it('passes allRowIds as the playlist ids', () => { + const playlists = [ + makePlaylist({ id: 10, name: 'A' }), + makePlaylist({ id: 20, name: 'B' }), + ]; + setupMocks({ playlists }); + render(); + expect(capturedTableOptions.allRowIds).toEqual( + expect.arrayContaining([10, 20]) + ); + }); + + it('actions column size is 75 in compact mode', () => { + setupMocks({ tableSize: 'compact' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(75); + }); + + it('actions column size is 100 in default mode', () => { + setupMocks({ tableSize: 'default' }); + render(); + const actionsCol = capturedTableOptions.columns.find((c) => c.id === 'actions'); + expect(actionsCol.size).toBe(100); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx new file mode 100644 index 00000000..47e66324 --- /dev/null +++ b/frontend/src/components/tables/__tests__/OutputProfilesTable.test.jsx @@ -0,0 +1,524 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/tables/OutputProfilesTableUtils.js', () => ({ + deleteOutputProfile: vi.fn().mockResolvedValue(undefined), + updateOutputProfile: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/OutputProfile', () => ({ + default: ({ isOpen, onClose, profile }) => + isOpen ? ( +
+ {profile?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, size, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useOutputProfilesStore from '../../../store/outputProfiles'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import * as OutputProfilesTableUtils from '../../../utils/tables/OutputProfilesTableUtils.js'; +import { useTable } from '../CustomTable'; +import OutputProfiles from '../OutputProfilesTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeProfile = (overrides = {}) => ({ + id: 1, + name: 'Test Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + is_active: true, + locked: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + profiles = [makeProfile()], + tableSize = 'default', +} = {}) => { + vi.mocked(useOutputProfilesStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useLocalStorage).mockReturnValue([tableSize, vi.fn()]); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (profile) => ({ + row: { id: String(profile.id), original: profile }, + cell: { column: { id: 'actions', columnDef: {} }, getValue: vi.fn(() => undefined) }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('OutputProfiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockResolvedValue(undefined); + vi.mocked(OutputProfilesTableUtils.updateOutputProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add Output Profile" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Output Profile')).toBeInTheDocument(); + }); + + it('renders the hide/show inactive toggle button', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + + it('passes all unlocked+active profiles to useTable when hideInactive is false', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Add Output Profile ───────────────────────────────────────────────────── + + describe('Add Output Profile', () => { + it('opens the form with no profile when "Add Output Profile" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Output Profile')); + expect(screen.getByTestId('output-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Output Profile')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit Output Profile (RowActions) ─────────────────────────────────────── + + describe('edit profile via RowActions', () => { + it('opens the form populated with the profile when edit icon is clicked', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('output-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile'); + }); + + it('closes the form after editing when onClose is called', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('output-profile-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete Output Profile (RowActions) ──────────────────────────────────── + + describe('delete profile via RowActions', () => { + it('calls deleteOutputProfile with the profile id when delete icon is clicked', async () => { + const profile = makeProfile({ id: 7 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(OutputProfilesTableUtils.deleteOutputProfile).toHaveBeenCalledWith(7) + ); + }); + + it('delete button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + + it('does not throw when deleteOutputProfile rejects', async () => { + vi.mocked(OutputProfilesTableUtils.deleteOutputProfile).mockRejectedValue( + new Error('server error') + ); + const profile = makeProfile({ id: 1 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + await expect( + act(async () => fireEvent.click(getByTestId('icon-square-minus').closest('button'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Toggle active (is_active Switch) ────────────────────────────────────── + + describe('toggle profile is_active', () => { + const renderSwitch = (profile) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => profile.is_active }, + row: { original: profile }, + }); + }; + + it('calls updateOutputProfile with is_active toggled to false', async () => { + const profile = makeProfile({ is_active: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: profile.id, is_active: false }) + ) + ); + }); + + it('calls updateOutputProfile with is_active toggled to true', async () => { + const profile = makeProfile({ is_active: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(OutputProfilesTableUtils.updateOutputProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: profile.id, is_active: true }) + ) + ); + }); + + it('switch is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).toBeDisabled(); + }); + + it('switch is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).not.toBeDisabled(); + }); + }); + + // ── Hide inactive toggle ─────────────────────────────────────────────────── + + describe('hide inactive toggle', () => { + it('shows Eye icon when hideInactive is false (default)', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument(); + }); + + it('shows EyeOff icon after the toggle is clicked', () => { + setupMocks(); + render(); + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows Eye icon again after toggling twice', () => { + setupMocks(); + render(); + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('excludes inactive profiles from table data when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + + expect(capturedTableOptions.data).toHaveLength(1); + expect(capturedTableOptions.data[0].name).toBe('Active'); + }); + + it('restores all profiles when hideInactive is turned back off', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + + const toggleBtn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); // hide inactive + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); // show all + + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('does not filter already-active profiles when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: true }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Column: Name ─────────────────────────────────────────────────────────── + + describe('Name column', () => { + it('renders the profile name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'My Profile' } }) + ); + expect(getByText('My Profile')).toBeInTheDocument(); + }); + }); + + // ── Column: Command ──────────────────────────────────────────────────────── + + describe('Command column', () => { + it('renders the command value', () => { + setupMocks(); + render(); + const col = getCol('command'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'ffmpeg' } }) + ); + expect(getByText('ffmpeg')).toBeInTheDocument(); + }); + }); + + // ── Column: Parameters ───────────────────────────────────────────────────── + + describe('Parameters column', () => { + it('renders the parameters value', () => { + setupMocks(); + render(); + const col = getCol('parameters'); + const { getByText } = render( + col.cell({ cell: { getValue: () => '-c:v copy -preset fast' } }) + ); + expect(getByText('-c:v copy -preset fast')).toBeInTheDocument(); + }); + }); + + // ── Store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes store profiles directly to the table data', () => { + const profiles = [ + makeProfile({ id: 1, name: 'Alpha' }), + makeProfile({ id: 2, name: 'Beta' }), + ]; + setupMocks({ profiles }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + expect(capturedTableOptions.data.map((p) => p.name)).toEqual(['Alpha', 'Beta']); + }); + + it('passes an empty array to the table when no profiles exist', () => { + setupMocks({ profiles: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx new file mode 100644 index 00000000..e13db683 --- /dev/null +++ b/frontend/src/components/tables/__tests__/StreamProfilesTable.test.jsx @@ -0,0 +1,540 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteStreamProfile: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({ + updateStreamProfile: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/StreamProfile', () => ({ + default: ({ isOpen, onClose, profile }) => + isOpen ? ( +
+ {profile?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + palette: { background: { paper: '#1a1a1a' } }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useStreamProfilesStore from '../../../store/streamProfiles'; +import useSettingsStore from '../../../store/settings'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { updateStreamProfile } from '../../../utils/forms/StreamProfileUtils.js'; +import API from '../../../api'; +import StreamProfiles from '../StreamProfilesTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeProfile = (overrides = {}) => ({ + id: 1, + name: 'Test Profile', + command: 'ffmpeg', + parameters: '-c copy', + is_active: true, + locked: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + profiles = [makeProfile()], + defaultProfileId = 99, +} = {}) => { + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { default_stream_profile: defaultProfileId } }) + ); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getCol = (keyOrId) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === keyOrId || c.id === keyOrId + ); + +const makeRowCtx = (profile) => ({ + row: { id: String(profile.id), original: profile }, + cell: { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => undefined), + }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('StreamProfiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteStreamProfile).mockResolvedValue(undefined); + vi.mocked(updateStreamProfile).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add Stream Profile" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add Stream Profile')).toBeInTheDocument(); + }); + + it('renders the hide/show inactive toggle button', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + + it('passes all profiles to useTable when hideInactive is false', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, name: 'Active', is_active: true }), + makeProfile({ id: 2, name: 'Inactive', is_active: false }), + ], + }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Add Stream Profile ───────────────────────────────────────────────────── + + describe('Add Stream Profile', () => { + it('opens the form with no profile when "Add Stream Profile" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Stream Profile')); + expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add Stream Profile')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit profile via RowActions ──────────────────────────────────────────── + + describe('edit profile via RowActions', () => { + it('opens the form populated with the profile when edit icon is clicked', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('stream-profile-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-profile-name')).toHaveTextContent('My Profile'); + }); + + it('closes the form after editing when onClose is called', () => { + const profile = makeProfile({ name: 'My Profile' }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('stream-profile-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete profile via RowActions ────────────────────────────────────────── + + describe('delete profile via RowActions', () => { + it('calls API.deleteStreamProfile with the profile id when delete icon is clicked', async () => { + const profile = makeProfile({ id: 7 }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteStreamProfile).toHaveBeenCalledWith(7) + ); + }); + + it('shows a notification and does NOT call API when deleting the default profile', async () => { + const profile = makeProfile({ id: 5 }); + setupMocks({ profiles: [profile], defaultProfileId: 5 }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cannot delete default stream-profile', + color: 'red.5', + }) + ) + ); + expect(API.deleteStreamProfile).not.toHaveBeenCalled(); + }); + + it('delete button is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { row, cell } = makeRowCtx(profile); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Toggle profile is_active ─────────────────────────────────────────────── + + describe('toggle profile is_active', () => { + const renderSwitch = (profile) => { + const col = getCol('is_active'); + return col.cell({ + cell: { getValue: () => profile.is_active }, + row: { original: profile }, + }); + }; + + it('calls updateStreamProfile with is_active toggled to false', async () => { + const profile = makeProfile({ is_active: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(updateStreamProfile).toHaveBeenCalledWith( + profile.id, + expect.objectContaining({ id: profile.id, is_active: false }) + ) + ); + }); + + it('calls updateStreamProfile with is_active toggled to true', async () => { + const profile = makeProfile({ is_active: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + fireEvent.click(getByTestId('active-switch')); + + await waitFor(() => + expect(updateStreamProfile).toHaveBeenCalledWith( + profile.id, + expect.objectContaining({ id: profile.id, is_active: true }) + ) + ); + }); + + it('switch is disabled when profile is locked', () => { + const profile = makeProfile({ locked: true }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).toBeDisabled(); + }); + + it('switch is enabled when profile is not locked', () => { + const profile = makeProfile({ locked: false }); + setupMocks({ profiles: [profile] }); + render(); + + const { getByTestId } = render(renderSwitch(profile)); + expect(getByTestId('active-switch')).not.toBeDisabled(); + }); + }); + + // ── Hide inactive toggle ─────────────────────────────────────────────────── + + describe('hide inactive toggle', () => { + it('shows Eye icon when hideInactive is false (default)', () => { + setupMocks(); + render(); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye-off')).not.toBeInTheDocument(); + }); + + it('shows EyeOff icon after the toggle is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(screen.getByTestId('icon-eye-off')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows Eye icon again after toggling twice', () => { + setupMocks(); + render(); + const btn = screen.getByTestId('icon-eye').closest('button'); + fireEvent.click(btn); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(screen.getByTestId('icon-eye')).toBeInTheDocument(); + }); + + it('excludes inactive profiles from table data when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: false }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(1); + expect(capturedTableOptions.data[0].id).toBe(1); + }); + + it('restores all profiles when hideInactive is turned back off', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: false }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(1); + fireEvent.click(screen.getByTestId('icon-eye-off').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('does not filter already-active profiles when hideInactive is true', () => { + setupMocks({ + profiles: [ + makeProfile({ id: 1, is_active: true }), + makeProfile({ id: 2, is_active: true }), + ], + }); + render(); + fireEvent.click(screen.getByTestId('icon-eye').closest('button')); + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('Name column', () => { + it('renders the profile name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'My Encoder' } }) + ); + expect(getByText('My Encoder')).toBeInTheDocument(); + }); + }); + + describe('Command column', () => { + it('renders the command value', () => { + setupMocks(); + render(); + const col = getCol('command'); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'ffmpeg' } }) + ); + expect(getByText('ffmpeg')).toBeInTheDocument(); + }); + }); + + describe('Parameters column', () => { + it('renders the parameters value inside a Tooltip', () => { + setupMocks(); + render(); + const col = getCol('parameters'); + const { getByText } = render( + col.cell({ cell: { getValue: () => '-c copy -f mpegts' } }) + ); + expect(getByText('-c copy -f mpegts')).toBeInTheDocument(); + }); + }); + + // ── Store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes store profiles directly to the table data', () => { + const profiles = [ + makeProfile({ id: 1, name: 'P1' }), + makeProfile({ id: 2, name: 'P2' }), + makeProfile({ id: 3, name: 'P3' }), + ]; + setupMocks({ profiles }); + render(); + expect(capturedTableOptions.data).toHaveLength(3); + }); + + it('passes an empty array to the table when no profiles exist', () => { + setupMocks({ profiles: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/StreamsTable.test.jsx b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx new file mode 100644 index 00000000..84554ca7 --- /dev/null +++ b/frontend/src/components/tables/__tests__/StreamsTable.test.jsx @@ -0,0 +1,833 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn() })); +vi.mock('../../../store/channelsTable', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); +vi.mock('../../../store/streamsTable', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => [{}, vi.fn()]), +})); + +// ── Router mock ──────────────────────────────────────────────────────────────── +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), + useDebounce: vi.fn((value) => value), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/components/FloatingVideoUtils.js', () => ({ + buildLiveStreamUrl: vi.fn((path) => path), +})); + +vi.mock('../../../utils/forms/ChannelUtils.js', () => ({ + requeryChannels: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../utils/tables/StreamsTableUtils.js', () => ({ + addStreamsToChannel: vi.fn().mockResolvedValue(undefined), + appendFetchPageParams: vi.fn(), + createChannelFromStream: vi.fn().mockResolvedValue(undefined), + createChannelsFromStreamsAsync: vi.fn().mockResolvedValue({ task_id: 'task-1', stream_count: 1 }), + deleteStream: vi.fn().mockResolvedValue(undefined), + deleteStreams: vi.fn().mockResolvedValue(undefined), + getAllStreamIds: vi.fn().mockResolvedValue([]), + getChannelNumberValue: vi.fn((mode) => mode === 'provider' ? null : 1), + getChannelProfileIds: vi.fn((profileIds) => profileIds), + getFilterParams: vi.fn(() => new URLSearchParams()), + getStatsTooltip: vi.fn(() => ({ compactDisplay: '1080p', tooltipContent: '1920x1080' })), + getStreamFilterOptions: vi.fn().mockResolvedValue({ groups: [], m3u_accounts: [] }), + getStreams: vi.fn().mockResolvedValue([]), + queryStreamsTable: vi.fn().mockResolvedValue({ count: 5, results: [] }), + requeryStreams: vi.fn().mockResolvedValue(undefined), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/Stream', () => ({ + default: ({ isOpen, onClose, stream }) => + isOpen ? ( +
+ {stream?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, message, confirmLabel, cancelLabel, loading }) => + opened ? ( +
+ {title} + {typeof message === 'string' ? message : 'message'} + + +
+ ) : null, +})); + +vi.mock('../../modals/CreateChannelModal', () => ({ + default: ({ opened, onClose, onConfirm }) => + opened ? ( +
+ + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Card: ({ children, style }) => ( +
{children}
+ ), + Center: ({ children, style }) =>
{children}
, + Divider: ({ label }) =>
, + Flex: ({ children, style }) => ( +
{children}
+ ), + Group: ({ children, style }) =>
{children}
, + LoadingOverlay: ({ visible }) => visible ?
: null, + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children }) =>
{children}
, + Label: ({ children }) =>
{children}
, + Item: ({ children, onClick, leftSection }) => ( + + ), + Divider: () =>
, + } + ), + MenuDivider: () =>
, + MenuDropdown: ({ children }) =>
{children}
, + MenuItem: ({ children, onClick, leftSection }) => ( + + ), + MenuLabel: ({ children }) =>
{children}
, + MenuTarget: ({ children }) =>
{children}
, + MultiSelect: ({ onChange, value, data }) => ( + + ), + NativeSelect: ({ onChange, value, data }) => ( + + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style }) => ( + {children} + ), + TextInput: ({ onChange, value, placeholder }) => ( + + ), + Title: ({ children, style }) =>

{children}

, + Tooltip: ({ children, label }) => ( +
{children}
+ ), + UnstyledButton: ({ children, onClick }) => ( + + ), + useMantineTheme: vi.fn(() => ({ + tailwind: { blue: { 6: '#3b82f6' }, green: { 5: '#22c55e' }, yellow: { 3: '#fde047' } }, + palette: { background: { paper: '#1a1a1a' } }, + colors: {}, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ArrowDownWideNarrow: () => , + ArrowUpDown: () => , + ArrowUpNarrowWide: () => , + Copy: () => , + EllipsisVertical: () => , + Eye: () => , + EyeOff: () => , + Filter: () => , + ListPlus: () => , + RotateCcw: () => , + Search: () => , + Square: () => , + SquareCheck: () => , + SquareMinus: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useChannelsStore from '../../../store/channels'; +import useSettingsStore from '../../../store/settings'; +import useVideoStore from '../../../store/useVideoStore'; +import useChannelsTableStore from '../../../store/channelsTable'; +import useWarningsStore from '../../../store/warnings'; +import useStreamsTableStore from '../../../store/streamsTable'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { useNavigate } from 'react-router-dom'; +import { useTable } from '../CustomTable'; +import * as StreamsTableUtils from '../../../utils/tables/StreamsTableUtils.js'; +import StreamsTable from '../StreamsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeStream = (overrides = {}) => ({ + id: 1, + name: 'Test Stream', + url: 'http://example.com/stream', + stream_hash: 'abc123', + channel_group: 'group-1', + m3u_account: 10, + tvg_id: 'tvg-1', + stream_stats: null, + is_custom: true, + is_stale: false, + ...overrides, +}); + +let capturedTableOptions = null; + +const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 50 }; +const DEFAULT_SORTING = [{ id: 'name', desc: false }]; + +const setupMocks = ({ + streams = [makeStream()], + pageCount = 1, + totalCount = 1, + allQueryIds = [1], + pagination = DEFAULT_PAGINATION, + sorting = DEFAULT_SORTING, + selectedStreamIds = [], + playlists = [{ id: 10, name: 'My M3U' }], + channelGroups = { 'group-1': { name: 'Sports' } }, + expandedChannelId = null, + selectedChannelIds = [], + channelProfiles = {}, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), + envMode = 'production', + showVideo = vi.fn(), + isVisible = false, + tableSize = null, +} = {}) => { + vi.mocked(useStreamsTableStore).mockImplementation((sel) => + sel({ + streams, + pageCount, + totalCount, + allQueryIds, + pagination, + sorting, + selectedStreamIds, + setAllQueryIds: vi.fn(), + setPagination: vi.fn(), + setSorting: vi.fn(), + setSelectedStreamIds: vi.fn(), + }) + ); + + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ playlists, fetchPlaylists: vi.fn(), isLoading: false }) + ); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelGroups, + fetchChannelGroups: vi.fn(), + profiles: channelProfiles, + selectedProfileId: '0', + }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: envMode } }) + ); + + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo, isVisible }) + ); + + vi.mocked(useChannelsTableStore).mockImplementation((sel) => + sel({ + expandedChannelId, + selectedChannelIds, + channels: [], + }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ suppressWarning, isWarningSuppressed }) + ); + + // useLocalStorage: first call is column-sizing, second is column-visibility + vi.mocked(useLocalStorage) + .mockReturnValueOnce([{}, vi.fn()]) // streams-table-column-sizing + .mockReturnValueOnce([tableSize, vi.fn()]); // streams-table-column-visibility + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + tableSize: tableSize ?? 'default', + }; + }); +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('StreamsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 5, results: [] }); + vi.mocked(StreamsTableUtils.getAllStreamIds).mockResolvedValue([]); + vi.mocked(StreamsTableUtils.getStreamFilterOptions).mockResolvedValue({ + groups: [], + m3u_accounts: [], + }); + vi.mocked(StreamsTableUtils.deleteStream).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.deleteStreams).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.addStreamsToChannel).mockResolvedValue(undefined); + vi.mocked(StreamsTableUtils.requeryStreams).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Streams" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Streams')).toBeInTheDocument(); + }); + + it('renders the "Create Stream" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Create Stream')).toBeInTheDocument(); + }); + + it('renders the "Delete" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('renders the "Add to Channel" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add to Channel')).toBeInTheDocument(); + }); + + it('renders "Create Channel (0)" button when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + expect(screen.getByText('Create Channel (0)')).toBeInTheDocument(); + }); + + it('renders "Create Channels (N)" button when multiple streams are selected', () => { + setupMocks({ selectedStreamIds: [1, 2] }); + render(); + expect(screen.getByText('Create Channels (2)')).toBeInTheDocument(); + }); + + it('does not render the stream form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument(); + }); + + it('shows getting-started card when totalCount is 0', async () => { + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] }); + setupMocks({ totalCount: 0, streams: [] }); + render(); + await waitFor(() => { + expect(screen.getByText('Getting started')).toBeInTheDocument(); + }); + }); + + it('renders the custom table when totalCount > 0', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + }); + + it('loading overlay is not visible after data finishes loading', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + // After the initial fetch resolves, the overlay is hidden + await waitFor(() => { + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Stream form (Create/Edit) ────────────────────────────────────────────── + + describe('Create Stream modal', () => { + it('opens the stream form with no stream when "Create Stream" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + expect(screen.getByTestId('stream-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-stream-name')).toHaveTextContent('new'); + }); + + it('closes the stream form when onClose is called', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + fireEvent.click(screen.getByTestId('form-close')); + await waitFor(() => { + expect(screen.queryByTestId('stream-form')).not.toBeInTheDocument(); + }); + }); + + it('calls requeryStreams after form is closed', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Create Stream')); + fireEvent.click(screen.getByTestId('form-close')); + await waitFor(() => { + expect(StreamsTableUtils.requeryStreams).toHaveBeenCalled(); + }); + }); + }); + + // ── Delete button state ──────────────────────────────────────────────────── + + describe('"Delete" button', () => { + it('is disabled when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const deleteBtn = screen.getByText('Delete').closest('button'); + expect(deleteBtn).toBeDisabled(); + }); + + it('is enabled when streams are selected', () => { + setupMocks({ selectedStreamIds: [1] }); + render(); + const deleteBtn = screen.getByText('Delete').closest('button'); + expect(deleteBtn).not.toBeDisabled(); + }); + }); + + // ── "Add to Channel" button state ────────────────────────────────────────── + + describe('"Add to Channel" button', () => { + it('is disabled when no streams are selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).toBeDisabled(); + }); + + it('is disabled when streams selected but no target channel', () => { + setupMocks({ selectedStreamIds: [1], expandedChannelId: null, selectedChannelIds: [] }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).toBeDisabled(); + }); + + it('is enabled when streams selected and target channel exists', () => { + setupMocks({ + selectedStreamIds: [1], + expandedChannelId: 42, + }); + render(); + const btn = screen.getByText('Add to Channel').closest('button'); + expect(btn).not.toBeDisabled(); + }); + }); + + // ── Single delete confirmation dialog ───────────────────────────────────── + + describe('single stream delete', () => { + it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm Bulk Stream Deletion'); + }); + + it('calls deleteStreams when delete is confirmed', async () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => { + expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled(); + }); + }); + + it('closes the dialog after confirming delete', async () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + it('closes the dialog on Cancel', () => { + setupMocks({ + selectedStreamIds: [1], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('skips the dialog and calls deleteStreams directly when warning is suppressed', async () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => true), + }); + render(); + fireEvent.click(screen.getByText('Delete')); + await waitFor(() => { + expect(StreamsTableUtils.deleteStreams).toHaveBeenCalled(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + }); + + // ── "Create Channel" button and modal ───────────────────────────────────── + + describe('Create Channel modal', () => { + it('opens CreateChannelModal when "Create Channel" is clicked with 1+ streams selected and warning not suppressed', () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + expect(screen.getByTestId('create-channel-modal')).toBeInTheDocument(); + }); + + it('closes CreateChannelModal when Close is clicked', () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + fireEvent.click(screen.getByTestId('create-channel-close')); + expect(screen.queryByTestId('create-channel-modal')).not.toBeInTheDocument(); + }); + + it('calls createChannelsFromStreamsAsync when modal is confirmed', async () => { + setupMocks({ + selectedStreamIds: [1, 2], + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByText('Create Channels (2)')); + fireEvent.click(screen.getByTestId('create-channel-confirm')); + await waitFor(() => { + expect(StreamsTableUtils.createChannelsFromStreamsAsync).toHaveBeenCalled(); + }); + }); + + it('"Create Channel" button is disabled when no streams selected', () => { + setupMocks({ selectedStreamIds: [] }); + render(); + const btn = screen.getByText('Create Channel (0)').closest('button'); + expect(btn).toBeDisabled(); + }); + }); + + // ── Getting started card navigation ─────────────────────────────────────── + + describe('getting started card', () => { + const renderEmpty = async () => { + vi.mocked(StreamsTableUtils.queryStreamsTable).mockResolvedValue({ count: 0, results: [] }); + setupMocks({ totalCount: 0, streams: [] }); + render(); + await waitFor(() => screen.getByText('Getting started')); + }; + + it('shows "Add M3U" button', async () => { + await renderEmpty(); + expect(screen.getByText('Add M3U')).toBeInTheDocument(); + }); + + it('shows "Add Individual Stream" button', async () => { + await renderEmpty(); + expect(screen.getByText('Add Individual Stream')).toBeInTheDocument(); + }); + + it('navigates to /sources when "Add M3U" is clicked', async () => { + const mockNavigate = vi.fn(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + await renderEmpty(); + fireEvent.click(screen.getByText('Add M3U')); + expect(mockNavigate).toHaveBeenCalledWith('/sources'); + }); + + it('opens stream form when "Add Individual Stream" is clicked', async () => { + await renderEmpty(); + fireEvent.click(screen.getByText('Add Individual Stream')); + expect(screen.getByTestId('stream-form')).toBeInTheDocument(); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('renders pagination controls when totalCount > 0', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + }); + }); + + it('renders current page number', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()], pagination: { pageIndex: 0, pageSize: 50 } }); + render(); + await waitFor(() => { + expect(screen.getByTestId('page-current')).toHaveTextContent('1'); + }); + }); + + it('renders native select for page size', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(screen.getByTestId('native-select')).toBeInTheDocument(); + }); + }); + }); + + // ── Column visibility (Table Settings menu) ──────────────────────────────── + + describe('column visibility toggle menu', () => { + it('renders "Toggle Columns" label in the settings menu', () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + // Menu label is always rendered even in collapsed state due to mock + expect(screen.getByText('Toggle Columns')).toBeInTheDocument(); + }); + + it('renders all column toggle menu items', () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Group')).toBeInTheDocument(); + expect(screen.getByText('M3U')).toBeInTheDocument(); + expect(screen.getByText('TVG-ID')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + }); + }); + + // ── useTable integration ─────────────────────────────────────────────────── + + describe('useTable integration', () => { + it('passes stream data to useTable', async () => { + const streams = [makeStream({ id: 1 }), makeStream({ id: 2 })]; + setupMocks({ streams, totalCount: 2 }); + render(); + await waitFor(() => { + expect(capturedTableOptions.data).toHaveLength(2); + }); + }); + + it('passes manualPagination: true to useTable', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(capturedTableOptions.manualPagination).toBe(true); + }); + }); + + it('passes manualSorting: true to useTable', async () => { + setupMocks({ totalCount: 5, streams: [makeStream()] }); + render(); + await waitFor(() => { + expect(capturedTableOptions.manualSorting).toBe(true); + }); + }); + + it('passes pagination state to useTable', async () => { + const pagination = { pageIndex: 2, pageSize: 25 }; + setupMocks({ totalCount: 5, streams: [makeStream()], pagination }); + render(); + await waitFor(() => { + expect(capturedTableOptions.state.pagination).toEqual(pagination); + }); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls queryStreamsTable on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.queryStreamsTable).toHaveBeenCalled(); + }); + }); + + it('calls getAllStreamIds on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.getAllStreamIds).toHaveBeenCalled(); + }); + }); + + it('calls getStreamFilterOptions on mount', async () => { + setupMocks(); + render(); + await waitFor(() => { + expect(StreamsTableUtils.getStreamFilterOptions).toHaveBeenCalled(); + }); + }); + + it('calls onReady callback once data is loaded', async () => { + setupMocks(); + const onReady = vi.fn(); + render(); + await waitFor(() => { + expect(onReady).toHaveBeenCalledTimes(1); + }); + }); + }); + + // ── "Add to Channel" action ──────────────────────────────────────────────── + + describe('"Add to Channel" action', () => { + it('calls addStreamsToChannel with selected streams', async () => { + const stream = makeStream({ id: 5 }); + setupMocks({ + streams: [stream], + selectedStreamIds: [5], + expandedChannelId: 42, + }); + render(); + fireEvent.click(screen.getByText('Add to Channel')); + await waitFor(() => { + expect(StreamsTableUtils.addStreamsToChannel).toHaveBeenCalledWith( + 42, + undefined, + expect.arrayContaining([expect.objectContaining({ id: 5 })]) + ); + }); + }); + }); + + // ── handleWatchStream ────────────────────────────────────────────────────── + + describe('handleWatchStream (via row actions)', () => { + it('calls buildLiveStreamUrl and showVideo via the actions cell renderer', async () => { + const mockShowVideo = vi.fn(); + setupMocks({ totalCount: 5, streams: [makeStream()], showVideo: mockShowVideo }); + render(); + await waitFor(() => expect(capturedTableOptions).not.toBeNull()); + + const actionsCell = capturedTableOptions.bodyCellRenderFns?.actions; + expect(actionsCell).toBeDefined(); + + // Render the actions cell to get the StreamRowActions component + const row = { + original: makeStream({ stream_hash: 'hash-abc', name: 'My Stream' }), + }; + const cell = { column: { id: 'actions' } }; + + // The actions renderer returns a StreamRowActions element; find Preview Stream button + const { getByText } = render(actionsCell({ cell, row })); + fireEvent.click(getByText('Preview Stream')); + expect(mockShowVideo).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx new file mode 100644 index 00000000..906bb0e6 --- /dev/null +++ b/frontend/src/components/tables/__tests__/UserAgentsTable.test.jsx @@ -0,0 +1,348 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteUserAgent: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/userAgents', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/UserAgent', () => ({ + default: ({ isOpen, onClose, userAgent }) => + isOpen ? ( +
+ {userAgent?.name ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: ({ color }) => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + X: ({ color }) => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useUserAgentsStore from '../../../store/userAgents'; +import useSettingsStore from '../../../store/settings'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import API from '../../../api'; +import UserAgentsTable from '../UserAgentsTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeUA = (overrides = {}) => ({ + id: 1, + name: 'Chrome Default', + user_agent: 'Mozilla/5.0 Chrome/120', + description: 'Standard Chrome UA', + is_active: true, + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + userAgents = [makeUA()], + defaultUserAgentId = 99, +} = {}) => { + vi.mocked(useUserAgentsStore).mockImplementation((sel) => + sel({ userAgents }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { default_user_agent: defaultUserAgentId } }) + ); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const makeRowCtx = (ua) => ({ + row: { id: String(ua.id), original: ua }, + cell: { + column: { id: 'actions', columnDef: {} }, + getValue: vi.fn(() => undefined), + }, +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('UserAgentsTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteUserAgent).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Add User-Agent" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add User-Agent')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + + it('passes all userAgents to useTable as data', () => { + const uas = [makeUA({ id: 1 }), makeUA({ id: 2 })]; + setupMocks({ userAgents: uas }); + render(); + expect(capturedTableOptions.data).toHaveLength(2); + }); + + it('passes an empty array when no userAgents exist', () => { + setupMocks({ userAgents: [] }); + render(); + expect(capturedTableOptions.data).toHaveLength(0); + }); + }); + + // ── Add User-Agent ───────────────────────────────────────────────────────── + + describe('Add User-Agent', () => { + it('opens the form with no user-agent when "Add User-Agent" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User-Agent')); + expect(screen.getByTestId('user-agent-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-ua-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User-Agent')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + }); + + // ── Edit via RowActions ──────────────────────────────────────────────────── + + describe('edit user-agent via RowActions', () => { + it('opens the form populated with the user-agent when edit icon is clicked', () => { + const ua = makeUA({ name: 'Firefox UA' }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('user-agent-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-ua-name')).toHaveTextContent('Firefox UA'); + }); + + it('closes the form after editing when onClose is called', () => { + const ua = makeUA({ name: 'Firefox UA' }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('user-agent-form')).not.toBeInTheDocument(); + }); + }); + + // ── Delete via RowActions (single) ───────────────────────────────────────── + + describe('delete user-agent via RowActions (single id)', () => { + it('calls API.deleteUserAgent with the user-agent id', async () => { + const ua = makeUA({ id: 7 }); + setupMocks({ userAgents: [ua] }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteUserAgent).toHaveBeenCalledWith(7) + ); + }); + + it('shows a notification and does NOT call API when deleting the default user-agent', async () => { + const ua = makeUA({ id: 5 }); + setupMocks({ userAgents: [ua], defaultUserAgentId: 5 }); + render(); + + const { row, cell } = makeRowCtx(ua); + const { getByTestId } = render( + capturedTableOptions.bodyCellRenderFns.actions({ cell, row }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cannot delete default user-agent', + color: 'red.5', + }) + ) + ); + expect(API.deleteUserAgent).not.toHaveBeenCalled(); + }); + }); + + // ── Active column cell renderer ──────────────────────────────────────────── + + describe('is_active column cell renderer', () => { + const renderIsActiveCell = (value) => { + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'is_active' + ); + return col.cell({ cell: { getValue: () => value } }); + }; + + it('renders Check icon when is_active is true', () => { + setupMocks(); + render(); + + const { getByTestId } = render(renderIsActiveCell(true)); + expect(getByTestId('icon-check')).toBeInTheDocument(); + }); + + it('renders X icon when is_active is false', () => { + setupMocks(); + render(); + + const { getByTestId } = render(renderIsActiveCell(false)); + expect(getByTestId('icon-x')).toBeInTheDocument(); + }); + }); + + // ── user_agent column cell renderer ─────────────────────────────────────── + + describe('user_agent column cell renderer', () => { + it('renders the user_agent string', () => { + setupMocks(); + render(); + + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'user_agent' + ); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'Mozilla/5.0 Safari/537' } }) + ); + expect(getByText('Mozilla/5.0 Safari/537')).toBeInTheDocument(); + }); + }); + + // ── description column cell renderer ────────────────────────────────────── + + describe('description column cell renderer', () => { + it('renders the description string', () => { + setupMocks(); + render(); + + const col = capturedTableOptions.columns.find( + (c) => c.accessorKey === 'description' + ); + const { getByText } = render( + col.cell({ cell: { getValue: () => 'A custom user agent' } }) + ); + expect(getByText('A custom user agent')).toBeInTheDocument(); + }); + }); + + // ── store reactivity ─────────────────────────────────────────────────────── + + describe('store reactivity', () => { + it('passes allRowIds derived from userAgent ids to useTable', () => { + const uas = [makeUA({ id: 10 }), makeUA({ id: 20 }), makeUA({ id: 30 })]; + setupMocks({ userAgents: uas }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([10, 20, 30]); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/UsersTable.test.jsx b/frontend/src/components/tables/__tests__/UsersTable.test.jsx new file mode 100644 index 00000000..c972fd81 --- /dev/null +++ b/frontend/src/components/tables/__tests__/UsersTable.test.jsx @@ -0,0 +1,674 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api', () => ({ + default: { + deleteUser: vi.fn().mockResolvedValue(undefined), + }, +})); + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/users', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + useDateTimeFormat: vi.fn(), + format: vi.fn((val) => `formatted:${val}`), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../forms/User', () => ({ + default: ({ isOpen, onClose, user }) => + isOpen ? ( +
+ {user?.username ?? 'new'} + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Flex: ({ children, style }) =>
{children}
, + Group: ({ children, style }) => ( +
{children}
+ ), + LoadingOverlay: ({ visible }) => visible ?
: null, + Paper: ({ children, style }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style, name }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + tailwind: { + yellow: { 3: '#fde047' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Eye: () => , + EyeOff: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useUsersStore from '../../../store/users'; +import useChannelsStore from '../../../store/channels'; +import useAuthStore from '../../../store/auth'; +import useWarningsStore from '../../../store/warnings'; +import { useDateTimeFormat, format } from '../../../utils/dateTimeUtils.js'; +import { useTable } from '../CustomTable'; +import API from '../../../api'; +import { USER_LEVELS, USER_LEVEL_LABELS } from '../../../constants'; +import UsersTable from '../UsersTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeUser = (overrides = {}) => ({ + id: 1, + username: 'testuser', + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + user_level: USER_LEVELS.STANDARD, + date_joined: '2024-01-15T10:00:00Z', + last_login: '2024-06-01T12:00:00Z', + custom_properties: { xc_password: 'secret123' }, + channel_profiles: [], + ...overrides, +}); + +const makeAdminUser = (overrides = {}) => + makeUser({ id: 99, username: 'admin', user_level: USER_LEVELS.ADMIN, ...overrides }); + +let capturedTableOptions = null; + +const setupMocks = ({ + users = [makeUser()], + authUser = makeAdminUser(), + profiles = { 10: { id: 10, name: 'HD Profile' } }, + isWarningSuppressed = vi.fn(() => false), + suppressWarning = vi.fn(), +} = {}) => { + vi.mocked(useUsersStore).mockImplementation((sel) => + sel({ users }) + ); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ profiles }) + ); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ user: authUser }) + ); + + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateFormat: 'MM/DD/YYYY', + fullDateTimeFormat: 'MM/DD/YYYY HH:mm', + }); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + }; + }); +}; + +const getActionsCell = () => + capturedTableOptions.columns.find((c) => c.id === 'actions'); + +const getCol = (key) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === key || c.id === key + ); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('UsersTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + vi.mocked(API.deleteUser).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the "Users" heading', () => { + setupMocks(); + render(); + expect(screen.getByText('Users')).toBeInTheDocument(); + }); + + it('renders the "Add User" button', () => { + setupMocks(); + render(); + expect(screen.getByText('Add User')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('does not render the user form on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('does not render the confirmation dialog on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('passes users sorted by id to useTable', () => { + const users = [ + makeUser({ id: 3, username: 'c' }), + makeUser({ id: 1, username: 'a' }), + makeUser({ id: 2, username: 'b' }), + ]; + setupMocks({ users }); + render(); + expect(capturedTableOptions.data.map((u) => u.id)).toEqual([1, 2, 3]); + }); + + it('passes allRowIds derived from user ids', () => { + const users = [makeUser({ id: 1 }), makeUser({ id: 2 })]; + setupMocks({ users }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([1, 2]); + }); + }); + + // ── "Add User" button state ──────────────────────────────────────────────── + + describe('"Add User" button access control', () => { + it('is enabled for admin users', () => { + setupMocks({ authUser: makeAdminUser() }); + render(); + expect(screen.getByText('Add User').closest('button')).not.toBeDisabled(); + }); + + it('is disabled for non-admin users', () => { + setupMocks({ authUser: makeUser({ user_level: USER_LEVELS.STANDARD }) }); + render(); + expect(screen.getByText('Add User').closest('button')).toBeDisabled(); + }); + }); + + // ── Add / Edit User form ─────────────────────────────────────────────────── + + describe('Add User form', () => { + it('opens the form with no user when "Add User" is clicked', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User')); + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-user-name')).toHaveTextContent('new'); + }); + + it('closes the form when onClose is called', () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Add User')); + fireEvent.click(screen.getByTestId('form-close')); + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + }); + + describe('Edit user via actions column', () => { + it('opens the form populated with the user when edit icon is clicked', () => { + const user = makeUser({ username: 'janedoe' }); + setupMocks({ users: [user] }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByTestId('form-user-name')).toHaveTextContent('janedoe'); + }); + + it('closes the form after editing when onClose is called', () => { + const user = makeUser({ username: 'janedoe' }); + setupMocks({ users: [user] }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('form-close')); + + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('edit button is disabled for non-admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-pen').closest('button')).toBeDisabled(); + }); + + it('edit button is enabled for admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeAdminUser() }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + }); + }); + + // ── Delete user ──────────────────────────────────────────────────────────── + + describe('Delete user via actions column', () => { + it('opens ConfirmationDialog when delete is clicked and warning is not suppressed', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm User Deletion'); + }); + + it('calls API.deleteUser when confirmed via dialog', async () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(API.deleteUser).toHaveBeenCalledWith(5) + ); + }); + + it('closes the dialog after confirming delete', async () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes the dialog when Cancel is clicked', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => false) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('skips dialog and calls API.deleteUser directly when warning is suppressed', async () => { + const user = makeUser({ id: 7 }); + setupMocks({ users: [user], isWarningSuppressed: vi.fn(() => true) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + fireEvent.click(getByTestId('icon-square-minus').closest('button')); + + await waitFor(() => + expect(API.deleteUser).toHaveBeenCalledWith(7) + ); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('delete button is disabled for non-admin auth user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeUser({ id: 99, user_level: USER_LEVELS.STANDARD }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is disabled when admin tries to delete themselves', () => { + const admin = makeAdminUser({ id: 99 }); + setupMocks({ users: [admin], authUser: admin }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: admin } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).toBeDisabled(); + }); + + it('delete button is enabled for admin deleting a different user', () => { + const user = makeUser({ id: 5 }); + setupMocks({ users: [user], authUser: makeAdminUser({ id: 99 }) }); + render(); + + const actionsCol = getActionsCell(); + const { getByTestId } = render( + actionsCol.cell({ row: { original: user } }) + ); + expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + }); + }); + + // ── XCPasswordCell ───────────────────────────────────────────────────────── + + describe('XCPasswordCell', () => { + const renderXCCell = (customProperties) => { + const XCCell = getCol('custom_properties').cell; + return render( customProperties} />); + }; + + it('hides password by default (shows bullets)', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell({ xc_password: 'mypassword' }); + expect(getByText('••••••••')).toBeInTheDocument(); + }); + + it('shows the password after clicking the eye toggle', () => { + setupMocks(); + render(); + + const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' }); + fireEvent.click(getByTestId('icon-eye').closest('button')); + expect(getByText('mypassword')).toBeInTheDocument(); + }); + + it('hides the password again after toggling twice', () => { + setupMocks(); + render(); + + const { getByText, getByTestId } = renderXCCell({ xc_password: 'mypassword' }); + const toggleBtn = getByTestId('icon-eye').closest('button'); + fireEvent.click(toggleBtn); + fireEvent.click(getByTestId('icon-eye-off').closest('button')); + expect(getByText('••••••••')).toBeInTheDocument(); + }); + + it('shows "N/A" when no xc_password', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell({}); + expect(getByText('N/A')).toBeInTheDocument(); + }); + + it('does not render the eye toggle when password is N/A', () => { + setupMocks(); + render(); + + const { queryByTestId } = renderXCCell({}); + expect(queryByTestId('icon-eye')).not.toBeInTheDocument(); + }); + + it('shows "N/A" when custom_properties is null', () => { + setupMocks(); + render(); + + const { getByText } = renderXCCell(null); + expect(getByText('N/A')).toBeInTheDocument(); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('user_level column', () => { + it('renders the label for ADMIN level', () => { + setupMocks(); + render(); + const col = getCol('user_level'); + const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.ADMIN })); + expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.ADMIN])).toBeInTheDocument(); + }); + + it('renders the label for STANDARD level', () => { + setupMocks(); + render(); + const col = getCol('user_level'); + const { getByText } = render(col.cell({ getValue: () => USER_LEVELS.STANDARD })); + expect(getByText(USER_LEVEL_LABELS[USER_LEVELS.STANDARD])).toBeInTheDocument(); + }); + }); + + describe('name column (accessorFn)', () => { + it('combines first_name and last_name', () => { + setupMocks(); + render(); + const col = getCol('name'); + const value = col.accessorFn({ first_name: 'Jane', last_name: 'Doe' }); + expect(value).toBe('Jane Doe'); + }); + + it('trims when only first_name is set', () => { + setupMocks(); + render(); + const col = getCol('name'); + const value = col.accessorFn({ first_name: 'Jane', last_name: '' }); + expect(value).toBe('Jane'); + }); + + it('cell renders "-" when value is empty', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render(col.cell({ getValue: () => '' })); + expect(getByText('-')).toBeInTheDocument(); + }); + + it('cell renders the full name when set', () => { + setupMocks(); + render(); + const col = getCol('name'); + const { getByText } = render(col.cell({ getValue: () => 'Jane Doe' })); + expect(getByText('Jane Doe')).toBeInTheDocument(); + }); + }); + + describe('date_joined column', () => { + it('calls format with fullDateFormat when date is present', () => { + setupMocks(); + render(); + const col = getCol('date_joined'); + const { getByText } = render(col.cell({ getValue: () => '2024-01-15T10:00:00Z' })); + expect(vi.mocked(format)).toHaveBeenCalledWith('2024-01-15T10:00:00Z', 'MM/DD/YYYY'); + expect(getByText('formatted:2024-01-15T10:00:00Z')).toBeInTheDocument(); + }); + + it('renders "-" when date is null', () => { + setupMocks(); + render(); + const col = getCol('date_joined'); + const { getByText } = render(col.cell({ getValue: () => null })); + expect(getByText('-')).toBeInTheDocument(); + }); + }); + + describe('last_login column', () => { + it('calls format with fullDateTimeFormat when date is present', () => { + setupMocks(); + render(); + const col = getCol('last_login'); + const { getByText } = render(col.cell({ getValue: () => '2024-06-01T12:00:00Z' })); + expect(vi.mocked(format)).toHaveBeenCalledWith('2024-06-01T12:00:00Z', 'MM/DD/YYYY HH:mm'); + expect(getByText('formatted:2024-06-01T12:00:00Z')).toBeInTheDocument(); + }); + + it('renders "Never" when last_login is null', () => { + setupMocks(); + render(); + const col = getCol('last_login'); + const { getByText } = render(col.cell({ getValue: () => null })); + expect(getByText('Never')).toBeInTheDocument(); + }); + }); + + describe('channel_profiles column', () => { + it('renders "All" badge when user has no profiles assigned', () => { + setupMocks({ profiles: { 10: { id: 10, name: 'HD Profile' } } }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [] })); + expect(getByText('All')).toBeInTheDocument(); + }); + + it('renders a badge for each assigned profile', () => { + setupMocks({ + profiles: { 10: { id: 10, name: 'HD Profile' }, 20: { id: 20, name: 'SD Profile' } }, + }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [10, 20] })); + expect(getByText('HD Profile')).toBeInTheDocument(); + expect(getByText('SD Profile')).toBeInTheDocument(); + }); + + it('renders "All" when profile ids do not match any profiles', () => { + setupMocks({ profiles: {} }); + render(); + const col = getCol('channel_profiles'); + const { getByText } = render(col.cell({ getValue: () => [99] })); + expect(getByText('All')).toBeInTheDocument(); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes enablePagination: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(false); + }); + + it('passes manualSorting: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(false); + }); + }); +}); diff --git a/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx new file mode 100644 index 00000000..3375121e --- /dev/null +++ b/frontend/src/components/tables/__tests__/VODLogosTable.test.jsx @@ -0,0 +1,1026 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/vodLogos', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['default', vi.fn()]), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Child component mocks ────────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + opened ? ( +
+ {title} + + +
+ ) : null, +})); + +vi.mock('../CustomTable', () => ({ + CustomTable: () =>
, + useTable: vi.fn(), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, leftSection, disabled, loading }) => ( + + ), + Center: ({ children, style }) =>
{children}
, + Checkbox: ({ checked, indeterminate, onChange }) => ( + + ), + Group: ({ children, style }) => ( +
{children}
+ ), + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + LoadingOverlay: ({ visible }) => + visible ?
: null, + NativeSelect: ({ value, data, onChange }) => ( + + ), + Pagination: ({ total, value, onChange }) => ( +
+ + {value} + +
+ ), + Paper: ({ children, style }) =>
{children}
, + Select: ({ value, onChange, data }) => ( + + ), + Stack: ({ children, style }) =>
{children}
, + Text: ({ children, style, name }) => ( + + {children} + + ), + TextInput: ({ value, onChange, placeholder }) => ( + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + tailwind: { + red: { 6: '#dc2626' }, + }, + })), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ExternalLink: () => , + SquareMinus: () => , + Trash: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useVODLogosStore from '../../../store/vodLogos'; +import { useTable } from '../CustomTable'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import VODLogosTable from '../VODLogosTable'; + +// ── Factories ────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'Test Logo', + url: 'http://example.com/logo.png', + cache_url: '/cache/logo.png', + movie_count: 0, + series_count: 0, + item_names: [], + ...overrides, +}); + +let capturedTableOptions = null; + +const setupMocks = ({ + logos = [makeLogo()], + totalCount = 1, + isLoading = false, + unusedCount = 0, +} = {}) => { + const fetchVODLogos = vi.fn().mockResolvedValue(undefined); + const deleteVODLogo = vi.fn().mockResolvedValue(undefined); + const deleteVODLogos = vi.fn().mockResolvedValue(undefined); + const cleanupUnusedVODLogos = vi.fn().mockResolvedValue({ deleted_count: 3 }); + const getUnusedLogosCount = vi.fn().mockResolvedValue(unusedCount); + + vi.mocked(useVODLogosStore).mockReturnValue({ + logos, + totalCount, + isLoading, + fetchVODLogos, + deleteVODLogo, + deleteVODLogos, + cleanupUnusedVODLogos, + getUnusedLogosCount, + }); + + vi.mocked(useTable).mockImplementation((opts) => { + capturedTableOptions = opts; + return { + getRowModel: () => ({ rows: [] }), + getHeaderGroups: () => [], + setSelectedTableIds: vi.fn(), + }; + }); + + return { + fetchVODLogos, + deleteVODLogo, + deleteVODLogos, + cleanupUnusedVODLogos, + getUnusedLogosCount, + }; +}; + +const getCol = (key) => + capturedTableOptions.columns.find( + (c) => c.accessorKey === key || c.id === key + ); + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +describe('VODLogosTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedTableOptions = null; + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the name filter input', () => { + setupMocks(); + render(); + expect(screen.getByTestId('name-filter')).toBeInTheDocument(); + }); + + it('renders the usage filter select', () => { + setupMocks(); + render(); + expect(screen.getByTestId('usage-filter')).toBeInTheDocument(); + }); + + it('renders the custom table', () => { + setupMocks(); + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders pagination controls', () => { + setupMocks(); + render(); + expect(screen.getByTestId('pagination')).toBeInTheDocument(); + expect(screen.getByTestId('page-size-select')).toBeInTheDocument(); + }); + + it('does not render confirmation dialogs on initial load', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('renders "Cleanup Unused" button', () => { + setupMocks(); + render(); + expect(screen.getByText(/Cleanup Unused/)).toBeInTheDocument(); + }); + + it('renders "Delete" button', () => { + setupMocks(); + render(); + expect(screen.getByText(/^Delete/)).toBeInTheDocument(); + }); + + it('shows LoadingOverlay when isLoading is true', () => { + setupMocks({ isLoading: true }); + render(); + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument(); + }); + + it('does not show LoadingOverlay when isLoading is false', () => { + setupMocks({ isLoading: false }); + render(); + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + it('renders pagination string on initial load', () => { + setupMocks({ totalCount: 50 }); + render(); + expect(screen.getByText('1 to 25 of 50')).toBeInTheDocument(); + }); + }); + + // ── Initial data fetch ───────────────────────────────────────────────────── + + describe('initial data fetch', () => { + it('calls fetchVODLogos with default params on mount', () => { + const { fetchVODLogos } = setupMocks(); + render(); + expect(fetchVODLogos).toHaveBeenCalledWith({ + page: 1, + page_size: 25, + name: '', + usage: undefined, + }); + }); + + it('calls getUnusedLogosCount on mount', () => { + const { getUnusedLogosCount } = setupMocks(); + render(); + expect(getUnusedLogosCount).toHaveBeenCalled(); + }); + + it('"Cleanup Unused" button shows count and becomes enabled when unusedCount resolves > 0', async () => { + setupMocks({ unusedCount: 7 }); + render(); + await waitFor(() => { + expect(screen.getByText(/Cleanup Unused \(7\)/)).toBeInTheDocument(); + expect( + screen.getByText(/Cleanup Unused \(7\)/).closest('button') + ).not.toBeDisabled(); + }); + }); + }); + + // ── Name filter ──────────────────────────────────────────────────────────── + + describe('name filter', () => { + it('calls fetchVODLogos with updated name when filter changes', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: 'HBO' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ name: 'HBO', page: 1 }) + ); + }); + + it('passes name: "" when filter is cleared', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: 'ESPN' }, + }); + fireEvent.change(screen.getByTestId('name-filter'), { + target: { value: '' }, + }); + expect(fetchVODLogos).toHaveBeenLastCalledWith( + expect.objectContaining({ name: '' }) + ); + }); + }); + + // ── Usage filter ─────────────────────────────────────────────────────────── + + describe('usage filter', () => { + it('passes usage: undefined when filter is "all"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: undefined }) + ); + }); + + it('passes usage: "used" when filter changes to "used"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('usage-filter'), { + target: { value: 'used' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: 'used' }) + ); + }); + + it('passes usage: "unused" when filter changes to "unused"', () => { + const { fetchVODLogos } = setupMocks(); + render(); + fireEvent.change(screen.getByTestId('usage-filter'), { + target: { value: 'unused' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ usage: 'unused' }) + ); + }); + }); + + // ── "Cleanup Unused" button ──────────────────────────────────────────────── + + describe('"Cleanup Unused" button', () => { + it('is disabled when unusedLogosCount is 0', () => { + setupMocks({ unusedCount: 0 }); + render(); + expect( + screen.getByText(/Cleanup Unused/).closest('button') + ).toBeDisabled(); + }); + + it('opens cleanup dialog when clicked', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Cleanup Unused Logos' + ); + }); + + it('calls cleanupUnusedVODLogos when confirmed', async () => { + const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(cleanupUnusedVODLogos).toHaveBeenCalled() + ); + }); + + it('shows success notification after cleanup', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'Cleaned up 3 unused VOD logos', + color: 'green', + }) + ) + ); + }); + + it('closes cleanup dialog after confirmation', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('closes cleanup dialog on cancel', async () => { + setupMocks({ unusedCount: 4 }); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-cancel')); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('shows error notification when cleanupUnusedVODLogos throws', async () => { + const { cleanupUnusedVODLogos } = setupMocks({ unusedCount: 4 }); + cleanupUnusedVODLogos.mockRejectedValue(new Error('Server error')); + render(); + await waitFor(() => + expect( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ).not.toBeDisabled() + ); + fireEvent.click( + screen.getByText(/Cleanup Unused \(4\)/).closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── "Delete selected" button ─────────────────────────────────────────────── + + describe('"Delete selected" button', () => { + it('is disabled when no rows are selected', () => { + setupMocks(); + render(); + // Find the Delete button (not the cleanup button) + const buttons = screen.getAllByTestId('button'); + const deleteBtn = buttons.find((b) => b.textContent.includes('Delete') && !b.textContent.includes('Cleanup')); + expect(deleteBtn).toBeDisabled(); + }); + + it('shows row count in button label when rows are selected', async () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + // Render row checkbox and click to select + const selectCol = getCol('select'); + const { getByTestId: getRowCheckbox } = render( + selectCol.cell({ row: { original: logo } }) + ); + fireEvent.click(getRowCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(1\)/)).toBeInTheDocument() + ); + }); + + it('opens delete dialog with "Delete Multiple Logos" title when multiple rows are selected', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + + // Select all via header checkbox + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render( + selectCol.header() + ); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Delete Multiple Logos' + ); + }); + + it('calls deleteVODLogos when bulk delete is confirmed', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + const { deleteVODLogos } = setupMocks({ logos }); + render(); + + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render(selectCol.header()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(deleteVODLogos).toHaveBeenCalledWith([1, 2]) + ); + }); + + it('shows success notification with count after bulk delete', async () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + + const selectCol = getCol('select'); + const { getByTestId: getHeaderCheckbox } = render(selectCol.header()); + fireEvent.click(getHeaderCheckbox('checkbox')); + + await waitFor(() => + expect(screen.getByText(/Delete \(2\)/)).toBeInTheDocument() + ); + fireEvent.click(screen.getByText(/Delete \(2\)/).closest('button')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: '2 VOD logos deleted successfully', + color: 'green', + }) + ) + ); + }); + }); + + // ── Delete via row actions ───────────────────────────────────────────────── + + describe('delete via row actions', () => { + it('opens delete dialog when row delete button is clicked', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + }); + + it('shows "Delete Logo" title for single row delete', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Delete Logo' + ); + }); + + it('closes delete dialog on cancel', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-cancel')); + + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Single delete confirmation ───────────────────────────────────────────── + + describe('single delete confirmation', () => { + const setupSingleDeleteFlow = () => { + const logo = makeLogo({ id: 5, name: 'ESPN Logo' }); + const mocks = setupMocks({ logos: [logo] }); + render(); + + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + return { logo, ...mocks }; + }; + + it('calls deleteVODLogo with the logo id when confirmed', async () => { + const { deleteVODLogo } = setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => expect(deleteVODLogo).toHaveBeenCalledWith(5)); + }); + + it('shows success notification after single delete', async () => { + setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'VOD logo deleted successfully', + color: 'green', + }) + ) + ); + }); + + it('closes dialog after single delete', async () => { + setupSingleDeleteFlow(); + fireEvent.click(screen.getByTestId('confirm-ok')); + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + + it('shows error notification when deleteVODLogo throws', async () => { + const logo = makeLogo({ id: 5 }); + const { deleteVODLogo } = setupMocks({ logos: [logo] }); + deleteVODLogo.mockRejectedValue(new Error('Network error')); + + render(); + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Error', + message: 'Network error', + color: 'red', + }) + ) + ); + }); + + it('closes dialog even when deleteVODLogo throws', async () => { + const logo = makeLogo({ id: 5 }); + const { deleteVODLogo } = setupMocks({ logos: [logo] }); + deleteVODLogo.mockRejectedValue(new Error('Network error')); + + render(); + const { container } = render( + getCol('actions').cell({ row: { original: logo } }) + ); + fireEvent.click( + within(container).getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + ); + }); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + describe('pagination', () => { + it('calls fetchVODLogos with page 2 when next page is clicked', () => { + const { fetchVODLogos } = setupMocks({ totalCount: 100 }); + render(); + fireEvent.click(screen.getByTestId('pagination-next')); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 2 }) + ); + }); + + it('resets to page 1 when page size changes', () => { + const { fetchVODLogos } = setupMocks({ totalCount: 200 }); + render(); + + // Go to page 2 first + fireEvent.click(screen.getByTestId('pagination-next')); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 2 }) + ); + + // Change page size — should reset to page 1 + fireEvent.change(screen.getByTestId('page-size-select'), { + target: { value: '50' }, + }); + expect(fetchVODLogos).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, page_size: 50 }) + ); + }); + + it('renders correct pagination string based on page and totalCount', () => { + setupMocks({ totalCount: 75 }); + render(); + expect(screen.getByText('1 to 25 of 75')).toBeInTheDocument(); + }); + + it('passes correct pageCount to useTable', () => { + setupMocks({ totalCount: 75 }); + render(); + // ceil(75 / 25) = 3 + expect(capturedTableOptions.pageCount).toBe(3); + }); + + it('updates pagination string after changing page size', () => { + setupMocks({ totalCount: 75 }); + render(); + fireEvent.change(screen.getByTestId('page-size-select'), { + target: { value: '50' }, + }); + expect(screen.getByText('1 to 50 of 75')).toBeInTheDocument(); + }); + }); + + // ── Column cell renderers ────────────────────────────────────────────────── + + describe('usage column', () => { + it('shows "Unused" badge when movie_count and series_count are both 0', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { movie_count: 0, series_count: 0, item_names: [] }, + }, + }) + ); + expect(getByText('Unused')).toBeInTheDocument(); + }); + + it('shows movie count label when only movies use the logo', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 2, + series_count: 0, + item_names: ['Movie A', 'Movie B'], + }, + }, + }) + ); + expect(getByText('2 movies')).toBeInTheDocument(); + }); + + it('uses singular "movie" for exactly 1 movie', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { movie_count: 1, series_count: 0, item_names: ['A'] }, + }, + }) + ); + expect(getByText('1 movie')).toBeInTheDocument(); + }); + + it('shows combined item count when both movies and series are used', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 2, + series_count: 1, + item_names: ['A', 'B', 'C'], + }, + }, + }) + ); + expect(getByText('3 items')).toBeInTheDocument(); + }); + + it('shows series count label when only series use the logo', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('usage').cell({ + row: { + original: { + movie_count: 0, + series_count: 3, + item_names: ['S1', 'S2', 'S3'], + }, + }, + }) + ); + expect(getByText('3 series')).toBeInTheDocument(); + }); + }); + + describe('url column', () => { + it('renders the URL text', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + expect(getByText('http://example.com/logo.png')).toBeInTheDocument(); + }); + + it('shows ExternalLink icon for http URLs', () => { + setupMocks(); + render(); + const { getByTestId } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + expect(getByTestId('icon-external-link')).toBeInTheDocument(); + }); + + it('hides ExternalLink for non-http (local) URLs', () => { + setupMocks(); + render(); + const { queryByTestId } = render( + getCol('url').cell({ getValue: () => '/data/logos/logo.png' }) + ); + expect(queryByTestId('icon-external-link')).not.toBeInTheDocument(); + }); + + it('opens URL in new tab when ExternalLink is clicked', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + setupMocks(); + render(); + const { getByTestId } = render( + getCol('url').cell({ getValue: () => 'http://example.com/logo.png' }) + ); + fireEvent.click(getByTestId('icon-external-link').closest('button')); + expect(openSpy).toHaveBeenCalledWith( + 'http://example.com/logo.png', + '_blank' + ); + openSpy.mockRestore(); + }); + }); + + describe('name column', () => { + it('renders the logo name', () => { + setupMocks(); + render(); + const { getByText } = render( + getCol('name').cell({ getValue: () => 'ESPN Logo' }) + ); + expect(getByText('ESPN Logo')).toBeInTheDocument(); + }); + }); + + describe('cache_url (Preview) column', () => { + it('renders an img with the correct src and alt', () => { + setupMocks(); + render(); + const { getByAltText } = render( + getCol('cache_url').cell({ + getValue: () => '/cache/espn.png', + row: { original: { name: 'ESPN Logo' } }, + }) + ); + const img = getByAltText('ESPN Logo'); + expect(img).toHaveAttribute('src', '/cache/espn.png'); + }); + }); + + describe('select column', () => { + it('renders an unchecked header checkbox when nothing is selected', () => { + const logos = [makeLogo({ id: 1 }), makeLogo({ id: 2 })]; + setupMocks({ logos }); + render(); + const { getByTestId } = render(getCol('select').header()); + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + + it('renders an unchecked row checkbox for an unselected row', () => { + const logo = makeLogo({ id: 5 }); + setupMocks({ logos: [logo] }); + render(); + const { getByTestId } = render( + getCol('select').cell({ row: { original: logo } }) + ); + expect(getByTestId('checkbox')).not.toBeChecked(); + }); + }); + + // ── useTable options ─────────────────────────────────────────────────────── + + describe('useTable options', () => { + it('passes enablePagination: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enablePagination).toBe(false); + }); + + it('passes enableRowSelection: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.enableRowSelection).toBe(true); + }); + + it('passes manualPagination: true', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualPagination).toBe(true); + }); + + it('passes logos as data', () => { + const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })]; + setupMocks({ logos }); + render(); + expect(capturedTableOptions.data).toEqual(logos); + }); + + it('passes allRowIds derived from logo ids', () => { + const logos = [makeLogo({ id: 10 }), makeLogo({ id: 20 })]; + setupMocks({ logos }); + render(); + expect(capturedTableOptions.allRowIds).toEqual([10, 20]); + }); + + it('passes manualSorting: false', () => { + setupMocks(); + render(); + expect(capturedTableOptions.manualSorting).toBe(false); + }); + }); +}); From fe44af7d38bdb2cbfa626cfd288f8d19f6eb2656 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:51:47 -0700 Subject: [PATCH 32/56] Updated to use datetime util --- frontend/src/components/cards/VodConnectionCard.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx index 90b4c034..8efe4e55 100644 --- a/frontend/src/components/cards/VodConnectionCard.jsx +++ b/frontend/src/components/cards/VodConnectionCard.jsx @@ -16,6 +16,7 @@ import { } from '@mantine/core'; import { convertToSec, + formatDuration, fromNow, toFriendlyDuration, useDateTimeFormat, @@ -31,8 +32,6 @@ import { calculateConnectionDuration, calculateConnectionStartTime, calculateProgress, - formatDuration, - formatTime, getEpisodeDisplayTitle, getEpisodeSubtitle, getMovieDisplayTitle, @@ -154,7 +153,7 @@ const ConnectionProgress = ({ connection, durationSecs }) => { Progress - {formatTime(currentTime)} / {formatTime(totalTime)} + {formatDuration(currentTime)} / {formatDuration(totalTime)} { )} - {metadata.duration_secs && ( - {formatDuration(metadata.duration_secs)} + {formatDuration(metadata.duration_secs, { zeroValue: 'Unknown', precision: 'hm' })} )} From 931f4dc50f029e3e043af578d0a18f7a80d9a039 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 09:28:24 -0500 Subject: [PATCH 33/56] refactor(tasks): Enhance rollup_channel_catchup_fields function to limit updates to channels linked to the specified account. Update SQL queries for clarity and efficiency, and improve docstrings for better understanding of the self-heal logic related to catch-up flags. --- apps/m3u/tasks.py | 29 ++++++++++++-------- apps/timeshift/tests/test_views.py | 44 ++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 34a72316..65326c75 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1885,11 +1885,22 @@ def _classify_sync_failure(exc): def rollup_channel_catchup_fields(account_id): - """Roll up catch-up flags from streams to channels (active accounts only).""" + """Roll up catch-up flags from streams to channels (active accounts only). + + Both the aggregate update and the self-heal pass are limited to channels + that still have at least one stream from *account_id*. + """ from django.db import connection + account_channels = """ + SELECT DISTINCT cs.channel_id + FROM dispatcharr_channels_channelstream cs + JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id + WHERE s.m3u_account_id = %s + """ + with connection.cursor() as cur: - cur.execute(""" + cur.execute(f""" WITH agg AS ( SELECT cs.channel_id, @@ -1898,12 +1909,7 @@ def rollup_channel_catchup_fields(account_id): FROM dispatcharr_channels_channelstream cs JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id JOIN m3u_m3uaccount a ON a.id = s.m3u_account_id - WHERE cs.channel_id IN ( - SELECT DISTINCT cs2.channel_id - FROM dispatcharr_channels_channelstream cs2 - JOIN dispatcharr_channels_stream s2 ON s2.id = cs2.stream_id - WHERE s2.m3u_account_id = %s - ) + WHERE cs.channel_id IN ({account_channels}) GROUP BY cs.channel_id ) UPDATE dispatcharr_channels_channel c @@ -1914,11 +1920,12 @@ def rollup_channel_catchup_fields(account_id): WHERE c.id = agg.channel_id """, [account_id]) - # Self-heal stale is_catchup flags. - cur.execute(""" + # Self-heal stale is_catchup flags on account-linked channels only. + cur.execute(f""" UPDATE dispatcharr_channels_channel c SET is_catchup = FALSE, catchup_days = 0 WHERE c.is_catchup = TRUE + AND c.id IN ({account_channels}) AND NOT EXISTS ( SELECT 1 FROM dispatcharr_channels_channelstream cs @@ -1928,7 +1935,7 @@ def rollup_channel_catchup_fields(account_id): AND s.is_catchup = TRUE AND a.is_active = TRUE ) - """) + """, [account_id]) @shared_task diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 310649cb..535e455e 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -1901,10 +1901,9 @@ class TimeshiftScrubPreemptTests(TestCase): class RollupSelfHealDbTests(TestCase): - """Catch-up flag consistency after stream removal — the review-#4 point 1 - guarantees: the ChannelStream signal handles bulk deletes (locked by a - regression test) and the rollup self-heals any channel left flagged with - no catch-up stream, regardless of how the link rows disappeared.""" + """Catch-up flag consistency after stream removal — the ChannelStream signal + handles bulk deletes (locked by a regression test) and the account-scoped + rollup self-heals stale flags on channels still linked to that account.""" @classmethod def setUpTestData(cls): @@ -1944,14 +1943,21 @@ class RollupSelfHealDbTests(TestCase): self.assertFalse(channel.is_catchup) self.assertEqual(channel.catchup_days, 0) - def test_rollup_self_heals_stale_channel_without_streams(self): - # Simulate staleness no signal caught (raw SQL delete, historic data): - # a flagged channel with zero catch-up streams must be reset by the - # rollup even though no remaining stream ties it to the account. - from apps.channels.models import Channel + def test_rollup_self_heals_stale_channel_with_non_catchup_stream(self): + # Channel still linked to the account but no active catch-up streams + # (e.g. catch-up flag cleared on import) — rollup must reset stale flags. + from apps.channels.models import Channel, ChannelStream, Stream from apps.m3u.tasks import rollup_channel_catchup_fields channel = Channel.objects.create(name="ts-rollup-stale") + stream = Stream.objects.create( + name="ts-rollup-stale-stream", + url="http://example.test/ts-rollup-stale", + m3u_account=self.account, + is_catchup=False, + catchup_days=0, + ) + ChannelStream.objects.create(channel=channel, stream=stream, order=0) Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) rollup_channel_catchup_fields(self.account.id) @@ -1960,6 +1966,26 @@ class RollupSelfHealDbTests(TestCase): self.assertFalse(channel.is_catchup) self.assertEqual(channel.catchup_days, 0) + def test_rollup_self_heal_skips_channels_not_linked_to_account(self): + from apps.channels.models import Channel + from apps.m3u.models import M3UAccount + from apps.m3u.tasks import rollup_channel_catchup_fields + + other_account = M3UAccount.objects.create( + name="ts-rollup-other", + server_url="http://example.test/other", + account_type="XC", + is_active=True, + ) + channel = Channel.objects.create(name="ts-rollup-unrelated") + Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9) + + rollup_channel_catchup_fields(other_account.id) + + channel.refresh_from_db() + self.assertTrue(channel.is_catchup) + self.assertEqual(channel.catchup_days, 9) + def test_rollup_keeps_and_corrects_channels_with_catchup_streams(self): # The self-heal pass must not touch channels that legitimately have # catch-up streams — and the account-scoped pass still corrects their From ec8594cd0adcecb284a9bacfcc979680cc842286 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 10:11:49 -0500 Subject: [PATCH 34/56] refactor(tasks): Add is_catchup and catchup_days fields to Stream processing logic. Update existing stream queries and conditions to handle new catch-up properties, ensuring accurate updates and memory management for bulk operations. --- apps/m3u/tasks.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 65326c75..aa67335f 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1089,7 +1089,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days' ) } @@ -1106,9 +1106,15 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.is_adult != stream_props["is_adult"] or obj.stream_id != stream_props["stream_id"] or obj.stream_chno != stream_props["stream_chno"] or - obj.channel_group_id != stream_props["channel_group_id"] + obj.channel_group_id != stream_props["channel_group_id"] or + obj.is_catchup != stream_props["is_catchup"] or + obj.catchup_days != stream_props["catchup_days"] ) + # Keep denormalized catch-up columns in memory (bulk_update reads them). + obj.is_catchup = stream_props["is_catchup"] + obj.catchup_days = stream_props["catchup_days"] + if changed: for key, value in stream_props.items(): setattr(obj, key, value) @@ -1322,7 +1328,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days' ) } @@ -1339,9 +1345,15 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.is_adult != stream_props["is_adult"] or obj.stream_id != stream_props["stream_id"] or obj.stream_chno != stream_props["stream_chno"] or - obj.channel_group_id != stream_props["channel_group_id"] + obj.channel_group_id != stream_props["channel_group_id"] or + obj.is_catchup != stream_props["is_catchup"] or + obj.catchup_days != stream_props["catchup_days"] ) + # Keep denormalized catch-up columns in memory (bulk_update reads them). + obj.is_catchup = stream_props["is_catchup"] + obj.catchup_days = stream_props["catchup_days"] + # Always update last_seen obj.last_seen = timezone.now() From 96a39ce5d2dcb708397f3b24e4d2af54767fa1ea Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 11:13:17 -0500 Subject: [PATCH 35/56] refactor(tasks): Improve stream processing logic by adding bulk update functionality for unchanged streams. Enhance memory management and error handling in database queries, and update cleanup functions to optimize memory usage. --- CHANGELOG.md | 11 ++- apps/m3u/tasks.py | 105 ++++++++++++++++------------- apps/timeshift/tests/test_views.py | 14 ++-- core/utils.py | 9 ++- dispatcharr/celery.py | 3 +- 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad83500..ce80b8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) (#1242) - - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; the per-account rollup also self-heals channels left flagged with no remaining catch-up stream (e.g. after stale-stream cleanup on manual/multi-provider channels). + - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated). - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. +### Performance + +- **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change. +- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. + +### Fixed + +- **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. + ## [0.27.1] - 2026-06-25 ### Security diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index aa67335f..7ef71dda 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -52,9 +52,9 @@ def _db_query_with_retry(fn, *, label="DB query", max_retries=2): Poisoned Celery worker connections often surface as OperationalError or as ``IndexError: list index out of range`` inside Django's row converters. """ - from django.db import InterfaceError, OperationalError + from django.db import DatabaseError, InterfaceError, OperationalError - transient_errors = (OperationalError, InterfaceError, IndexError) + transient_errors = (OperationalError, InterfaceError, IndexError, DatabaseError) for attempt in range(max_retries): try: return fn() @@ -746,13 +746,16 @@ def process_groups(account, groups, scan_start_time=None): all_group_objs = existing_group_objs + newly_created_group_objs # Get existing relationships for this account - existing_relationships = { - rel.channel_group.name: rel - for rel in ChannelGroupM3UAccount.objects.filter( - m3u_account=account, - channel_group__name__in=groups.keys() - ).select_related('channel_group') - } + existing_relationships = _db_query_with_retry( + lambda: { + rel.channel_group.name: rel + for rel in ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + channel_group__name__in=groups.keys(), + ).select_related("channel_group") + }, + label=f"process_groups relationships for account {account.id}", + ) relations_to_create = [] relations_to_update = [] @@ -971,6 +974,27 @@ def collect_xc_streams(account_id, enabled_groups): ) return all_streams + +_STREAM_TOUCH_FIELDS = ("last_seen", "is_stale") +_STREAM_CHANGED_FIELDS = ( + "name", "url", "logo_url", "tvg_id", "custom_properties", "is_adult", + "last_seen", "updated_at", "is_stale", "stream_id", "stream_chno", + "channel_group_id", "is_catchup", "catchup_days", +) + + +def _bulk_update_stream_refresh_batches(changed_streams, touch_streams, *, batch_size): + """Unchanged streams only need last_seen/is_stale; changed rows get the full set.""" + if touch_streams: + Stream.objects.bulk_update( + touch_streams, list(_STREAM_TOUCH_FIELDS), batch_size=batch_size, + ) + if changed_streams: + Stream.objects.bulk_update( + changed_streams, list(_STREAM_CHANGED_FIELDS), batch_size=batch_size, + ) + + def process_xc_category_direct(account_id, batch, groups, hash_keys): from django.db import connections @@ -981,6 +1005,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): streams_to_create = [] streams_to_update = [] + streams_to_touch = [] stream_hashes = {} try: @@ -1111,10 +1136,6 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.catchup_days != stream_props["catchup_days"] ) - # Keep denormalized catch-up columns in memory (bulk_update reads them). - obj.is_catchup = stream_props["is_catchup"] - obj.catchup_days = stream_props["catchup_days"] - if changed: for key, value in stream_props.items(): setattr(obj, key, value) @@ -1123,11 +1144,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.is_stale = False streams_to_update.append(obj) else: - # Always update last_seen, even if nothing else changed obj.last_seen = timezone.now() obj.is_stale = False - # Don't update updated_at for unchanged streams - streams_to_update.append(obj) + streams_to_touch.append(obj) # Remove from existing_streams since we've processed it del existing_streams[stream_hash] @@ -1144,13 +1163,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): if streams_to_create: Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) - if streams_to_update: - # Simplified bulk update for better performance - Stream.objects.bulk_update( - streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days'], - batch_size=150 # Smaller batch size for XC processing - ) + _bulk_update_stream_refresh_batches( + streams_to_update, streams_to_touch, batch_size=150, + ) # Update last_seen for any remaining existing streams that weren't processed if len(existing_streams.keys()) > 0: @@ -1158,7 +1173,10 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): except Exception as e: logger.error(f"Bulk operation failed for XC streams: {str(e)}") - retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + retval = ( + f"Batch processed: {len(streams_to_create)} created, " + f"{len(streams_to_update) + len(streams_to_touch)} updated." + ) except Exception as e: logger.error(f"XC category processing error: {str(e)}") @@ -1168,7 +1186,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): connections.close_all() # Aggressive garbage collection - del streams_to_create, streams_to_update, stream_hashes, existing_streams + del streams_to_create, streams_to_update, streams_to_touch, stream_hashes, existing_streams gc.collect() return retval @@ -1203,6 +1221,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): streams_to_create = [] streams_to_update = [] + streams_to_touch = [] stream_hashes = {} name_max_length = Stream._meta.get_field('name').max_length @@ -1350,15 +1369,10 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.catchup_days != stream_props["catchup_days"] ) - # Keep denormalized catch-up columns in memory (bulk_update reads them). - obj.is_catchup = stream_props["is_catchup"] - obj.catchup_days = stream_props["catchup_days"] - - # Always update last_seen obj.last_seen = timezone.now() + obj.is_stale = False if changed: - # Only update fields that changed and set updated_at obj.name = stream_props["name"] obj.url = stream_props["url"] obj.logo_url = stream_props["logo_url"] @@ -1368,12 +1382,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.stream_id = stream_props["stream_id"] obj.stream_chno = stream_props["stream_chno"] obj.channel_group_id = stream_props["channel_group_id"] + obj.is_catchup = stream_props["is_catchup"] + obj.catchup_days = stream_props["catchup_days"] obj.updated_at = timezone.now() - - # Always mark as not stale since we saw it in this refresh - obj.is_stale = False - - streams_to_update.append(obj) + streams_to_update.append(obj) + else: + streams_to_touch.append(obj) else: # New stream stream_props["last_seen"] = timezone.now() @@ -1386,23 +1400,22 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): if streams_to_create: Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) - if streams_to_update: - # Update all streams in a single bulk operation - Stream.objects.bulk_update( - streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id', 'is_catchup', 'catchup_days'], - batch_size=200 - ) + _bulk_update_stream_refresh_batches( + streams_to_update, streams_to_touch, batch_size=200, + ) except Exception as e: logger.error(f"Bulk operation failed: {str(e)}") - retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + retval = ( + f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, " + f"{len(streams_to_update) + len(streams_to_touch)} updated." + ) # Clean up database connections for threading connections.close_all() # Free batch data structures (reference-counted deallocation) - del streams_to_create, streams_to_update, stream_hashes, existing_streams + del streams_to_create, streams_to_update, streams_to_touch, stream_hashes, existing_streams gc.collect() return retval @@ -3814,8 +3827,6 @@ def _refresh_single_m3u_account_impl(account_id): except OSError: pass - _release_task_db_connection() - return f"Dispatched jobs complete." diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 535e455e..270dfbe5 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -1901,9 +1901,12 @@ class TimeshiftScrubPreemptTests(TestCase): class RollupSelfHealDbTests(TestCase): - """Catch-up flag consistency after stream removal — the ChannelStream signal - handles bulk deletes (locked by a regression test) and the account-scoped - rollup self-heals stale flags on channels still linked to that account.""" + """Catch-up flag consistency after stream removal. + + The ChannelStream signal handles bulk deletes (locked by a regression test). + The account-scoped rollup self-heals stale flags on channels still linked + to that account. + """ @classmethod def setUpTestData(cls): @@ -1945,7 +1948,7 @@ class RollupSelfHealDbTests(TestCase): def test_rollup_self_heals_stale_channel_with_non_catchup_stream(self): # Channel still linked to the account but no active catch-up streams - # (e.g. catch-up flag cleared on import) — rollup must reset stale flags. + # (e.g. catch-up flag cleared on import). Rollup must reset stale flags. from apps.channels.models import Channel, ChannelStream, Stream from apps.m3u.tasks import rollup_channel_catchup_fields @@ -1988,8 +1991,7 @@ class RollupSelfHealDbTests(TestCase): def test_rollup_keeps_and_corrects_channels_with_catchup_streams(self): # The self-heal pass must not touch channels that legitimately have - # catch-up streams — and the account-scoped pass still corrects their - # values. + # catch-up streams. The account-scoped pass still corrects their values. from apps.channels.models import Channel from apps.m3u.tasks import rollup_channel_catchup_fields diff --git a/core/utils.py b/core/utils.py index 44504b6a..16d9ed85 100644 --- a/core/utils.py +++ b/core/utils.py @@ -565,13 +565,15 @@ def trim_c_allocator_heap(): return False -def cleanup_memory(log_usage=False, force_collection=True): +def cleanup_memory(log_usage=False, force_collection=True, trim_heap=False): """ Comprehensive memory cleanup function to reduce memory footprint Args: log_usage: Whether to log memory usage before and after cleanup force_collection: Whether to force garbage collection + trim_heap: Return freed C heap pages to the OS. Only use after DB + connections are closed (e.g. Celery task_postrun). """ logger.trace("Starting memory cleanup django memory cleanup") # Skip logging if log level is not set to debug or more verbose (like trace) @@ -606,6 +608,8 @@ def cleanup_memory(log_usage=False, force_collection=True): logger.debug(f"Memory after cleanup: {after_mem:.2f} MB (change: {after_mem-before_mem:.2f} MB)") except (ImportError, Exception): pass + if trim_heap: + trim_c_allocator_heap() logger.trace("Memory cleanup complete for django") @@ -618,8 +622,7 @@ def spawn_memory_trim(close_connections=False): so the pooled DB connection is released first. """ def _run(): - cleanup_memory(force_collection=True) - trim_c_allocator_heap() + cleanup_memory(force_collection=True, trim_heap=True) if close_connections: from django.db import close_old_connections diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 18be81eb..16a393dc 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -99,6 +99,7 @@ def cleanup_task_memory(**kwargs): memory_intensive_tasks = [ 'apps.m3u.tasks.refresh_single_m3u_account', 'apps.m3u.tasks.refresh_m3u_accounts', + 'apps.m3u.tasks.refresh_m3u_groups', 'apps.m3u.tasks.process_m3u_batch', 'apps.m3u.tasks.process_xc_category', 'apps.m3u.tasks.sync_auto_channels', @@ -121,7 +122,7 @@ def cleanup_task_memory(**kwargs): from core.utils import cleanup_memory # Use the comprehensive cleanup function - cleanup_memory(log_usage=True, force_collection=True) + cleanup_memory(log_usage=True, force_collection=True, trim_heap=True) # Log memory usage if psutil is installed try: From f01c6563c1937267345491074fb61794b3ca3735 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 11:52:00 -0500 Subject: [PATCH 36/56] refactor(tasks): Optimize M3U stream processing by pre-compiling filters for batch workers, reducing redundant regex evaluations. Update related functions to improve performance and memory management during account refreshes. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 118 ++++++++++++------- apps/m3u/tests/test_memory_cleanup.py | 24 +++- apps/m3u/tests/test_stream_filters.py | 156 ++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 43 deletions(-) create mode 100644 apps/m3u/tests/test_stream_filters.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce80b8e5..82058d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Performance - **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change. +- **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely. - **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. ### Fixed diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 7ef71dda..3818683a 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -975,6 +975,39 @@ def collect_xc_streams(account_id, enabled_groups): return all_streams +def _compile_m3u_stream_filters(filter_queryset): + """Compile account M3UFilter rows once per refresh for batch workers.""" + compiled = [] + for filter_obj in filter_queryset: + flags = ( + re.IGNORECASE + if (filter_obj.custom_properties or {}).get("case_sensitive", True) is False + else 0 + ) + compiled.append((re.compile(filter_obj.regex_pattern, flags), filter_obj)) + return compiled + + +def _stream_passes_m3u_filters(name, url, group_title, compiled_filters): + """Return False when the first matching filter excludes the stream.""" + for pattern, filter_obj in compiled_filters: + logger.trace("Checking filter pattern %s", pattern.pattern) + if filter_obj.filter_type == "url": + target = url + elif filter_obj.filter_type == "group": + target = group_title + else: + target = name + + if pattern.search(target or ""): + logger.debug( + "Stream %s - %s matches filter pattern %s", + name, url, filter_obj.regex_pattern, + ) + return not filter_obj.exclude + return True + + _STREAM_TOUCH_FIELDS = ("last_seen", "is_stale") _STREAM_CHANGED_FIELDS = ( "name", "url", "logo_url", "tvg_id", "custom_properties", "is_adult", @@ -1192,8 +1225,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): return retval -def process_m3u_batch_direct(account_id, batch, groups, hash_keys): - """Processes a batch of M3U streams using bulk operations with thread-safe DB connections.""" +def process_m3u_batch_direct(account_id, batch, groups, hash_keys, compiled_filters=None): + """Processes a batch of M3U streams using bulk operations with thread-safe DB connections. + + ``compiled_filters`` should be pre-built once per account refresh and shared + across batch workers. Pass an empty list when the account has no filters. + """ from django.db import connections # Ensure clean database connections for threading @@ -1201,23 +1238,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): account = M3UAccount.objects.get(id=account_id) - compiled_filters = [ - ( - re.compile( - f.regex_pattern, - ( - re.IGNORECASE - if (f.custom_properties or {}).get( - "case_sensitive", True - ) - == False - else 0 - ), - ), - f, - ) - for f in account.filters.order_by("order") - ] + if compiled_filters is None: + compiled_filters = _compile_m3u_stream_filters(account.filters.order_by("order")) streams_to_create = [] streams_to_update = [] @@ -1228,7 +1250,10 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}") if compiled_filters: - logger.debug(f"Using compiled filters: {[f[1].regex_pattern for f in compiled_filters]}") + logger.debug( + "Using compiled filters: %s", + [filter_obj.regex_pattern for _, filter_obj in compiled_filters], + ) for stream_info in batch: try: name, url = stream_info["name"], stream_info["url"] @@ -1249,25 +1274,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): group_title = get_case_insensitive_attr( stream_info["attributes"], "group-title", "Default Group" ) - logger.debug(f"Processing stream: {name} - {url} in group {group_title}") - include = True - for pattern, filter in compiled_filters: - logger.trace(f"Checking filter pattern {pattern}") - target = name - if filter.filter_type == "url": - target = url - elif filter.filter_type == "group": - target = group_title + logger.trace("Processing stream: %s - %s in group %s", name, url, group_title) - if pattern.search(target or ""): - logger.debug( - f"Stream {name} - {url} matches filter pattern {filter.regex_pattern}" - ) - include = not filter.exclude - break - - if not include: - logger.debug(f"Stream excluded by filter, skipping.") + if compiled_filters and not _stream_passes_m3u_filters( + name, url, group_title, compiled_filters, + ): + logger.debug("Stream excluded by filter, skipping.") continue # Filter out disabled groups for this account @@ -3322,7 +3334,15 @@ def _refresh_single_m3u_account_impl(account_id): ) account = _get_active_m3u_account(account_id) - filters = list(account.filters.all()) + compiled_stream_filters = _compile_m3u_stream_filters( + account.filters.order_by("order") + ) + if compiled_stream_filters: + logger.debug( + "Account %s has %s stream filter(s) for this refresh", + account_id, + len(compiled_stream_filters), + ) # Check if VOD is enabled for this account vod_enabled = ensure_custom_properties_dict(account.custom_properties).get( @@ -3499,7 +3519,14 @@ def _refresh_single_m3u_account_impl(account_id): with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit batch processing tasks using direct functions (now thread-safe) future_to_batch = { - executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + executor.submit( + process_m3u_batch_direct, + account_id, + batch, + existing_groups, + hash_keys, + compiled_stream_filters, + ): i for i, batch in enumerate(batches) } @@ -3612,7 +3639,14 @@ def _refresh_single_m3u_account_impl(account_id): with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit stream batch processing tasks (reuse standard M3U processing) future_to_batch = { - executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + executor.submit( + process_m3u_batch_direct, + account_id, + batch, + existing_groups, + hash_keys, + compiled_stream_filters, + ): i for i, batch in enumerate(batches) } @@ -3819,6 +3853,8 @@ def _refresh_single_m3u_account_impl(account_id): del filtered_groups if 'channel_group_relationships' in locals(): del channel_group_relationships + if 'compiled_stream_filters' in locals(): + del compiled_stream_filters # Remove cache file after processing (success or failure) cache_path = os.path.join(m3u_dir, f"{account_id}.json") diff --git a/apps/m3u/tests/test_memory_cleanup.py b/apps/m3u/tests/test_memory_cleanup.py index bfc964d0..b29ca947 100644 --- a/apps/m3u/tests/test_memory_cleanup.py +++ b/apps/m3u/tests/test_memory_cleanup.py @@ -30,7 +30,7 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase): mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") with patch("django.db.connections") as mock_connections: - process_m3u_batch_direct(1, [], {}, ["name", "url"]) + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) mock_connections.close_all.assert_called() @patch("apps.m3u.tasks.Stream") @@ -48,9 +48,29 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase): mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") with patch("gc.collect") as mock_gc, patch("django.db.connections"): - process_m3u_batch_direct(1, [], {}, ["name", "url"]) + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) mock_gc.assert_called() + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_precompiled_empty_filters_skip_db_lookup( + self, mock_account_cls, mock_stream_cls, + ): + """When filters are precompiled as empty, batch workers must not query filters.""" + from apps.m3u.tasks import process_m3u_batch_direct + + mock_account = MagicMock() + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + with patch("django.db.connections"): + process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[]) + + mock_account.filters.order_by.assert_not_called() + class LockReleaseTests(SimpleTestCase): """Verify task lock is released on all exit paths.""" diff --git a/apps/m3u/tests/test_stream_filters.py b/apps/m3u/tests/test_stream_filters.py new file mode 100644 index 00000000..aba1a262 --- /dev/null +++ b/apps/m3u/tests/test_stream_filters.py @@ -0,0 +1,156 @@ +"""Tests for M3U stream filter compilation and batch application.""" +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +from apps.m3u.tasks import ( + _compile_m3u_stream_filters, + _stream_passes_m3u_filters, + process_m3u_batch_direct, +) + + +class CompileM3UStreamFiltersTests(SimpleTestCase): + def test_compiles_case_insensitive_when_configured(self): + filter_obj = MagicMock() + filter_obj.regex_pattern = "news" + filter_obj.custom_properties = {"case_sensitive": False} + + compiled = _compile_m3u_stream_filters([filter_obj]) + + self.assertEqual(len(compiled), 1) + pattern, _ = compiled[0] + self.assertTrue(pattern.search("NEWS")) + + def test_compiles_case_sensitive_by_default(self): + filter_obj = MagicMock() + filter_obj.regex_pattern = "news" + filter_obj.custom_properties = {} + + compiled = _compile_m3u_stream_filters([filter_obj]) + + pattern, _ = compiled[0] + self.assertIsNone(pattern.search("NEWS")) + self.assertTrue(pattern.search("news")) + + +class StreamPassesM3UFiltersTests(SimpleTestCase): + def _compiled(self, *, filter_type="name", exclude=False, pattern="Adult"): + filter_obj = MagicMock() + filter_obj.filter_type = filter_type + filter_obj.exclude = exclude + filter_obj.regex_pattern = pattern + filter_obj.custom_properties = {} + return _compile_m3u_stream_filters([filter_obj]) + + def test_include_filter_passes_matching_stream(self): + compiled = self._compiled(exclude=False) + self.assertTrue( + _stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled) + ) + + def test_include_filter_passes_non_matching_stream(self): + """Non-matching streams still pass unless a matching exclude filter hits.""" + compiled = self._compiled(exclude=False, pattern="news") + self.assertTrue( + _stream_passes_m3u_filters("Sports", "http://x", "Sports", compiled) + ) + + def test_exclude_filter_rejects_matching_stream(self): + compiled = self._compiled(exclude=True, pattern="Adult") + self.assertFalse( + _stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled) + ) + + def test_url_filter_type_targets_url(self): + compiled = self._compiled(filter_type="url", exclude=True, pattern="blocked") + self.assertFalse( + _stream_passes_m3u_filters("OK", "http://blocked.example/live", "News", compiled) + ) + self.assertTrue( + _stream_passes_m3u_filters("blocked name", "http://ok.example/live", "News", compiled) + ) + + def test_group_filter_type_targets_group(self): + compiled = self._compiled(filter_type="group", exclude=True, pattern="Hidden") + self.assertFalse( + _stream_passes_m3u_filters("Channel", "http://x", "Hidden Group", compiled) + ) + + +class ProcessM3UBatchFilterTests(SimpleTestCase): + def _mock_stream_meta(self, mock_stream_cls, max_length=255): + mock_field = MagicMock() + mock_field.max_length = max_length + mock_stream_cls._meta.get_field.return_value = mock_field + + @patch("apps.m3u.tasks._bulk_update_stream_refresh_batches") + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_exclude_filter_skips_stream_import( + self, mock_account_cls, mock_stream_cls, mock_bulk_update, + ): + self._mock_stream_meta(mock_stream_cls) + mock_account = MagicMock() + mock_account.account_type = "STD" + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + filter_obj = MagicMock() + filter_obj.regex_pattern = "skip-me" + filter_obj.filter_type = "name" + filter_obj.exclude = True + filter_obj.custom_properties = {} + compiled = _compile_m3u_stream_filters([filter_obj]) + + batch = [{ + "name": "skip-me channel", + "url": "http://example/live", + "attributes": {"group-title": "News"}, + "vlc_opts": {}, + }] + + with patch("django.db.connections"): + result = process_m3u_batch_direct( + 1, batch, {"News": 1}, ["name", "url"], compiled_filters=compiled, + ) + + self.assertIn("0 created", result) + mock_stream_cls.objects.bulk_create.assert_not_called() + mock_bulk_update.assert_called_once_with([], [], batch_size=200) + + @patch("apps.m3u.tasks._bulk_update_stream_refresh_batches") + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_no_filters_imports_matching_stream( + self, mock_account_cls, mock_stream_cls, mock_bulk_update, + ): + self._mock_stream_meta(mock_stream_cls) + mock_account = MagicMock() + mock_account.account_type = "STD" + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + mock_stream_cls.objects.bulk_create.return_value = [] + + batch = [{ + "name": "News One", + "url": "http://example/live", + "attributes": {"group-title": "News"}, + "vlc_opts": {}, + }] + + with patch("django.db.connections"), patch( + "apps.m3u.tasks.transaction.atomic", + ): + result = process_m3u_batch_direct( + 1, batch, {"News": 1}, ["name", "url"], compiled_filters=[], + ) + + self.assertIn("1 created", result) + mock_stream_cls.objects.bulk_create.assert_called_once() From 6456d1179554b7a7aa05f979c1c15514f1b241bf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 12:48:02 -0500 Subject: [PATCH 37/56] refactor(tasks): Enhance M3U refresh process by introducing a line-by-line parsing method for on-disk files, improving memory efficiency. Update error handling to return None for failures, and streamline the fetching logic for better clarity and performance. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 112 +++++++++++++++++++++++++++++----------------- 2 files changed, 71 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82058d39..9991c83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change. - **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely. +- **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync. - **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. ### Fixed diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 3818683a..b06532d6 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -134,11 +134,23 @@ def _ensure_m3u_refresh_terminal_status(account_id): _EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2') +def _open_m3u_text_source(source_path): + """Open an on-disk M3U (or .m3u.gz) file for line-by-line parsing.""" + if source_path.endswith(".gz"): + return gzip.open(source_path, "rt", encoding="utf-8") + return open(source_path, "r", encoding="utf-8") + + def fetch_m3u_lines(account, use_cache=False): + """Fetch M3U source for parsing. + + On success returns ``(source, True)`` where *source* is either a filesystem + path (streamed during parse) or, for ZIP uploads only, an in-memory line + list. Failures return ``(None, False)``. + """ os.makedirs(m3u_dir, exist_ok=True) file_path = os.path.join(m3u_dir, f"{account.id}.m3u") - """Fetch M3U file lines efficiently.""" if account.server_url: if not use_cache or not os.path.exists(file_path): try: @@ -213,7 +225,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Only call raise_for_status if we have a success code (this should not raise now) response.raise_for_status() @@ -288,7 +300,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Validate the file by reading only the first portion from # disk — no need to load the entire file into memory just @@ -360,7 +372,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except UnicodeDecodeError: with open(temp_path, "rb") as vf: @@ -378,7 +390,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Validation passed — promote temp file to final path os.replace(temp_path, file_path) @@ -433,7 +445,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except requests.exceptions.RequestException as e: # Handle other request errors (connection, timeout, etc.) if "timeout" in str(e).lower(): @@ -454,7 +466,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False except Exception as e: # Handle any other unexpected errors error_msg = f"Unexpected error while fetching M3U file from URL: {account.server_url} - {str(e)}" @@ -469,7 +481,7 @@ def fetch_m3u_lines(account, use_cache=False): status="error", error=error_msg, ) - return [], False + return None, False # Check if the file exists and is not empty (fallback check - should not happen with new validation) if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: @@ -481,27 +493,14 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False # Return empty list and False for success + return None, False - try: - with open(file_path, "r", encoding="utf-8") as f: - return f.readlines(), True - except Exception as e: - error_msg = f"Error reading M3U file: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account.id, "downloading", 100, status="error", error=error_msg - ) - return [], False + return file_path, True elif account.file_path: try: if account.file_path.endswith(".gz"): - with gzip.open(account.file_path, "rt", encoding="utf-8") as f: - return f.readlines(), True + return account.file_path, True elif account.file_path.endswith(".zip"): with zipfile.ZipFile(account.file_path, "r") as zip_file: @@ -522,11 +521,10 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False + return None, False else: - with open(account.file_path, "r", encoding="utf-8") as f: - return f.readlines(), True + return account.file_path, True except (IOError, OSError, zipfile.BadZipFile, gzip.BadGzipFile) as e: error_msg = f"Error opening file {account.file_path}: {e}" @@ -537,7 +535,7 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update( account.id, "downloading", 100, status="error", error=error_msg ) - return [], False + return None, False # Neither server_url nor uploaded_file is available error_msg = "No M3U source available (missing URL and file)" @@ -546,7 +544,7 @@ def fetch_m3u_lines(account, use_cache=False): account.last_message = error_msg account.save(update_fields=["status", "last_message"]) send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) - return [], False + return None, False def get_case_insensitive_attr(attributes, key, default=""): @@ -1714,28 +1712,49 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta release_task_lock("refresh_m3u_account_groups", account_id) return error_msg, None else: - lines, success = fetch_m3u_lines(account, use_cache) + source, success = fetch_m3u_lines(account, use_cache) if not success: # If fetch failed, don't continue processing lock_renewer.stop() release_task_lock("refresh_m3u_account_groups", account_id) return f"Failed to fetch M3U data for account_id={account_id}.", None - # Log basic file structure for debugging - logger.debug(f"Processing {len(lines)} lines from M3U file") - valid_stream_count = 0 - for entry in iter_m3u_entries(lines): - valid_stream_count += 1 - group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") - if group_title_attr and group_title_attr not in groups: - logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") - groups[group_title_attr] = {} - extinf_data.append(entry) + if isinstance(source, str): + logger.debug(f"Streaming M3U parse from {source}") + with _open_m3u_text_source(source) as m3u_file: + entry_iter = iter_m3u_entries(m3u_file) + for entry in entry_iter: + valid_stream_count += 1 + group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") + if group_title_attr and group_title_attr not in groups: + logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") + groups[group_title_attr] = {} + extinf_data.append(entry) - if valid_stream_count % 1000 == 0: - logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}") + if valid_stream_count % 1000 == 0: + logger.debug( + f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}" + ) + else: + logger.debug(f"Processing {len(source)} in-memory M3U lines (zip upload)") + try: + for entry in iter_m3u_entries(source): + valid_stream_count += 1 + group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") + if group_title_attr and group_title_attr not in groups: + logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") + groups[group_title_attr] = {} + extinf_data.append(entry) + + if valid_stream_count % 1000 == 0: + logger.debug( + f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}" + ) + finally: + del source + gc.collect() logger.info(f"M3U parsing complete - Valid streams: {valid_stream_count}") @@ -3581,6 +3600,10 @@ def _refresh_single_m3u_account_impl(account_id): batches[batch_idx] = None logger.info(f"Thread-based processing completed for account {account_id}") + + # Parsed catalog is no longer needed; drop before stale cleanup / auto-sync. + del extinf_data, batches + gc.collect() else: # For XC accounts, get the groups with their custom properties containing xc_id logger.debug(f"Processing XC account with groups: {existing_groups}") @@ -3702,6 +3725,9 @@ def _refresh_single_m3u_account_impl(account_id): logger.info(f"XC thread-based processing completed for account {account_id}") + del batches + gc.collect() + # Ensure all database transactions are committed before cleanup logger.info( f"All thread processing completed, ensuring DB transactions are committed before cleanup" @@ -3856,6 +3882,8 @@ def _refresh_single_m3u_account_impl(account_id): if 'compiled_stream_filters' in locals(): del compiled_stream_filters + gc.collect() + # Remove cache file after processing (success or failure) cache_path = os.path.join(m3u_dir, f"{account_id}.json") try: From 6d17663f84b52a96d6277dc0c7639fd2c38bf646 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 13:33:26 -0500 Subject: [PATCH 38/56] refactor(tasks): Optimize XC stream collection by avoiding redundant work during catalog filtering. Introduce a shared URL prefix for stream URLs and enhance memory management by releasing per-group caches after processing. Update changelog to reflect these improvements. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 101 +++++++++++++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9991c83e..a20b9032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change. - **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely. - **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync. +- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration. - **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs. ### Fixed diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b06532d6..a5894892 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -906,6 +906,11 @@ def collect_xc_streams(account_id, enabled_groups): account.get_user_agent(), ) as xc_client: + stream_url_prefix = ( + f"{xc_client.server_url.rstrip('/')}/live/" + f"{xc_client.username}/{xc_client.password}/" + ) + # Fetch ALL live streams in a single API call (much more efficient) logger.info("Fetching ALL live streams from XC provider...") all_xc_streams = xc_client.get_all_live_streams() # Get all streams without category filter @@ -918,46 +923,49 @@ def collect_xc_streams(account_id, enabled_groups): # Filter streams based on enabled categories for stream in all_xc_streams: - # Fall back to a generated name if the provider returns null/empty - stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + category_id = str(stream.get("category_id", "")) + if category_id not in enabled_category_ids: + continue + + group_info = enabled_category_ids[category_id] + stream_name = stream.get("name") or ( + f"{account.name} - {stream.get('stream_id', 'Unknown')}" + ) if not stream.get("name"): logger.warning( - f"XC stream has null/empty name; using generated name '{stream_name}' " - f"(stream_id={stream.get('stream_id', 'unknown')})" + "XC stream has null/empty name; using generated name '%s' " + "(stream_id=%s)", + stream_name, stream.get("stream_id", "unknown"), ) - # Get the category_id for this stream - category_id = str(stream.get("category_id", "")) - - # Only include streams from enabled categories - if category_id in enabled_category_ids: - group_info = enabled_category_ids[category_id] - - # Convert XC stream to our standard format with all properties preserved - stream_data = { - "name": stream_name, - "url": xc_client.get_stream_url(stream["stream_id"]), - "attributes": { - "tvg-id": stream.get("epg_channel_id", ""), - "tvg-logo": stream.get("stream_icon", ""), - "group-title": group_info["name"], - # Preserve all XC stream properties as custom attributes - "stream_id": str(stream.get("stream_id", "")), - "num": stream.get("num"), - "category_id": category_id, - "stream_type": stream.get("stream_type", ""), - "added": stream.get("added", ""), - "is_adult": str(stream.get("is_adult", "0")), - "custom_sid": stream.get("custom_sid", ""), - # Include any other properties that might be present - **{k: str(v) for k, v in stream.items() if k not in [ - "name", "stream_id", "epg_channel_id", "stream_icon", - "category_id", "stream_type", "added", "is_adult", "custom_sid", "num" - ] and v is not None} - } - } - all_streams.append(stream_data) - filtered_count += 1 + stream_id = stream.get("stream_id") + attributes = { + "tvg-id": stream.get("epg_channel_id", ""), + "tvg-logo": stream.get("stream_icon", ""), + "group-title": group_info["name"], + "stream_id": str(stream.get("stream_id", "")), + "num": stream.get("num"), + "category_id": category_id, + "stream_type": stream.get("stream_type", ""), + "added": stream.get("added", ""), + "is_adult": str(stream.get("is_adult", "0")), + "custom_sid": stream.get("custom_sid", ""), + **{ + k: str(v) + for k, v in stream.items() + if k not in [ + "name", "stream_id", "epg_channel_id", "stream_icon", + "category_id", "stream_type", "added", "is_adult", + "custom_sid", "num", + ] and v is not None + }, + } + all_streams.append({ + "name": stream_name, + "url": f"{stream_url_prefix}{stream_id}.ts", + "attributes": attributes, + }) + filtered_count += 1 # Drop the full provider catalog before returning; only filtered rows are needed. del all_xc_streams @@ -2927,6 +2935,17 @@ def sync_auto_channels(account_id, scan_start_time=None): f"{pack_result['failed']} failed" ) + # Release per-group working sets before the next group iteration. + del ( + current_streams, + logo_cache_by_url, + epg_cache_by_tvg_id, + existing_channel_map, + existing_channels_by_id, + existing_channel_streams, + processed_stream_ids, + ) + # Cleanup mode read from account.custom_properties.orphan_channel_cleanup: # "always" (default; key absent) removes every orphan auto channel; # "preserve_customized" keeps those with a ChannelOverride row; @@ -2962,7 +2981,7 @@ def sync_auto_channels(account_id, scan_start_time=None): f"{channels_created} created, {channels_updated} updated, " f"{channels_deleted} deleted, {channels_failed} failed" ) - return { + result = { "status": "ok", "channels_created": channels_created, "channels_updated": channels_updated, @@ -2970,6 +2989,9 @@ def sync_auto_channels(account_id, scan_start_time=None): "channels_failed": channels_failed, "failed_stream_details": failed_stream_details, } + del failed_stream_details + gc.collect() + return result except Exception as e: logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}") @@ -3641,6 +3663,8 @@ def _refresh_single_m3u_account_impl(account_id): logger.info("Fetching all XC streams from provider and filtering by enabled categories...") all_xc_streams = collect_xc_streams(account_id, filtered_groups) + del channel_group_relationships, filtered_groups + if not all_xc_streams: logger.warning("No streams collected from XC groups") else: @@ -3848,6 +3872,9 @@ def _refresh_single_m3u_account_impl(account_id): message=account.last_message, ) + del auto_sync_result + gc.collect() + # Trigger VOD refresh if enabled and account is XtreamCodes type if vod_enabled and account.account_type == M3UAccount.Types.XC: logger.info(f"VOD is enabled for account {account_id}, triggering VOD refresh") From 269acc2dda5a75f872368fdacd24d3256617edad Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 27 Jun 2026 16:50:44 -0500 Subject: [PATCH 39/56] refactor(timeshift): Replace random session ID generation with secure secrets for timeshift sessions. Introduce helper functions for session management and enhance session validation to prevent foreign session reuse. Update tests to cover new session handling logic and ensure proper user ownership checks. --- apps/timeshift/tests/test_views.py | 68 ++++++++++++++++++++++++------ apps/timeshift/views.py | 56 ++++++++++++++++++------ 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 270dfbe5..1e45f53b 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -550,7 +550,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def _call(self, timestamp, provider_tz="Europe/Brussels"): request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts?session_id={TEST_SESSION_ID}") sentinel = MagicMock(status_code=200) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -596,7 +596,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def test_invalid_timestamp_rejected_before_upstream(self): request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -611,7 +611,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): def test_network_access_denied_returns_403(self): # Same network gate as other XC API endpoints (player_api, xmltv, etc.). request = self.factory.get(_proxy_url()) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=False) as gate, \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_stream_from_provider") as stream_mock: @@ -634,7 +634,7 @@ class TimeshiftProxyFailoverTests(TestCase): def _call(self, streams, provider_responses): request = self.factory.get(_proxy_url()) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -758,7 +758,7 @@ class _ProxyLoopTestMixin: if build_side_effect is not None else {"return_value": ["http://example.test/x.ts"]} ) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -1359,7 +1359,7 @@ class TimeshiftTakeoverTests(TestCase): # the user's own seek gets denied. call_order = [] request = RequestFactory().get(_proxy_url()) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -1413,7 +1413,9 @@ class TimeshiftSessionReuseTests(TestCase): return_value=profile), \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)) as reserve_mock: - acquired = views._acquire_idle_pool_session(self.redis, self.SESSION) + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=5, + ) self.assertIsNotNone(acquired) descriptor, got_profile = acquired self.assertEqual(descriptor["stream_id"], "111") @@ -1425,11 +1427,53 @@ class TimeshiftSessionReuseTests(TestCase): _seed_pool_session(self.redis, session_id=self.SESSION, busy="1") with patch.object(views.M3UAccountProfile.objects, "get") as prof_mock, \ patch.object(views, "reserve_profile_slot") as reserve_mock: - acquired = views._acquire_idle_pool_session(self.redis, self.SESSION) + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=5, + ) self.assertIsNone(acquired) prof_mock.assert_not_called() reserve_mock.assert_not_called() + def test_acquire_rejects_foreign_user(self): + self._make_idle_entry() + profile = MagicMock(id=31) + with patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "reserve_profile_slot", + return_value=(True, 1, None)) as reserve_mock: + acquired = views._acquire_idle_pool_session( + self.redis, self.SESSION, user_id=99, + ) + self.assertIsNone(acquired) + reserve_mock.assert_not_called() + + def test_foreign_session_id_redirects_instead_of_reusing_pool(self): + victim_session = "timeshift_victim_session" + _seed_pool_session(self.redis, session_id=victim_session, user_id=99) + request = self.factory.get(_proxy_url(victim_session)) + attacker = MagicMock(id=5) + with patch.object(views, "_authenticate_user", return_value=attacker), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", + return_value=[_make_catchup_stream()]), \ + patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "parse_catchup_timestamp", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "_acquire_idle_pool_session") as acquire_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy( + request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + ) + self.assertEqual(response.status_code, 301) + self.assertIn("session_id=timeshift_", response["Location"]) + self.assertNotIn(victim_session, response["Location"]) + acquire_mock.assert_not_called() + attempt_mock.assert_not_called() + def test_find_matching_idle_session_requires_ip_and_user_agent(self): _seed_pool_session( self.redis, session_id="timeshift_other", @@ -1729,7 +1773,7 @@ class TimeshiftScrubPreemptTests(TestCase): HTTP_RANGE="bytes=1000-", ) streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -1768,7 +1812,7 @@ class TimeshiftScrubPreemptTests(TestCase): HTTP_RANGE="bytes=0-", ) streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -1800,7 +1844,7 @@ class TimeshiftScrubPreemptTests(TestCase): HTTP_RANGE="bytes=2527702896-", ) streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ @@ -1866,7 +1910,7 @@ class TimeshiftScrubPreemptTests(TestCase): streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] profile = MagicMock(id=31) ok = MagicMock(status_code=206) - with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 113cd460..0ba3a5a7 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -3,7 +3,7 @@ import hmac import itertools import logging -import random +import secrets import time from urllib.parse import urlencode @@ -104,19 +104,20 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) session_id = request.GET.get("session_id") if not session_id: - session_id = f"timeshift_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" - query_params = {k: request.GET.getlist(k) for k in request.GET} - query_params["session_id"] = [session_id] - redirect_url = f"{request.path}?{urlencode(query_params, doseq=True)}" - logger.debug("Timeshift session redirect: %s -> %s", request.path, session_id) - return HttpResponse(status=301, headers={"Location": redirect_url}) + logger.debug("Timeshift session redirect: %s (new session)", request.path) + return _redirect_with_new_session(request) + + session_entry = _get_pool_entry(redis_client, session_id) + if session_entry and not _pool_entry_owned_by_user(session_entry, user.id): + logger.info( + "Timeshift: rejecting foreign session_id for user %s", user.id, + ) + return _redirect_with_new_session(request) # Stable client identity for stats, stop keys, and the provider pool. effective_session_id = session_id client_id = session_id - session_entry = _get_pool_entry(redis_client, session_id) - # Reuse an idle pool owned by this session, or fingerprint-match a prior # idle session from the same client (VOD-style) before opening upstream. if not session_entry: @@ -178,11 +179,12 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) acquired = _wait_for_idle_pool_session( redis_client, effective_session_id, + user_id=user.id, wait_seconds=_POOL_PREEMPT_WAIT_SECONDS, ) else: acquired = _acquire_idle_pool_session( - redis_client, effective_session_id, + redis_client, effective_session_id, user_id=user.id, ) if acquired is not None: @@ -466,6 +468,28 @@ def _score_pool_fingerprint(entry, client_ip, client_user_agent): return score +def _mint_timeshift_session_id(): + return f"timeshift_{secrets.token_urlsafe(16)}" + + +def _redirect_with_new_session(request): + session_id = _mint_timeshift_session_id() + query_params = {k: request.GET.getlist(k) for k in request.GET} + query_params["session_id"] = [session_id] + redirect_url = f"{request.path}?{urlencode(query_params, doseq=True)}" + return HttpResponse(status=301, headers={"Location": redirect_url}) + + +def _pool_entry_owned_by_user(entry, user_id): + """True when *entry* is unclaimed or owned by *user_id*.""" + if not entry or not entry.get("profile_id"): + return True + owner = entry.get("user_id") + if owner is None or owner == "": + return False + return str(owner) == str(user_id) + + def _find_matching_idle_session( redis_client, *, media_id, user_id, client_ip, client_user_agent, ): @@ -583,7 +607,7 @@ def _pool_lock(redis_client, session_id): ) -def _acquire_idle_pool_session(redis_client, session_id): +def _acquire_idle_pool_session(redis_client, session_id, *, user_id=None): """Re-reserve an idle session's profile slot and mark it busy.""" if redis_client is None or not session_id: return None @@ -593,6 +617,8 @@ def _acquire_idle_pool_session(redis_client, session_id): data = redis_client.hgetall(key) if not data or not data.get("profile_id"): return None + if user_id is not None and not _pool_entry_owned_by_user(data, user_id): + return None if data.get("busy") == "1": return None try: @@ -614,12 +640,16 @@ def _acquire_idle_pool_session(redis_client, session_id): return None -def _wait_for_idle_pool_session(redis_client, session_id, wait_seconds=_POOL_WAIT_SECONDS): +def _wait_for_idle_pool_session( + redis_client, session_id, *, user_id=None, wait_seconds=_POOL_WAIT_SECONDS, +): if redis_client is None or not session_id: return None deadline = time.time() + wait_seconds while True: - acquired = _acquire_idle_pool_session(redis_client, session_id) + acquired = _acquire_idle_pool_session( + redis_client, session_id, user_id=user_id, + ) if acquired is not None: return acquired if not _get_pool_entry(redis_client, session_id): From 40ec6b6339915a5ad4487beea5d4ff1497170984 Mon Sep 17 00:00:00 2001 From: None <190783071+CodeBormen@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:45:42 -0500 Subject: [PATCH 40/56] fix(channels): align the auto-sync rename preview and live rename on the regex module The Find and Replace preview did not correctly reflect the rename the sync performs, and the rename engine differed from the preview engine. - The preview rendered the literal $1 instead of the substituted capture group, because the replacement was passed straight into the regex engine, which honors \1, not the JS-style $1 the field accepts. - The preview compiled patterns with the regex module while the live rename used stdlib re, so patterns valid in regex but not re (for example ^*) previewed a transform the sync silently skipped. - A rename that expanded a name past the Channel.name column length aborted the whole bulk_create sync, while the preview showed the full name. - Convert JS-style $1 backreferences to \1 via a shared helper used by both the preview and the live rename. - Switch the live rename from re.sub to regex.sub, matching the preview engine and the sync's own include/exclude filters, with a timeout to bound catastrophic backtracking on user patterns. - Cap the rename result at the Channel.name column length in both paths, so an over-length result cannot abort the sync. - Add unit, integration, and differential parity tests covering the above. --- CHANGELOG.md | 1 + apps/channels/api_views.py | 15 +- apps/m3u/tasks.py | 30 ++- apps/m3u/tests/test_rename_preview_parity.py | 212 +++++++++++++++++++ apps/m3u/tests/test_sync_correctness.py | 122 +++++++++++ apps/m3u/utils.py | 13 ++ 6 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 apps/m3u/tests/test_rename_preview_parity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a20b9032..81a4248d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. +- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) ## [0.27.1] - 2026-06-25 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index fd8a4758..3f28903c 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -27,6 +27,7 @@ from apps.accounts.permissions import ( from core.models import UserAgent, CoreSettings from core.utils import RedisClient, safe_upload_path +from apps.m3u.utils import convert_js_numbered_backreferences from .models import ( Stream, @@ -368,6 +369,17 @@ class StreamViewSet(viewsets.ModelViewSet): except re.error as e: exclude_error = str(e) + # The replace field accepts JS-style $1 backreferences, but the regex + # engine honors \1. Convert once so the preview's "after" matches the + # name the live rename produces (apps/m3u/tasks.py sync_auto_channels + # applies the same conversion on the same engine). + replace_repl = convert_js_numbered_backreferences(replace_pat) + + # The live rename caps the result at Channel.name's column length + # before bulk_create; mirror that cap so the preview never shows a + # name the sync would truncate. + name_max_len = Channel._meta.get_field("name").max_length + # Capped at SCAN_CAP to bound memory on huge groups; the # separate COUNT lets the client surface scan_limit_hit when # the preview covers only a sample. @@ -390,11 +402,12 @@ class StreamViewSet(viewsets.ModelViewSet): total_scanned += 1 if find_re is not None: try: - new_name = find_re.sub(replace_pat, name, timeout=REGEX_TIMEOUT) + new_name = find_re.sub(replace_repl, name, timeout=REGEX_TIMEOUT) except (TimeoutError, re.error) as e: find_error = find_error or f"Pattern timed out: {e}" find_re = None continue + new_name = new_name[:name_max_len] if new_name != name: find_match_count += 1 if len(find_matches) < limit: diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index a5894892..4b570d93 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -26,7 +26,7 @@ from core.utils import ( from core.models import CoreSettings, UserAgent from core.xtream_codes import Client as XCClient from core.utils import send_websocket_update -from .utils import normalize_stream_url +from .utils import convert_js_numbered_backreferences, normalize_stream_url logger = logging.getLogger(__name__) @@ -2019,6 +2019,11 @@ def sync_auto_channels(account_id, scan_start_time=None): from apps.epg.models import EPGData from django.utils import timezone + channel_name_max_len = Channel._meta.get_field("name").max_length + # Per-call cap on the rename substitution; bounds catastrophic + # backtracking on a user-supplied pattern so it cannot hang the worker. + rename_regex_timeout = 0.1 + try: account = M3UAccount.objects.get(id=account_id) logger.info(f"Starting auto channel sync for M3U account {account.name}") @@ -2572,17 +2577,30 @@ def sync_auto_channels(account_id, scan_start_time=None): else "" ) try: - # Convert $1, $2, etc. to \1, \2, etc. for consistency with M3U profiles - safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace) - new_name = re.sub( - name_regex_pattern, safe_replace_pattern, original_name + # Use the regex module (not stdlib re) so rename + # patterns match the JS-style semantics the UI + # authors and the preview uses; the timeout bounds + # catastrophic backtracking. + safe_replace_pattern = convert_js_numbered_backreferences(replace) + new_name = regex.sub( + name_regex_pattern, + safe_replace_pattern, + original_name, + timeout=rename_regex_timeout, ) - except re.error as e: + except (regex.error, TimeoutError) as e: logger.warning( f"Regex error for group '{channel_group.name}': {e}. Using original name." ) new_name = original_name + # Channel.name is bounded by the column length; a rename + # that expands past it would otherwise fail the whole + # bulk_create and abort the sync. Cap it so one overlong + # result cannot break the batch, and so the preview (which + # applies the same cap) stays faithful. + new_name = new_name[:channel_name_max_len] + # Check if we already have a channel for this stream existing_channel = existing_channel_map.get(stream.id) diff --git a/apps/m3u/tests/test_rename_preview_parity.py b/apps/m3u/tests/test_rename_preview_parity.py new file mode 100644 index 00000000..4650c50b --- /dev/null +++ b/apps/m3u/tests/test_rename_preview_parity.py @@ -0,0 +1,212 @@ +""" +Differential parity tests: the auto-sync rename preview must predict the exact +name the live rename produces, across a broad matrix of regex strategies. + +Both the preview and the live rename compile with the third-party `regex` +module (for its JS-aligned syntax and per-call timeout). They can only be +trusted together if, for every find/replace a user might author, the preview's +predicted `after` equals the channel name the sync actually writes. + +Each case is run end-to-end: real streams, the real sync_auto_channels rename, +and the real regex-preview endpoint, compared per original stream name. +""" +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, ChannelGroup, ChannelStream, Stream +from apps.m3u.models import M3UAccount +from apps.m3u.tasks import sync_auto_channels + + +# Diverse names: distinct word-prefixes (collision-resistant), with quality +# tags, brackets, pipes, ampersands, extra whitespace, underscores, dots, CJK, +# and emoji, to exercise anchors, classes, boundaries, and Unicode handling. +NAMES = [ + "Alpha Channel 11", + "Bravo Sports HD", + "Charlie News FHD", + "Delta Movie (2024)", + "Echo [UK] 4K", + "Foxtrot spaced", + "Golf|Pipe|Name", + "Hotel & Inn ", + "India 日本語 77", + "Juliet 📺 88", + "Kilo_under_9", + "Lima.dot.name", +] + +# (find, replace) pairs spanning common user strategies and edge cases. +STRATEGIES = [ + # --- capture groups --- + (r"(.+) Channel (\d+)", r"$1 #$2"), + (r"(\w+) (\w+)", r"$2 $1"), + (r"(.+)", r"$1 - $1"), + (r"(.+)", r"[$1]"), + (r"(.+) (\d+)$", r"$2 $1"), + # --- strip / delete --- + (r" (HD|FHD|4K|SD)\b", r""), + (r"\s+", r" "), + (r"[\[\(].*?[\]\)]", r""), + (r"\d+", r""), + (r"[_.]", r" "), + # --- anchors / inserts --- + (r"^", r"NEW "), + (r"$", r" LIVE"), + (r"^(\w+)", r"<$1>"), + # --- char classes / Unicode (divergence hunters) --- + (r"\w+", r"W"), + (r"\b\w", r"_"), + (r"[A-Z]", r"*"), + (r"\s", r"_"), + (r"[^\x00-\x7F]+", r"?"), + # --- non-capturing / lookaround / in-pattern backref --- + (r"(?:Channel|Movie|News) ", r""), + (r"(\w)\1", r"$1"), + (r"\w+(?= )", r"X"), + (r"(?<=\d)\d", r"#"), + # --- literal $ and odd replacements --- + (r" ", r" $ "), + (r"o", r"0"), + # --- invalid group references (rejected by both engines) --- + (r"(.+)", r"$2"), + (r"(.+)", r"$10"), + # --- rename that expands past Channel.name's column length --- + (r"(.+)", r"$1" * 40), + # --- regex-module syntax: quantified anchor, JS-style and duplicate + # named groups (these transform; both paths use the regex module) --- + (r"^*", r"$"), + (r"(?\d+)", r"S$1"), + (r"(?Px)(?Py)", r"z"), +] + + +class RenamePreviewParityTests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + from rest_framework.test import APIClient + from apps.accounts.models import User + + admin = User.objects.create_superuser( + username="admin_rename_parity", password="pw", user_level=10 + ) + cls.client_api = APIClient() + cls.client_api.force_authenticate(user=admin) + cls.account = M3UAccount.objects.create( + name="Rename Parity Provider", + server_url="http://example.com/test.m3u", + ) + + def _sync(self): + return sync_auto_channels( + self.account.id, + scan_start_time=( + timezone.now() - timezone.timedelta(minutes=1) + ).isoformat(), + ) + + def _run_case(self, group_name, find, replace): + """Returns a list of human-readable mismatch strings (empty == parity).""" + group = ChannelGroup.objects.create(name=group_name) + from apps.channels.models import ChannelGroupM3UAccount + + ChannelGroupM3UAccount.objects.create( + m3u_account=self.account, + channel_group=group, + enabled=True, + auto_channel_sync=True, + auto_sync_channel_start=1000, + custom_properties={ + "name_regex_pattern": find, + "name_replace_pattern": replace, + }, + ) + for i, name in enumerate(NAMES): + Stream.objects.create( + name=name, + url=f"http://example.com/{group_name}_{i}.m3u8", + m3u_account=self.account, + channel_group=group, + tvg_id=f"{group_name}-{i}", + last_seen=timezone.now(), + ) + + # --- live rename via sync --- + result = self._sync() + if result.get("status") != "ok": + return [f"[{find!r} -> {replace!r}] sync status={result.get('status')}"] + + channels = Channel.objects.filter( + auto_created_by=self.account, channel_group=group + ) + cs_rows = ChannelStream.objects.filter( + channel__in=channels + ).select_related("channel", "stream") + sync_map = {row.stream.name: row.channel.name for row in cs_rows} + + # --- preview endpoint --- + response = self.client_api.get( + "/api/channels/streams/regex-preview/", + { + "channel_group": group_name, + "find": find, + "replace": replace, + "limit": 50, + }, + ) + if response.status_code != 200: + return [f"[{find!r} -> {replace!r}] preview HTTP {response.status_code}"] + data = response.data + # When the preview reports find_error it predicts no rename at all. + preview_map = {name: name for name in NAMES} + if "find_error" not in data: + for m in data.get("find_matches", []): + preview_map[m["before"]] = m["after"] + + mismatches = [] + for name in NAMES: + sync_after = sync_map.get(name) + if sync_after is None: + mismatches.append( + f"[{find!r} -> {replace!r}] {name!r}: no channel created" + ) + continue + preview_after = preview_map[name] + if preview_after != sync_after: + mismatches.append( + f"[{find!r} -> {replace!r}] {name!r}: " + f"preview={preview_after!r} sync={sync_after!r} " + f"(find_error={data.get('find_error')!r})" + ) + return mismatches + + def test_preview_predicts_rename_across_strategies(self): + all_mismatches = [] + for idx, (find, replace) in enumerate(STRATEGIES): + all_mismatches.extend( + self._run_case(f"ParityG{idx}", find, replace) + ) + self.assertEqual( + all_mismatches, + [], + "Preview diverged from the live rename:\n" + + "\n".join(all_mismatches), + ) + + def test_overlong_rename_is_bounded_not_aborting_sync(self): + # A rename that expands past Channel.name's column length must not + # abort the bulk_create sync. Both sync and preview cap at the column + # length so the channel is created (truncated) and the preview shows + # the same bounded name. + max_len = Channel._meta.get_field("name").max_length + mismatches = self._run_case("OverlongG", r"(.+)", "$1" * 40) + self.assertEqual(mismatches, [], "\n".join(mismatches)) + + group = ChannelGroup.objects.get(name="OverlongG") + channels = Channel.objects.filter( + auto_created_by=self.account, channel_group=group + ) + self.assertEqual(channels.count(), len(NAMES)) + self.assertTrue(all(len(c.name) <= max_len for c in channels)) + self.assertTrue(any(len(c.name) == max_len for c in channels)) diff --git a/apps/m3u/tests/test_sync_correctness.py b/apps/m3u/tests/test_sync_correctness.py index 8ae09fa8..75bc5d03 100644 --- a/apps/m3u/tests/test_sync_correctness.py +++ b/apps/m3u/tests/test_sync_correctness.py @@ -763,6 +763,128 @@ class RegexPreviewTests(TestCase): self.assertEqual(response.data["total_scanned"], 3) self.assertFalse(response.data["scan_limit_hit"]) + def test_find_replace_applies_numbered_capture_group(self): + # The replace field accepts JS-style $1 backreferences, but the regex + # engine expects \1. Without the conversion the preview echoes the + # literal "$1", so the previewed "after" disagrees with the name the + # live rename produces. + account = self._make_account() + group = _make_group(name="Sports") + Stream.objects.create( + name="High Limit Racing at Eagle @ Jun 9 7:00 PM", + url="http://example.com/hlr.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + ) + client = self._client() + + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Sports", "find": r"(.+) @.*", "replace": "$1"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["find_match_count"], 1) + after = response.data["find_matches"][0]["after"] + self.assertEqual(after, "High Limit Racing at Eagle") + self.assertNotIn("$1", after) + + def test_preview_after_matches_live_sync_rename(self): + # Guards the defect class: the preview and the live rename are + # separate code paths that must convert the replacement identically, + # so the preview can never promise an output the sync would not yield. + name = "High Limit Racing at Eagle @ Jun 9 7:00 PM" + account = self._make_account() + group = _make_group(name="Racing") + _attach_group_to_account( + account, + group, + custom_properties={ + "name_regex_pattern": r"(.+) @.*", + "name_replace_pattern": "$1", + }, + ) + _make_stream(account, group, name=name, tvg_id="hlr") + + result = _sync(account) + self.assertEqual(result.get("status"), "ok") + channel = Channel.objects.get(auto_created=True, auto_created_by=account) + live_name = channel.name + + client = self._client() + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Racing", "find": r"(.+) @.*", "replace": "$1"}, + ) + + self.assertEqual(response.status_code, 200) + preview_after = response.data["find_matches"][0]["after"] + self.assertEqual(preview_after, live_name) + self.assertEqual(preview_after, "High Limit Racing at Eagle") + + def test_regex_engine_pattern_transforms_in_preview(self): + # Both the preview and the live rename use the regex module, which is + # more permissive than stdlib re and matches the JS-style syntax the UI + # authors. A quantified anchor like "^*" (which stdlib re rejects) + # compiles and transforms rather than reporting an error. + account = self._make_account() + group = _make_group(name="Sports") + Stream.objects.create( + name="Doc95", + url="http://example.com/doc95.m3u8", + m3u_account=account, + channel_group=group, + last_seen=timezone.now(), + ) + client = self._client() + + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Sports", "find": "^*", "replace": "$"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("find_error", response.data) + self.assertEqual(response.data["find_match_count"], 1) + # ^* matches the empty string at every position, so the literal $ + # replacement is inserted between characters. + self.assertEqual( + response.data["find_matches"][0]["after"], "$D$o$c$9$5$" + ) + + def test_preview_and_sync_agree_on_regex_only_pattern(self): + # Parity guard for the engine alignment: a pattern valid in regex but + # not stdlib re must transform identically in the sync and the preview, + # rather than diverging (the sync no longer silently keeps the + # original name for these patterns). + name = "Doc95" + account = self._make_account() + group = _make_group(name="Docs") + _attach_group_to_account( + account, + group, + custom_properties={ + "name_regex_pattern": "^*", + "name_replace_pattern": "$", + }, + ) + _make_stream(account, group, name=name, tvg_id="doc95") + + result = _sync(account) + self.assertEqual(result.get("status"), "ok") + channel = Channel.objects.get(auto_created=True, auto_created_by=account) + live_name = channel.name + self.assertNotEqual(live_name, name) + + client = self._client() + response = client.get( + "/api/channels/streams/regex-preview/", + {"channel_group": "Docs", "find": "^*", "replace": "$"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["find_matches"][0]["after"], live_name) + def test_filter_returns_matched_names_with_count(self): account = self._make_account() group = _make_group(name="Sports") diff --git a/apps/m3u/utils.py b/apps/m3u/utils.py index 598ef713..dbf8d91a 100644 --- a/apps/m3u/utils.py +++ b/apps/m3u/utils.py @@ -1,4 +1,5 @@ # apps/m3u/utils.py +import regex import threading import logging from django.db import models @@ -9,6 +10,18 @@ active_streams_map = {} logger = logging.getLogger(__name__) +def convert_js_numbered_backreferences(replacement): + """Translate JS-style ``$1``/``$2`` backreferences to Python ``\\1``/``\\2``. + + Auto-sync replace patterns are authored in JS regex syntax, but Python's + regex engines honor backslash backreferences, not ``$1``. The live rename + and the UI preview must convert identically, so both call this single + helper and cannot drift apart (otherwise the preview promises an output + the sync would never produce). + """ + return regex.sub(r"\$(\d+)", r"\\\1", replacement) + + def normalize_stream_url(url): """ Normalize stream URLs for compatibility with FFmpeg. From fc4ced904300288c65a2e4c398f86d0ccd2ddcf3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 28 Jun 2026 09:54:09 -0500 Subject: [PATCH 41/56] feat(m3u): Add stream count summary and parsing result handling to M3U refresh process. Introduce helper functions for better readability and maintainability. Update frontend notification to display detailed stream processing outcomes, including counts for created, updated, stale, and removed streams. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 90 ++++++++++++------- .../src/components/M3URefreshNotification.jsx | 36 ++++++-- .../__tests__/M3URefreshNotification.test.jsx | 33 +++++++ 4 files changed, 122 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20b9032..28af285d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. ## [0.27.1] - 2026-06-25 diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index a5894892..5d9172c7 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1034,6 +1034,26 @@ def _bulk_update_stream_refresh_batches(changed_streams, touch_streams, *, batch ) +def _batch_stream_count_message(created, updated, unchanged): + """Human-readable batch summary; unchanged = last_seen touch only.""" + return ( + f"{created} created, {updated} updated, {unchanged} unchanged." + ) + + +def _parse_batch_stream_counts(result): + """Extract (created, updated, unchanged) from a batch processing result string.""" + if not isinstance(result, str): + return 0, 0, 0 + try: + created = int(re.search(r"(\d+) created", result).group(1)) + updated = int(re.search(r"(\d+) updated", result).group(1)) + unchanged = int(re.search(r"(\d+) unchanged", result).group(1)) + return created, updated, unchanged + except (AttributeError, ValueError): + return 0, 0, 0 + + def process_xc_category_direct(account_id, batch, groups, hash_keys): from django.db import connections @@ -1213,8 +1233,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): logger.error(f"Bulk operation failed for XC streams: {str(e)}") retval = ( - f"Batch processed: {len(streams_to_create)} created, " - f"{len(streams_to_update) + len(streams_to_touch)} updated." + "Batch processed: " + + _batch_stream_count_message( + len(streams_to_create), + len(streams_to_update), + len(streams_to_touch), + ) ) except Exception as e: @@ -1425,8 +1449,12 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys, compiled_filt logger.error(f"Bulk operation failed: {str(e)}") retval = ( - f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, " - f"{len(streams_to_update) + len(streams_to_touch)} updated." + f"M3U account: {account_id}, Batch processed: " + + _batch_stream_count_message( + len(streams_to_create), + len(streams_to_update), + len(streams_to_touch), + ) ) # Clean up database connections for threading @@ -3359,6 +3387,8 @@ def _refresh_single_m3u_account_impl(account_id): start_time = time.time() # For tracking elapsed time as float streams_created = 0 streams_updated = 0 + streams_unchanged = 0 + streams_stale = 0 streams_deleted = 0 try: @@ -3540,6 +3570,7 @@ def _refresh_single_m3u_account_impl(account_id): # Initialize stream counters streams_created = 0 streams_updated = 0 + streams_unchanged = 0 if account.account_type == M3UAccount.Types.STADNARD: logger.debug( @@ -3582,17 +3613,13 @@ def _refresh_single_m3u_account_impl(account_id): completed_batches += 1 # Extract stream counts from result - if isinstance(result, str): - try: - created_match = re.search(r"(\d+) created", result) - updated_match = re.search(r"(\d+) updated", result) - if created_match and updated_match: - created_count = int(created_match.group(1)) - updated_count = int(updated_match.group(1)) - streams_created += created_count - streams_updated += updated_count - except (AttributeError, ValueError): - pass + created_count, updated_count, unchanged_count = ( + _parse_batch_stream_counts(result) + ) + if created_count or updated_count or unchanged_count: + streams_created += created_count + streams_updated += updated_count + streams_unchanged += unchanged_count # Send progress update progress = int((completed_batches / total_batches) * 100) @@ -3610,7 +3637,7 @@ def _refresh_single_m3u_account_impl(account_id): progress, elapsed_time=current_elapsed, time_remaining=time_remaining, - streams_processed=streams_created + streams_updated, + streams_processed=streams_created + streams_updated + streams_unchanged, ) logger.debug(f"Thread batch {completed_batches}/{total_batches} completed") @@ -3708,17 +3735,13 @@ def _refresh_single_m3u_account_impl(account_id): completed_batches += 1 # Extract stream counts from result - if isinstance(result, str): - try: - created_match = re.search(r"(\d+) created", result) - updated_match = re.search(r"(\d+) updated", result) - if created_match and updated_match: - created_count = int(created_match.group(1)) - updated_count = int(updated_match.group(1)) - streams_created += created_count - streams_updated += updated_count - except (AttributeError, ValueError): - pass + created_count, updated_count, unchanged_count = ( + _parse_batch_stream_counts(result) + ) + if created_count or updated_count or unchanged_count: + streams_created += created_count + streams_updated += updated_count + streams_unchanged += unchanged_count # Send progress update progress = int((completed_batches / total_batches) * 100) @@ -3736,7 +3759,7 @@ def _refresh_single_m3u_account_impl(account_id): progress, elapsed_time=current_elapsed, time_remaining=time_remaining, - streams_processed=streams_created + streams_updated, + streams_processed=streams_created + streams_updated + streams_unchanged, ) logger.debug(f"XC thread batch {completed_batches}/{total_batches} completed") @@ -3762,11 +3785,11 @@ def _refresh_single_m3u_account_impl(account_id): ).exists() # This will never find anything but ensures DB sync # Mark streams that weren't seen in this refresh as stale (pending deletion) - stale_stream_count = Stream.objects.filter( + streams_stale = Stream.objects.filter( m3u_account=account, last_seen__lt=refresh_start_timestamp ).update(is_stale=True) - logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}") + logger.info(f"Marked {streams_stale} streams as stale for account {account_id}") # Mark group relationships that weren't seen in this refresh as stale (pending deletion) stale_group_count = ChannelGroupM3UAccount.objects.filter( @@ -3826,13 +3849,14 @@ def _refresh_single_m3u_account_impl(account_id): elapsed_time = time.time() - start_time # Calculate total streams processed - streams_processed = streams_created + streams_updated + streams_processed = streams_created + streams_updated + streams_unchanged # Set status to success and update timestamp BEFORE sending the final update account.status = M3UAccount.Status.SUCCESS account.last_message = ( f"Processing completed in {elapsed_time:.1f} seconds. " - f"Streams: {streams_created} created, {streams_updated} updated, {streams_deleted} removed. " + f"Streams: {streams_created} created, {streams_updated} updated, " + f"{streams_stale} marked stale, {streams_deleted} removed. " f"Total processed: {streams_processed}.{auto_sync_message}" ) account.updated_at = timezone.now() @@ -3845,6 +3869,7 @@ def _refresh_single_m3u_account_impl(account_id): elapsed_time=round(elapsed_time, 2), streams_created=streams_created, streams_updated=streams_updated, + streams_stale=streams_stale, streams_deleted=streams_deleted, total_processed=streams_processed, ) @@ -3860,6 +3885,7 @@ def _refresh_single_m3u_account_impl(account_id): streams_processed=streams_processed, streams_created=streams_created, streams_updated=streams_updated, + streams_stale=streams_stale, streams_deleted=streams_deleted, # Structured auto-sync counts so the frontend can render a # warning card when anything failed, without parsing the diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 8e24bdb3..f1966295 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -52,6 +52,22 @@ const M3uSetupSuccess = ({ data }) => { }; // One-line outcome summary for the notification body. +const buildStreamSummary = (data) => { + if (data.streams_processed == null && data.streams_created == null) { + return null; + } + const created = data.streams_created || 0; + const updated = data.streams_updated || 0; + const stale = data.streams_stale || 0; + const removed = data.streams_deleted || 0; + const processed = data.streams_processed || 0; + return ( + `Streams: ${created} created, ${updated} updated, ` + + `${stale} marked stale, ${removed} removed. ` + + `Total processed: ${processed}.` + ); +}; + const buildAutoSyncSummary = (data) => { const created = data.channels_created || 0; const updated = data.channels_updated || 0; @@ -211,21 +227,29 @@ export default function M3URefreshNotification() { let body = message; let autoClose = 2000; - // Surface auto-sync counts attached to the parsing-complete event - // so the channel-side outcome appears in the notification body. + // Surface stream and auto-sync counts attached to the parsing-complete + // event so the outcome appears in the notification body. if (data.progress == 100 && data.action === 'parsing') { + const streamSummary = buildStreamSummary(data); const autoSyncSummary = buildAutoSyncSummary(data); const failed = data.channels_failed || 0; const failedDetails = Array.isArray(data.failed_stream_details) ? data.failed_stream_details : []; - if (autoSyncSummary) { + if (streamSummary || autoSyncSummary) { body = ( {message} - - {autoSyncSummary} - + {streamSummary && ( + + {streamSummary} + + )} + {autoSyncSummary && ( + + {autoSyncSummary} + + )} {failed > 0 && failedDetails.length > 0 && ( + + + {advancedEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + + + )} ); }); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index c874b3e6..b0fa3a56 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -14,6 +14,21 @@ vi.mock('../../../../constants.js', () => ({ description: 'Speed multiplier', }, redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, + redis_chunk_ttl: { + label: 'Buffer Chunk TTL', + advanced: true, + description: 'Chunk TTL', + }, + channel_init_grace_period: { + label: 'Channel Initialization Timeout', + advanced: true, + description: 'Init timeout', + }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + advanced: true, + description: 'Advanced grace period', + }, }, })); @@ -71,6 +86,8 @@ vi.mock('@mantine/core', () => ({
), Stack: ({ children }) =>
{children}
, + Collapse: ({ in: isOpen, children }) => + isOpen ?
{children}
: null, TextInput: ({ label, description, ...rest }) => (
@@ -353,11 +370,52 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + it('calls getInputProps for main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); + }); + + it('hides advanced settings until expanded', () => { + render(); + expect( + screen.queryByTestId('number-input-Client Connect Grace Period') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Channel Initialization Timeout') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Buffer Chunk TTL') + ).not.toBeInTheDocument(); + expect(screen.getByText('Show Advanced Settings')).toBeInTheDocument(); + }); + + it('shows advanced settings when expanded', () => { + render(); + fireEvent.click(screen.getByText('Show Advanced Settings')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Client Connect Grace Period') + ).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Channel Initialization Timeout') + ).toBeInTheDocument(); + expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); diff --git a/frontend/src/constants.js b/frontend/src/constants.js index fba0d7c6..ea6dec13 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,6 +43,7 @@ export const PROXY_SETTINGS_OPTIONS = { }, redis_chunk_ttl: { label: 'Buffer Chunk TTL', + advanced: true, description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', }, @@ -53,13 +54,15 @@ export const PROXY_SETTINGS_OPTIONS = { }, channel_init_grace_period: { label: 'Channel Initialization Timeout', + advanced: true, description: 'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.', }, channel_client_wait_period: { label: 'Client Connect Grace Period', + advanced: true, description: - 'Seconds to keep a ready channel alive when no client has connected yet (e.g. after an API warmup). Once a client connects the channel becomes active; if none connect within this window the channel stops.', + 'Seconds to keep a buffered channel alive when no viewer is connected yet. Rarely needed unless you start channels programmatically.', }, new_client_behind_seconds: { label: 'New Client Buffer (seconds)', From 968862cad3ff4efd3fe421146ec59565599d664a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 10:00:22 -0500 Subject: [PATCH 45/56] refactor(proxysettings): Enhance proxy settings UI by categorizing advanced options and improving documentation. The `channel_client_wait_period` and `channel_init_grace_period` are now marked as advanced settings, and the UI has been updated to allow users to expand and view these options. Additionally, the changelog has been updated to reflect these changes and clarify the behavior of grace periods. --- CHANGELOG.md | 9 +- .../forms/settings/ProxySettingsForm.jsx | 162 +++++++++++------- .../__tests__/ProxySettingsForm.test.jsx | 60 ++++++- frontend/src/constants.js | 5 +- 4 files changed, 167 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1ed3c3..2929247f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. +- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately. ### Performance @@ -31,15 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. -- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Restores the grace-period behaviour that was unintentionally dropped in 0.27.1. When a channel's buffer becomes ready but no client has connected yet (e.g. after an API warmup), this controls how long to keep the channel alive before tearing down. ### Changed -- **Proxy grace-period settings are now split into three distinct timeouts.** In 0.27.1, `channel_init_grace_period` was repurposed as the channel startup/failover timeout (replacing a hardcoded 10s). That left API-warmup "wait for the first client" behaviour tied to `channel_shutdown_delay` (default 0s), so warmed-up channels stopped almost immediately. Behaviour is now: +- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now: - **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup. - **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case). - - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies to warmup. -- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep warmed-up channels alive, set `channel_client_wait_period` instead. + - **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet. +- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced). +- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**. ## [0.27.1] - 2026-06-25 diff --git a/frontend/src/components/forms/settings/ProxySettingsForm.jsx b/frontend/src/components/forms/settings/ProxySettingsForm.jsx index a3a9600a..91b36bb7 100644 --- a/frontend/src/components/forms/settings/ProxySettingsForm.jsx +++ b/frontend/src/components/forms/settings/ProxySettingsForm.jsx @@ -5,83 +5,119 @@ import { updateSetting } from '../../../utils/pages/SettingsUtils.js'; import { Alert, Button, + Collapse, Flex, NumberInput, Stack, TextInput, } from '@mantine/core'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { PROXY_SETTINGS_OPTIONS } from '../../../constants.js'; import { getProxySettingDefaults, getProxySettingsFormInitialValues, } from '../../../utils/forms/settings/ProxySettingsFormUtils.js'; +const isNumericField = (key) => { + return [ + 'buffering_timeout', + 'redis_chunk_ttl', + 'channel_shutdown_delay', + 'channel_init_grace_period', + 'channel_client_wait_period', + 'new_client_behind_seconds', + ].includes(key); +}; + +const isFloatField = (key) => key === 'buffering_speed'; + +const getNumericFieldMax = (key) => { + if (key === 'buffering_timeout') return 300; + if (key === 'redis_chunk_ttl') return 3600; + if (key === 'channel_shutdown_delay') return 300; + if (key === 'channel_client_wait_period') return 300; + if (key === 'new_client_behind_seconds') return 120; + return 300; +}; + +const renderProxySettingField = (key, config, proxySettingsForm) => { + if (isNumericField(key)) { + return ( + + ); + } + + if (isFloatField(key)) { + return ( + + ); + } + + return ( + + ); +}; + const ProxySettingsOptions = React.memo(({ proxySettingsForm }) => { - const isNumericField = (key) => { - // Determine if this field should be a NumberInput - return [ - 'buffering_timeout', - 'redis_chunk_ttl', - 'channel_shutdown_delay', - 'channel_init_grace_period', - 'channel_client_wait_period', - 'new_client_behind_seconds', - ].includes(key); - }; - const isFloatField = (key) => { - return key === 'buffering_speed'; - }; - const getNumericFieldMax = (key) => { - return key === 'buffering_timeout' - ? 300 - : key === 'redis_chunk_ttl' - ? 3600 - : key === 'channel_shutdown_delay' - ? 300 - : key === 'channel_client_wait_period' - ? 300 - : key === 'new_client_behind_seconds' - ? 120 - : 300; - }; + const [advancedOpen, setAdvancedOpen] = useState(false); + const entries = Object.entries(PROXY_SETTINGS_OPTIONS); + const mainEntries = entries.filter(([, config]) => !config.advanced); + const advancedEntries = entries.filter(([, config]) => config.advanced); + return ( <> - {Object.entries(PROXY_SETTINGS_OPTIONS).map(([key, config]) => { - if (isNumericField(key)) { - return ( - - ); - } else if (isFloatField(key)) { - return ( - - ); - } else { - return ( - - ); - } - })} + {mainEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + {advancedEntries.length > 0 && ( + <> + + + + {advancedEntries.map(([key, config]) => + renderProxySettingField(key, config, proxySettingsForm) + )} + + + + )} ); }); diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index c874b3e6..b0fa3a56 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -14,6 +14,21 @@ vi.mock('../../../../constants.js', () => ({ description: 'Speed multiplier', }, redis_url: { label: 'Redis URL', description: 'Redis connection URL' }, + redis_chunk_ttl: { + label: 'Buffer Chunk TTL', + advanced: true, + description: 'Chunk TTL', + }, + channel_init_grace_period: { + label: 'Channel Initialization Timeout', + advanced: true, + description: 'Init timeout', + }, + channel_client_wait_period: { + label: 'Client Connect Grace Period', + advanced: true, + description: 'Advanced grace period', + }, }, })); @@ -71,6 +86,8 @@ vi.mock('@mantine/core', () => ({
), Stack: ({ children }) =>
{children}
, + Collapse: ({ in: isOpen, children }) => + isOpen ?
{children}
: null, TextInput: ({ label, description, ...rest }) => (
@@ -353,11 +370,52 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => { + it('calls getInputProps for main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); + }); + + it('hides advanced settings until expanded', () => { + render(); + expect( + screen.queryByTestId('number-input-Client Connect Grace Period') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Channel Initialization Timeout') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('number-input-Buffer Chunk TTL') + ).not.toBeInTheDocument(); + expect(screen.getByText('Show Advanced Settings')).toBeInTheDocument(); + }); + + it('shows advanced settings when expanded', () => { + render(); + fireEvent.click(screen.getByText('Show Advanced Settings')); + expect(screen.getByTestId('collapse-open')).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Client Connect Grace Period') + ).toBeInTheDocument(); + expect( + screen.getByTestId('number-input-Channel Initialization Timeout') + ).toBeInTheDocument(); + expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_client_wait_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith( + 'channel_init_grace_period' + ); + expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); diff --git a/frontend/src/constants.js b/frontend/src/constants.js index b9016d22..ecb7daca 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -43,6 +43,7 @@ export const PROXY_SETTINGS_OPTIONS = { }, redis_chunk_ttl: { label: 'Buffer Chunk TTL', + advanced: true, description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', }, @@ -53,13 +54,15 @@ export const PROXY_SETTINGS_OPTIONS = { }, channel_init_grace_period: { label: 'Channel Initialization Timeout', + advanced: true, description: 'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.', }, channel_client_wait_period: { label: 'Client Connect Grace Period', + advanced: true, description: - 'Seconds to keep a ready channel alive when no client has connected yet (e.g. after an API warmup). Once a client connects the channel becomes active; if none connect within this window the channel stops.', + 'Seconds to keep a buffered channel alive when no viewer is connected yet. Rarely needed unless you start channels programmatically.', }, new_client_behind_seconds: { label: 'New Client Buffer (seconds)', From 97515c4d05acc8bc438f05b49057027d09e0ad1a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 10:34:40 -0500 Subject: [PATCH 46/56] tests: fix proxy setting test --- .../__tests__/ProxySettingsForm.test.jsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index b0fa3a56..2b0dc55f 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -370,18 +370,11 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for main settings on initial render', () => { + it('binds main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); }); it('hides advanced settings until expanded', () => { @@ -409,13 +402,6 @@ describe('ProxySettingsForm', () => { screen.getByTestId('number-input-Channel Initialization Timeout') ).toBeInTheDocument(); expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); From 8d5c13bdc1c079942d866e106c8763b268eb77c2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 10:34:40 -0500 Subject: [PATCH 47/56] tests: fix proxy setting test --- .../__tests__/ProxySettingsForm.test.jsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx index b0fa3a56..2b0dc55f 100644 --- a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx @@ -370,18 +370,11 @@ describe('ProxySettingsForm', () => { // ── ProxySettingsOptions field routing ───────────────────────────────────── describe('ProxySettingsOptions field routing', () => { - it('calls getInputProps for main settings on initial render', () => { + it('binds main settings on initial render', () => { render(); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout'); expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed'); expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url'); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).not.toHaveBeenCalledWith('redis_chunk_ttl'); }); it('hides advanced settings until expanded', () => { @@ -409,13 +402,6 @@ describe('ProxySettingsForm', () => { screen.getByTestId('number-input-Channel Initialization Timeout') ).toBeInTheDocument(); expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument(); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_client_wait_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith( - 'channel_init_grace_period' - ); - expect(formMock.getInputProps).toHaveBeenCalledWith('redis_chunk_ttl'); }); }); }); From 22b957aee4934cc7546b57c836d96be1be3c10c4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 30 Jun 2026 16:28:27 +0000 Subject: [PATCH 48/56] Release v0.27.2 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 309ed5c1..a97f5158 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.27.2] - 2026-06-30 + ### Added - **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately. diff --git a/version.py b/version.py index db62d71f..25ccd35c 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.27.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.27.2' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From eac3849486e5ce39a1b9ae367dc493cde46afcb4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 11:57:38 -0500 Subject: [PATCH 49/56] changelog: add thanks to submitter. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6a2687..55962c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. -- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) +- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.27.2] - 2026-06-30 From 617eeae42a60f0f0843b9bf05e639e9818a67603 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 12:22:55 -0500 Subject: [PATCH 50/56] docs: Update changelog and reduce code comment length. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 12 +++--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55962c84..d5d18d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. - **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) +- **XC refresh no longer wipes auto-created channels on an empty provider fetch.** When `collect_xc_streams()` returned no live streams (transient upstream failure, fetch exception, or no enabled category matched), the refresh used to continue into stale-marking and auto-sync, which deleted the account's entire auto-created channel lineup. An empty XC result now aborts before those steps, sets the account to `ERROR`, and preserves the existing lineup—matching the standard M3U path's empty/failed-download guards. (Fixes #1377) — Thanks [@Jacob-Lasky](https://github.com/Jacob-Lasky) ## [0.27.2] - 2026-06-30 diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index fa207464..581a390f 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -3711,15 +3711,9 @@ def _refresh_single_m3u_account_impl(account_id): del channel_group_relationships, filtered_groups if not all_xc_streams: - # collect_xc_streams() returns an empty list when the provider - # returned no live streams, the fetch raised, or no enabled - # category matched. Falling through would mark every existing - # stream stale and then let sync_auto_channels delete the - # account's entire auto-created channel lineup (its per-group - # "no streams remaining" branch). A routine refresh must not - # destroy channels because of a transient upstream failure, so - # abort here, before stale-marking and auto-sync, exactly as the - # standard-path guards above do for an empty/failed download. + # Empty XC fetch (provider hiccup, fetch error, or no enabled + # category matched) must not fall through to stale-marking and + # auto-sync, which would delete the entire auto-created lineup. logger.error( f"No streams collected from XC provider for account " f"{account_id}; aborting refresh to preserve the existing " From 14ab2a0317fd3f225aef41b5eb6b86106a5ab2fa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 16:48:36 -0500 Subject: [PATCH 51/56] Enhance VodConnectionCard to use human-readable duration format and update tests accordingly. Refactor formatDuration function to support 'human' precision and add related unit tests for various duration scenarios. --- .../components/cards/VodConnectionCard.jsx | 5 +- .../__tests__/VodConnectionCard.test.jsx | 74 ++++++++++++++++++- .../src/utils/__tests__/dateTimeUtils.test.js | 43 ++++++++++- frontend/src/utils/dateTimeUtils.js | 32 +++++--- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx index 8efe4e55..0ecb2031 100644 --- a/frontend/src/components/cards/VodConnectionCard.jsx +++ b/frontend/src/components/cards/VodConnectionCard.jsx @@ -372,7 +372,10 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { {metadata.duration_secs && ( - {formatDuration(metadata.duration_secs, { zeroValue: 'Unknown', precision: 'hm' })} + {formatDuration(metadata.duration_secs, { + zeroValue: 'Unknown', + precision: 'human', + })} )} diff --git a/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx index 6013ff31..1419dc1b 100644 --- a/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx @@ -147,7 +147,10 @@ vi.mock('lucide-react', () => ({ })); // ── Imports after mocks ─────────────────────────────────────────────────────── -import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js'; +import { + formatDuration, + useDateTimeFormat, +} from '../../../utils/dateTimeUtils.js'; import { calculateProgress, getEpisodeDisplayTitle, @@ -397,6 +400,75 @@ describe('VodConnectionCard', () => { }); }); + // ── Content duration badge ───────────────────────────────────────────────── + + describe('content duration badge', () => { + it('requests human-readable formatting for the content duration badge', () => { + render( + + ); + + expect(formatDuration).toHaveBeenCalledWith(2700, { + zeroValue: 'Unknown', + precision: 'human', + }); + }); + + it('renders the human-readable duration returned by formatDuration', () => { + vi.mocked(formatDuration).mockImplementation((seconds, options = {}) => { + if (options?.precision === 'human') { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + } + return `${seconds}s`; + }); + + render( + + ); + + expect(screen.getByText('45m')).toBeInTheDocument(); + }); + + it('shows hours and minutes for long-form content', () => { + vi.mocked(formatDuration).mockImplementation((seconds, options = {}) => { + if (options?.precision === 'human') { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + } + return `${seconds}s`; + }); + + render( + + ); + + expect(screen.getByText('2h 0m')).toBeInTheDocument(); + }); + + it('does not render the duration badge when duration_secs is missing', () => { + render( + + ); + + expect(formatDuration).not.toHaveBeenCalled(); + }); + }); + // ── Episode rendering ────────────────────────────────────────────────────── describe('episode content', () => { diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 43ecaaf6..3bc642af 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -73,11 +73,21 @@ describe('dateTimeUtils', () => { it('should handle strict parsing', () => { // With strict=true, a date that doesn't match the format should be invalid - const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true); + const invalid = dateTimeUtils.initializeTime( + '15-01-2024', + 'YYYY-MM-DD', + null, + true + ); expect(invalid.isValid()).toBe(false); // With strict=true and a matching format, it should be valid - const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true); + const valid = dateTimeUtils.initializeTime( + '2024-01-15', + 'YYYY-MM-DD', + null, + true + ); expect(valid.isValid()).toBe(true); }); }); @@ -873,6 +883,35 @@ describe('dateTimeUtils', () => { }); }); + describe('precision: human', () => { + it('should format sub-hour content as minutes with suffix', () => { + expect(dateTimeUtils.formatDuration(2700, { precision: 'human' })).toBe( + '45m' + ); + }); + + it('should floor partial minutes for short content', () => { + expect(dateTimeUtils.formatDuration(90, { precision: 'human' })).toBe( + '1m' + ); + }); + + it('should format hour-plus content as hours and minutes', () => { + expect(dateTimeUtils.formatDuration(5400, { precision: 'human' })).toBe( + '1h 30m' + ); + }); + + it('should return custom zeroValue when seconds is 0', () => { + expect( + dateTimeUtils.formatDuration(0, { + precision: 'human', + zeroValue: 'Unknown', + }) + ).toBe('Unknown'); + }); + }); + describe('precision: m', () => { it('should return total minutes as integer', () => { expect(dateTimeUtils.formatDuration(90, { precision: 'm' })).toBe('1'); diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index e198329c..2b613a32 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -18,7 +18,12 @@ export const convertToMs = (dateTime) => dayjs(dateTime).valueOf(); export const convertToSec = (dateTime) => dayjs(dateTime).unix(); -export const initializeTime = (dateTime, format = null, locale = null, strict = false) => { +export const initializeTime = ( + dateTime, + format = null, + locale = null, + strict = false +) => { if (format && locale) { return dayjs(dateTime, format, locale, strict); } else if (format) { @@ -26,7 +31,7 @@ export const initializeTime = (dateTime, format = null, locale = null, strict = } else { return dayjs(dateTime); } -} +}; export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day'); @@ -90,7 +95,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value); export const setSecond = (dateTime, value) => dayjs(dateTime).second(value); -export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value); +export const setMillisecond = (dateTime, value) => + dayjs(dateTime).millisecond(value); export const getMonth = (dateTime) => dayjs(dateTime).month(); @@ -376,12 +382,16 @@ export const MONTH_ABBR = [ /** * @param {number} seconds * @param {object} [options] - * @param {'hms'|'hm'|'m'} [options.precision='hms'] - Segments to include + * @param {'hms'|'hm'|'m'|'human'} [options.precision='hms'] - Segments to include * @param {boolean} [options.alwaysShowHours=false] - Always include hours segment * @param {string|null} [options.zeroValue=null] - Return this when seconds is 0/falsy */ export const formatDuration = (seconds, options = {}) => { - const { precision = 'hms', alwaysShowHours = false, zeroValue = null } = options; + const { + precision = 'hms', + alwaysShowHours = false, + zeroValue = null, + } = options; if (!seconds || seconds === 0) return zeroValue ?? '0:00'; @@ -395,16 +405,14 @@ export const formatDuration = (seconds, options = {}) => { const hh = h.toString().padStart(2, '0'); switch (precision) { + case 'human': + return h > 0 ? `${h}h ${m}m` : `${m}m`; case 'm': return `${Math.floor(abs / 60)}`; case 'hm': - return (alwaysShowHours || h > 0) - ? `${hh}:${mm}` - : `${m}`; + return alwaysShowHours || h > 0 ? `${hh}:${mm}` : `${m}`; case 'hms': default: - return (alwaysShowHours || h > 0) - ? `${hh}:${mm}:${ss}` - : `${m}:${ss}`; + return alwaysShowHours || h > 0 ? `${hh}:${mm}:${ss}` : `${m}:${ss}`; } -}; \ No newline at end of file +}; From dafdb1a19b0fae1420212f1fde2148af1745ee32 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 16:49:30 -0500 Subject: [PATCH 52/56] refactor(tests): Improve formatting and structure in ChannelsTable tests; streamline requeryChannels function in ChannelUtils --- .../tables/__tests__/ChannelsTable.test.jsx | 233 ++++++++++++++---- frontend/src/utils/forms/ChannelUtils.js | 4 +- .../forms/__tests__/ChannelUtils.test.js | 13 +- 3 files changed, 198 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx index a3e20609..1d36f682 100644 --- a/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx +++ b/frontend/src/components/tables/__tests__/ChannelsTable.test.jsx @@ -1,5 +1,11 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── DnD Kit ──────────────────────────────────────────────────────────────────── @@ -79,7 +85,10 @@ vi.mock('../CustomTable', () => ({ vi.mock('../ChannelsTable/ChannelsTableOnboarding', () => ({ default: ({ editChannel }) => (
-
@@ -99,7 +108,10 @@ vi.mock('../ChannelsTable/ChannelTableHeader', () => ({ setShowOnlyOverriddenChannels, }) => (
- @@ -125,7 +139,9 @@ vi.mock('../ChannelsTable/ChannelTableHeader', () => ({ @@ -175,7 +191,15 @@ vi.mock('../../forms/Recording', () => ({ ) : null, })); vi.mock('../../ConfirmationDialog', () => ({ - default: ({ opened, onClose, onConfirm, title, loading, confirmLabel, cancelLabel }) => + default: ({ + opened, + onClose, + onConfirm, + title, + loading, + confirmLabel, + cancelLabel, + }) => opened ? (
{title} @@ -205,7 +229,11 @@ vi.mock('@mantine/core', () => ({
), Button: ({ children, onClick, leftSection, disabled, loading }) => ( - @@ -236,27 +264,57 @@ vi.mock('@mantine/core', () => ({ ), MenuTarget: ({ children }) =>
{children}
, MultiSelect: ({ value, data }) => ( - {}} + > {(data || []).map((d) => ( - + ))} ), NativeSelect: ({ value, data, onChange }) => ( - {(data || []).map((d) => ( - + ))} ), NumberInput: ({ value, onChange }) => ( - onChange(Number(e.target.value))} /> + onChange(Number(e.target.value))} + /> ), Pagination: ({ total, value, onChange }) => (
- + {value} - +
), Paper: ({ children, style }) =>
{children}
, @@ -264,11 +322,17 @@ vi.mock('@mantine/core', () => ({ ({ children }) =>
{children}
, { Target: ({ children }) =>
{children}
, - Dropdown: ({ children }) =>
{children}
, + Dropdown: ({ children }) => ( +
{children}
+ ), } ), PopoverDropdown: ({ children, onClick, onMouseDown }) => ( -
+
{children}
), @@ -281,7 +345,9 @@ vi.mock('@mantine/core', () => ({ > {(data || []).map((d) => ( - + ))} ), @@ -297,7 +363,9 @@ vi.mock('@mantine/core', () => ({ /> ), Text: ({ children, style }) => ( - {children} + + {children} + ), TextInput: ({ value, placeholder, onChange, readOnly }) => ( ({ ), Tooltip: ({ children, label }) =>
{children}
, UnstyledButton: ({ children, onClick }) => ( - + ), useMantineTheme: vi.fn(() => ({ - tailwind: { yellow: { 3: '#fde047' }, red: { 6: '#dc2626' }, green: { 5: '#22c55e' } }, + tailwind: { + yellow: { 3: '#fde047' }, + red: { 6: '#dc2626' }, + green: { 5: '#22c55e' }, + }, palette: { - custom: { greenMain: '#22c55e', indigoMain: '#6366f1', greyBorder: '#71717a' }, + custom: { + greenMain: '#22c55e', + indigoMain: '#6366f1', + greyBorder: '#71717a', + }, }, })), })); @@ -353,7 +431,12 @@ import useOutputProfilesStore from '../../../store/outputProfiles'; import useWarningsStore from '../../../store/warnings'; import useLocalStorage from '../../../hooks/useLocalStorage'; import { useTable } from '../CustomTable'; -import { deleteChannel, deleteChannels, queryChannels, getAllChannelIds } from '../../../utils/tables/ChannelsTableUtils.js'; +import { + deleteChannel, + deleteChannels, + queryChannels, + getAllChannelIds, +} from '../../../utils/tables/ChannelsTableUtils.js'; import { requeryChannels } from '../../../utils/forms/ChannelUtils.js'; import { copyToClipboard } from '../../../utils'; import { buildLiveStreamUrl } from '../../../utils/components/FloatingVideoUtils.js'; @@ -433,9 +516,7 @@ const setupMocks = ({ }) ); - vi.mocked(useAuthStore).mockImplementation((sel) => - sel({ user: authUser }) - ); + vi.mocked(useAuthStore).mockImplementation((sel) => sel({ user: authUser })); vi.mocked(useEPGsStore).mockImplementation((sel) => sel({ epgs, tvgsById: {}, tvgsLoaded: true }) @@ -471,8 +552,7 @@ const setupMocks = ({ const getActionsCol = () => capturedTableOptions.columns.find((c) => c.id === 'actions'); -const getCol = (id) => - capturedTableOptions.columns.find((c) => c.id === id); +const getCol = (id) => capturedTableOptions.columns.find((c) => c.id === id); // ══════════════════════════════════════════════════════════════════════════════ // Tests @@ -516,10 +596,17 @@ describe('ChannelsTable', () => { tableOverrides: { getRowModel: () => ({ rows: [] }) }, }); vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelIds: [], profiles: {}, selectedProfileId: '0', channelGroups: {} }) + sel({ + channelIds: [], + profiles: {}, + selectedProfileId: '0', + channelGroups: {}, + }) ); render(); - await waitFor(() => expect(screen.getByTestId('onboarding')).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId('onboarding')).toBeInTheDocument() + ); }); it('renders pagination controls when channels exist', () => { @@ -633,7 +720,12 @@ describe('ChannelsTable', () => { tableOverrides: { getRowModel: () => ({ rows: [] }) }, }); vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelIds: [], profiles: {}, selectedProfileId: '0', channelGroups: {} }) + sel({ + channelIds: [], + profiles: {}, + selectedProfileId: '0', + channelGroups: {}, + }) ); render(); const addBtn = await screen.findByTestId('onboarding-add'); @@ -665,7 +757,9 @@ describe('ChannelsTable', () => { const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); render(); const { getByTestId } = renderActionCell(channel, tableInstance); - expect(getByTestId('icon-square-pen').closest('button')).not.toBeDisabled(); + expect( + getByTestId('icon-square-pen').closest('button') + ).not.toBeDisabled(); }); it('clicking edit button opens channel form populated with the channel', () => { @@ -702,7 +796,9 @@ describe('ChannelsTable', () => { const { tableInstance } = setupMocks({ authUser: makeAdminUser() }); render(); const { getByTestId } = renderActionCell(channel, tableInstance); - expect(getByTestId('icon-square-minus').closest('button')).not.toBeDisabled(); + expect( + getByTestId('icon-square-minus').closest('button') + ).not.toBeDisabled(); }); it('opens confirmation dialog when delete is clicked and warning is not suppressed', () => { @@ -820,9 +916,38 @@ describe('ChannelsTable', () => { render(); fireEvent.click(screen.getByTestId('header-delete-channels')); fireEvent.click(screen.getByTestId('confirm-ok')); - await waitFor(() => - expect(deleteChannels).toHaveBeenCalledWith([1, 2]) - ); + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); + }); + + it('awaits requeryChannels before clearing bulk selection', async () => { + const channels = [makeChannel({ id: 1 }), makeChannel({ id: 2 })]; + const setSelectedTableIds = vi.fn(); + let resolveRequery; + const requeryPromise = new Promise((resolve) => { + resolveRequery = resolve; + }); + vi.mocked(requeryChannels).mockReturnValue(requeryPromise); + + setupMocks({ + channels, + tableOverrides: { + getRowModel: vi.fn(() => ({ rows: [] })), + getHeaderGroups: vi.fn(() => []), + setSelectedTableIds, + selectedTableIds: [1, 2], + }, + isWarningSuppressed: vi.fn(() => false), + }); + render(); + fireEvent.click(screen.getByTestId('header-delete-channels')); + fireEvent.click(screen.getByTestId('confirm-ok')); + + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); + expect(requeryChannels).toHaveBeenCalled(); + expect(setSelectedTableIds).not.toHaveBeenCalled(); + + resolveRequery(); + await waitFor(() => expect(setSelectedTableIds).toHaveBeenCalledWith([])); }); it('skips dialog and calls deleteChannels directly when warning is suppressed', async () => { @@ -839,9 +964,7 @@ describe('ChannelsTable', () => { }); render(); fireEvent.click(screen.getByTestId('header-delete-channels')); - await waitFor(() => - expect(deleteChannels).toHaveBeenCalledWith([1, 2]) - ); + await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2])); expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); }); }); @@ -856,7 +979,9 @@ describe('ChannelsTable', () => { vi.mocked(useVideoStore).mockImplementation((sel) => sel({ showVideo: showVideoMock }) ); - vi.mocked(buildLiveStreamUrl).mockReturnValue('/proxy/ts/stream/uuid-abc'); + vi.mocked(buildLiveStreamUrl).mockReturnValue( + '/proxy/ts/stream/uuid-abc' + ); render(); const col = getActionsCol(); const { getByTestId } = render( @@ -884,10 +1009,14 @@ describe('ChannelsTable', () => { ); // Record menu item is the second menu-item (after Copy URL) const menuItems = getAllByTestId('menu-item'); - const recordItem = menuItems.find((el) => el.textContent.includes('Record')); + const recordItem = menuItems.find((el) => + el.textContent.includes('Record') + ); fireEvent.click(recordItem); expect(screen.getByTestId('recording-form')).toBeInTheDocument(); - expect(screen.getByTestId('recording-form-channel')).toHaveTextContent('CNN'); + expect(screen.getByTestId('recording-form-channel')).toHaveTextContent( + 'CNN' + ); }); it('closes recording form when onClose is called', () => { @@ -899,7 +1028,9 @@ describe('ChannelsTable', () => { col.cell({ row: { original: channel }, table: tableInstance }) ); const menuItems = getAllByTestId('menu-item'); - const recordItem = menuItems.find((el) => el.textContent.includes('Record')); + const recordItem = menuItems.find((el) => + el.textContent.includes('Record') + ); fireEvent.click(recordItem); fireEvent.click(screen.getByTestId('recording-form-close')); expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument(); @@ -918,7 +1049,9 @@ describe('ChannelsTable', () => { col.cell({ row: { original: channel }, table: tableInstance }) ); const menuItems = getAllByTestId('unstyled-button'); - const copyBtn = menuItems.find((el) => el.textContent.includes('Copy URL')); + const copyBtn = menuItems.find((el) => + el.textContent.includes('Copy URL') + ); fireEvent.click(copyBtn); expect(copyToClipboard).toHaveBeenCalled(); }); @@ -949,7 +1082,9 @@ describe('ChannelsTable', () => { const tableInstance = makeDefaultTableInstance({ getState: () => ({ selectedTableIds: [] }), }); - return render(col.cell({ row: { original: channel }, table: tableInstance })); + return render( + col.cell({ row: { original: channel }, table: tableInstance }) + ); }; it('renders a Switch for the enabled column', () => { @@ -1002,7 +1137,10 @@ describe('ChannelsTable', () => { setupMocks(); render(); const col = getCol('name'); - const result = col.accessorFn({ effective_name: 'Override Name', name: 'Original' }); + const result = col.accessorFn({ + effective_name: 'Override Name', + name: 'Original', + }); expect(result).toBe('Override Name'); }); @@ -1057,7 +1195,10 @@ describe('ChannelsTable', () => { describe('expandedRowRenderer', () => { it('renders ChannelTableStreams for the expanded row', () => { - const channel = makeChannel({ id: 1, streams: [{ id: 10, is_stale: false }] }); + const channel = makeChannel({ + id: 1, + streams: [{ id: 10, is_stale: false }], + }); setupMocks({ channels: [channel] }); render(); const { expandedRowRenderer } = capturedTableOptions; diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js index 40cf9a27..7eabab0a 100644 --- a/frontend/src/utils/forms/ChannelUtils.js +++ b/frontend/src/utils/forms/ChannelUtils.js @@ -40,9 +40,7 @@ export const updateChannel = (values) => { export const addChannel = (channel) => { return API.addChannel(channel); }; -export const requeryChannels = () => { - API.requeryChannels(); -}; +export const requeryChannels = () => API.requeryChannels(); // PATCH semantic: `override: null` deletes the override row; per-field // nulls only clear the matching field. diff --git a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js index 88002c09..53cd0d99 100644 --- a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js +++ b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js @@ -114,13 +114,20 @@ describe('ChannelUtils', () => { describe('requeryChannels', () => { it('calls API.requeryChannels', () => { + vi.mocked(API.requeryChannels).mockResolvedValue(undefined); requeryChannels(); expect(API.requeryChannels).toHaveBeenCalledTimes(1); }); - it('returns undefined', () => { - const result = requeryChannels(); - expect(result).toBeUndefined(); + it('returns the promise from API.requeryChannels', async () => { + const response = { results: [{ id: 1 }] }; + vi.mocked(API.requeryChannels).mockResolvedValue(response); + await expect(requeryChannels()).resolves.toBe(response); + }); + + it('propagates rejection from API.requeryChannels', async () => { + vi.mocked(API.requeryChannels).mockRejectedValue(new Error('network')); + await expect(requeryChannels()).rejects.toThrow('network'); }); }); From 15fbf3d3d6b0bfac1a57b38bac47080928a5b259 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 30 Jun 2026 16:56:51 -0500 Subject: [PATCH 53/56] changelog: Update for pr. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d18d44..4d74a3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. +- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810) ### Performance @@ -33,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. - **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) - **XC refresh no longer wipes auto-created channels on an empty provider fetch.** When `collect_xc_streams()` returned no live streams (transient upstream failure, fetch exception, or no enabled category matched), the refresh used to continue into stale-marking and auto-sync, which deleted the account's entire auto-created channel lineup. An empty XC result now aborts before those steps, sets the account to `ERROR`, and preserves the existing lineup—matching the standard M3U path's empty/failed-download guards. (Fixes #1377) — Thanks [@Jacob-Lasky](https://github.com/Jacob-Lasky) +- **`ChannelUtils.requeryChannels()` returns the API promise again** so bulk channel delete, drag reorder, and inline channel-number saves await the table refetch before clearing selection or continuing. — Thanks [@nick4810](https://github.com/nick4810) (#1393) +- **VOD connection card Content Duration badge** shows human-readable lengths (`45m`, `2h 0m`) again on the Stats page instead of bare minute integers. — Thanks [@nick4810](https://github.com/nick4810) (#1393) ## [0.27.2] - 2026-06-30 From 12f094cbc09d93037df1a87d2a962a456009dfc3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 1 Jul 2026 17:07:47 -0500 Subject: [PATCH 54/56] feat(epg): Implement channel parsing progress tracking and update progress reporting logic in parse_channels_only function --- CHANGELOG.md | 1 + apps/epg/tasks.py | 80 +++++++++++++++++-- .../tests/test_parsing_channels_progress.py | 44 ++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 apps/epg/tests/test_parsing_channels_progress.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d74a3c3..f1664302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **EPG channel parsing progress no longer exceeds 100%.** During `parsing_channels`, progress used the pre-parse database channel count as the denominator while the numerator counted channels found in the XML. When the guide grew (or on sources where the file had more channels than the DB), the UI could show values well above 100% (e.g. ~300%). The estimate now bumps when the XML exceeds the DB baseline, progress is capped at 98% until final cleanup, and update frequency adapts to the known total (~20 ticks on steady-state refreshes; coarse every-100-channel updates for first import or when the file outgrows the estimate). A final in-loop update reports the true parsed count before completion. - **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. - **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 83aaa275..ecf05694 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -1197,6 +1197,35 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False): logger.error(f"Error extracting {file_path}: {str(e)}", exc_info=True) return None +_CHANNEL_PARSE_PROGRESS_START = 25 +_CHANNEL_PARSE_PROGRESS_CAP = 98 +_CHANNEL_PARSE_PROGRESS_SCALE = 0.98 + + +def _channel_parse_progress(processed_channels, channel_estimate, had_db_baseline): + """Map channel parsing onto 25-98%. Bump estimate when the XML has grown.""" + if not had_db_baseline: + if processed_channels <= 0: + return _CHANNEL_PARSE_PROGRESS_START, channel_estimate + return ( + min( + _CHANNEL_PARSE_PROGRESS_START + (processed_channels // 100), + _CHANNEL_PARSE_PROGRESS_CAP - 1, + ), + channel_estimate, + ) + + if processed_channels > channel_estimate: + channel_estimate = processed_channels + if channel_estimate <= 0: + return _CHANNEL_PARSE_PROGRESS_START, channel_estimate + + span = _CHANNEL_PARSE_PROGRESS_CAP - _CHANNEL_PARSE_PROGRESS_START + progress = _CHANNEL_PARSE_PROGRESS_START + int( + (processed_channels / channel_estimate) * span * _CHANNEL_PARSE_PROGRESS_SCALE + ) + return min(_CHANNEL_PARSE_PROGRESS_CAP, progress), channel_estimate + def parse_channels_only(source): # Use extracted file if available, otherwise use the original file path @@ -1309,6 +1338,8 @@ def parse_channels_only(source): epgs_to_create = [] epgs_to_update = [] total_channels = 0 + channel_estimate = 0 + had_db_baseline = False processed_channels = 0 batch_size = 500 # Process in batches to limit memory usage progress = 0 # Initialize progress variable here @@ -1323,10 +1354,14 @@ def parse_channels_only(source): # Attempt to count existing channels in the database try: total_channels = EPGData.objects.filter(epg_source=source).count() + channel_estimate = total_channels + had_db_baseline = total_channels > 0 logger.info(f"Found {total_channels} existing channels for this source") except Exception as e: logger.error(f"Error counting channels: {e}") total_channels = 500 # Default estimate + channel_estimate = total_channels + had_db_baseline = True if process: logger.debug(f"[parse_channels_only] Memory after closing initial file: {process.memory_info().rss / 1024 / 1024:.2f} MB") @@ -1453,20 +1488,36 @@ def parse_channels_only(source): if process: logger.info(f"[parse_channels_only] Memory after clearing cache: {process.memory_info().rss / 1024 / 1024:.2f} MB") - # Send progress updates - if processed_channels % 100 == 0 or processed_channels == total_channels: - progress = 25 + int((processed_channels / total_channels) * 65) if total_channels > 0 else 90 + # Send progress updates (~20 ticks when the DB count still holds; else every 100) + estimate_exceeded = had_db_baseline and processed_channels > total_channels + interval = ( + max(1, total_channels // 20) + if had_db_baseline and total_channels and not estimate_exceeded + else 100 + ) + if processed_channels % interval == 0: + progress, channel_estimate = _channel_parse_progress( + processed_channels, + channel_estimate, + had_db_baseline, + ) send_epg_update( source.id, "parsing_channels", progress, processed=processed_channels, - total=total_channels + total=channel_estimate, + ) + if had_db_baseline and processed_channels > total_channels: + logger.debug( + f"[parse_channels_only] Processed channel {tvg_id} - " + f"processed {processed_channels - total_channels} additional channels" ) - if processed_channels > total_channels: - logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels - total_channels} additional channels") else: - logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels}/{total_channels}") + logger.debug( + f"[parse_channels_only] Processed channel {tvg_id} - " + f"processed {processed_channels}/{channel_estimate or total_channels}" + ) if process: logger.debug(f"[parse_channels_only] Memory before elem cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") # Clear memory @@ -1499,6 +1550,21 @@ def parse_channels_only(source): logger.info(f"[parse_channels_only] Processed {processed_channels} channels current memory: {process.memory_info().rss / 1024 / 1024:.2f} MB") else: logger.info(f"[parse_channels_only] Processed {processed_channels} channels") + + if processed_channels > 0: + progress, channel_estimate = _channel_parse_progress( + processed_channels, + channel_estimate, + had_db_baseline, + ) + send_epg_update( + source.id, + "parsing_channels", + progress, + processed=processed_channels, + total=channel_estimate, + ) + # Process any remaining items if epgs_to_create: EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) diff --git a/apps/epg/tests/test_parsing_channels_progress.py b/apps/epg/tests/test_parsing_channels_progress.py new file mode 100644 index 00000000..ece0e71c --- /dev/null +++ b/apps/epg/tests/test_parsing_channels_progress.py @@ -0,0 +1,44 @@ +from django.test import SimpleTestCase + +from apps.epg.tasks import ( + _CHANNEL_PARSE_PROGRESS_CAP, + _CHANNEL_PARSE_PROGRESS_START, + _channel_parse_progress, +) + + +class ChannelParseProgressTests(SimpleTestCase): + def test_exceeding_estimate_recalculates_instead_of_going_over_cap(self): + """101/100 used to yield ~90%+; now bumps estimate to 101 so ratio stays <= 1.""" + progress, estimate = _channel_parse_progress(100, 100, had_db_baseline=True) + self.assertEqual(estimate, 100) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + progress, estimate = _channel_parse_progress(101, 100, had_db_baseline=True) + self.assertEqual(estimate, 101) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + self.assertGreater(progress, 90) + + def test_large_xml_growth_never_exceeds_cap(self): + """Previously 425 channels vs DB estimate of 100 could show ~300%.""" + estimate = 100 + for processed in (100, 200, 300, 425): + progress, estimate = _channel_parse_progress( + processed, estimate, had_db_baseline=True + ) + self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + def test_first_import_crawls_instead_of_flat_ninety(self): + progress, estimate = _channel_parse_progress(100, 0, had_db_baseline=False) + self.assertEqual(estimate, 0) + self.assertEqual(progress, _CHANNEL_PARSE_PROGRESS_START + 1) + self.assertLess(progress, 90) + + progress, _ = _channel_parse_progress(5000, 0, had_db_baseline=False) + self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP) + + def test_partial_progress_when_below_estimate(self): + progress, estimate = _channel_parse_progress(50, 100, had_db_baseline=True) + self.assertEqual(estimate, 100) + self.assertGreater(progress, _CHANNEL_PARSE_PROGRESS_START) + self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP) From de3a0a071310659b44c27a2538184563f00f5487 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 2 Jul 2026 13:33:04 -0500 Subject: [PATCH 55/56] refactor(celery): Replace worker_ready signal with worker_process_init for plugin discovery and close inherited DB connections. Use djangos PostgrSQL backend for celery tasks. --- dispatcharr/celery.py | 21 +++++++-- dispatcharr/db/process_label.py | 11 +++++ dispatcharr/settings.py | 31 +++++++++--- tests/test_celery_plugin_discovery.py | 68 +++++++++++++++------------ tests/test_process_label.py | 25 +++++++++- 5 files changed, 114 insertions(+), 42 deletions(-) diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 16a393dc..69bb1221 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -2,7 +2,7 @@ import os from celery import Celery import logging -from celery.signals import task_postrun, task_prerun, worker_ready +from celery.signals import task_postrun, task_prerun, worker_process_init, worker_ready logger = logging.getLogger(__name__) @@ -42,13 +42,26 @@ app.autodiscover_tasks() # Plugins live outside INSTALLED_APPS, so autodiscover_tasks() never imports # them. Without an eager import, workers reject plugin @shared_tasks with # "Received unregistered task" until a lazy event import warms the module. -@worker_ready.connect(weak=False) -def discover_plugins_on_worker_ready(**_kwargs): +# Discovery runs in worker_process_init (each prefork child / thread worker) +# rather than worker_ready (the long-lived arbiter) so the parent process +# never opens DB connections that autoscale children would inherit via fork(). +@worker_process_init.connect(weak=False) +def init_worker_process(**_kwargs): + from django.db import connections + + # Standard Celery + Django guidance for prefork pools: discard any + # connection state inherited from the parent across fork(). + try: + connections.close_all() + except Exception: + logger.warning("Failed to close inherited DB connections after fork", exc_info=True) + try: from apps.plugins.loader import PluginManager PluginManager.get().discover_plugins(sync_db=False) except Exception: - logger.exception("plugin discovery on worker_ready failed") + logger.exception("plugin discovery on worker_process_init failed") + # Use environment variable for log level with fallback to INFO CELERY_LOG_LEVEL = os.environ.get('DISPATCHARR_LOG_LEVEL', 'INFO').upper() diff --git a/dispatcharr/db/process_label.py b/dispatcharr/db/process_label.py index 209ad291..137d3b21 100644 --- a/dispatcharr/db/process_label.py +++ b/dispatcharr/db/process_label.py @@ -39,5 +39,16 @@ def get_process_role(argv: list[str] | None = None) -> str: return "django" +def uses_geventpool_database_backend(argv: list[str] | None = None) -> bool: + """ + True for uWSGI/Daphne/manage (gevent or request-scoped pooling). + + Celery prefork/thread workers must use Django's standard PostgreSQL backend: + django-db-geventpool keeps a process-wide warm-connection pool that fork() + duplicates across autoscale children, which corrupts Postgres session state. + """ + return get_process_role(argv) not in ("celery-worker", "celery-dvr", "celery-beat") + + def db_application_name() -> str: return f"Dispatcharr-{get_process_role()}-{os.getpid()}" diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index a1e2b798..5b8de973 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -5,6 +5,8 @@ from datetime import timedelta from urllib.parse import quote_plus from django.core.exceptions import ImproperlyConfigured +from dispatcharr.db.process_label import db_application_name, uses_geventpool_database_backend + def _validate_tls_cert_paths(paths, service_name): """Validate that configured TLS certificate file paths exist on disk. @@ -228,24 +230,39 @@ if os.getenv("DB_ENGINE", None) == "sqlite": } } else: + _use_geventpool_db = uses_geventpool_database_backend() + _pg_options = {"pool": False} if _use_geventpool_db else { + "application_name": db_application_name(), + } + if _use_geventpool_db: + _pg_options.update({ + "MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100 + "REUSE_CONNS": 3, # Connections to keep warm between requests + "CONN_MAX_LIFETIME": DATABASE_POOL_CONN_MAX_LIFETIME or None, + }) + DATABASES = { "default": { - "ENGINE": "dispatcharr.db.backends.postgresql_psycopg3", + "ENGINE": ( + "dispatcharr.db.backends.postgresql_psycopg3" + if _use_geventpool_db + else "django.db.backends.postgresql" + ), "NAME": os.environ.get("POSTGRES_DB", "dispatcharr"), "USER": os.environ.get("POSTGRES_USER", "dispatch"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, - "OPTIONS": { - "MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100 - "REUSE_CONNS": 3, # Connections to keep warm between requests - "pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections - "CONN_MAX_LIFETIME": DATABASE_POOL_CONN_MAX_LIFETIME or None, - }, + "OPTIONS": _pg_options, } } + if not _use_geventpool_db: + print( + f"PostgreSQL: standard backend for Celery ({_pg_options.get('application_name')})" + ) + if POSTGRES_SSL: _validate_tls_cert_paths([ ("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT), diff --git a/tests/test_celery_plugin_discovery.py b/tests/test_celery_plugin_discovery.py index f001b56f..218ecea5 100644 --- a/tests/test_celery_plugin_discovery.py +++ b/tests/test_celery_plugin_discovery.py @@ -1,5 +1,5 @@ """ -Regression tests for the worker_ready hook in `dispatcharr/celery.py` +Regression tests for the worker_process_init hook in `dispatcharr/celery.py` that eagerly discovers plugins so their @shared_task definitions register with the worker before beat starts firing. @@ -15,63 +15,71 @@ from unittest.mock import MagicMock, patch from django.test import SimpleTestCase -class WorkerReadyPluginDiscoveryTests(SimpleTestCase): +class WorkerProcessInitPluginDiscoveryTests(SimpleTestCase): def test_invokes_discover_plugins_with_sync_db_false(self): """The handler must call PluginManager.discover_plugins(sync_db=False). sync_db=False is intentional: discovery on every worker boot must not touch the DB schema, just import plugin modules so their @shared_task decorators run.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process mock_pm = MagicMock() with patch( "apps.plugins.loader.PluginManager.get", return_value=mock_pm ) as mock_get: - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + init_worker_process() mock_get.assert_called_once() mock_pm.discover_plugins.assert_called_once_with(sync_db=False) + def test_closes_inherited_db_connections_before_discovery(self): + from dispatcharr.celery import init_worker_process + + with patch("django.db.connections.close_all") as mock_close_all: + with patch("apps.plugins.loader.PluginManager.get"): + init_worker_process() + + mock_close_all.assert_called_once() + def test_swallows_plugin_loader_errors(self): """If the plugin loader explodes, the worker must still come up — the handler must not propagate exceptions.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process - with patch( - "apps.plugins.loader.PluginManager.get", - side_effect=RuntimeError("plugin loader exploded"), - ): - # Must NOT raise. - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + with patch( + "apps.plugins.loader.PluginManager.get", + side_effect=RuntimeError("plugin loader exploded"), + ): + # Must NOT raise. + init_worker_process() def test_swallows_discover_plugins_errors(self): """Failure inside discover_plugins itself (e.g. one plugin's plugin.py has an import error) must also be swallowed — one bad plugin shouldn't keep the worker from coming up.""" - from dispatcharr.celery import discover_plugins_on_worker_ready + from dispatcharr.celery import init_worker_process mock_pm = MagicMock() mock_pm.discover_plugins.side_effect = ImportError("bad plugin") - with patch( - "apps.plugins.loader.PluginManager.get", return_value=mock_pm - ): - # Must NOT raise. - discover_plugins_on_worker_ready() + with patch("django.db.connections.close_all"): + with patch( + "apps.plugins.loader.PluginManager.get", return_value=mock_pm + ): + # Must NOT raise. + init_worker_process() - def test_handler_is_connected_to_worker_ready(self): + def test_handler_is_connected_to_worker_process_init(self): """The connect decorator must have wired the handler into the - worker_ready signal so Celery actually fires it at startup.""" - from celery.signals import worker_ready - from dispatcharr.celery import discover_plugins_on_worker_ready + worker_process_init signal so Celery actually fires it after fork.""" + from celery.signals import worker_process_init + from dispatcharr.celery import init_worker_process - # signal.receivers is a list of (lookup_key, weakref_or_func) pairs. - # The handler is registered with weak=False so the function appears - # directly (not as a dead weakref). - receivers = [r for _, r in worker_ready.receivers] - # Dereference weakrefs; pass direct references through as-is. + receivers = [r for _, r in worker_process_init.receivers] callables = [r() if isinstance(r, weakref.ref) else r for r in receivers] - assert discover_plugins_on_worker_ready in receivers or \ - any(getattr(c, "__wrapped__", c) is discover_plugins_on_worker_ready for c in callables), ( - "discover_plugins_on_worker_ready was not connected to " - "Celery's worker_ready signal" + assert init_worker_process in receivers or \ + any(getattr(c, "__wrapped__", c) is init_worker_process for c in callables), ( + "init_worker_process was not connected to " + "Celery's worker_process_init signal" ) diff --git a/tests/test_process_label.py b/tests/test_process_label.py index e4525a85..a919f9e1 100644 --- a/tests/test_process_label.py +++ b/tests/test_process_label.py @@ -2,7 +2,7 @@ from unittest.mock import patch from django.test import SimpleTestCase -from dispatcharr.db.process_label import get_process_role +from dispatcharr.db.process_label import get_process_role, uses_geventpool_database_backend class ProcessLabelTests(SimpleTestCase): @@ -27,3 +27,26 @@ class ProcessLabelTests(SimpleTestCase): with patch.dict("sys.modules", {"uwsgi": fake_uwsgi}): role = get_process_role(["/dispatcharrpy/bin/python", "-c", "pass"]) self.assertEqual(role, "django") + + +class DatabaseBackendSelectionTests(SimpleTestCase): + def test_celery_workers_use_standard_postgres_backend(self): + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "worker", "-Q", "celery"] + )) + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "worker", "-Q", "dvr", "--pool=threads"] + )) + self.assertFalse(uses_geventpool_database_backend( + ["celery", "-A", "dispatcharr", "beat"] + )) + + def test_uwsgi_uses_geventpool_backend(self): + self.assertTrue(uses_geventpool_database_backend( + ["uwsgi", "--ini", "/app/docker/uwsgi.ini"] + )) + + def test_manage_uses_geventpool_backend(self): + self.assertTrue(uses_geventpool_database_backend( + ["manage.py", "migrate"] + )) From 3a81a34e4af369463688208b3fef19cd289d3666 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 2 Jul 2026 13:34:53 -0500 Subject: [PATCH 56/56] fix(epg): Enhance EPG data refresh and parsing logic to prevent data loss and improve concurrency handling. Implement deferred processing for ongoing tasks, ensuring stability during refresh operations. Update changelog with significant fixes and improvements. --- CHANGELOG.md | 3 + apps/epg/tasks.py | 775 +++++++++++------- apps/epg/tests/test_epg_source_file_lock.py | 95 +++ .../tests/test_parse_programs_for_source.py | 36 + .../tests/test_parse_programs_for_tvg_id.py | 128 +++ apps/epg/tests/test_programme_index.py | 49 +- 6 files changed, 752 insertions(+), 334 deletions(-) create mode 100644 apps/epg/tests/test_epg_source_file_lock.py create mode 100644 apps/epg/tests/test_parse_programs_for_tvg_id.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f1664302..704dc58e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Celery workers no longer use django-db-geventpool (fixes Postgres protocol errors under concurrent tasks).** Celery's default queue runs prefork (`--autoscale=6,1`), not gevent, but it was sharing the same `django-db-geventpool` backend as uWSGI. That pool is a process-wide singleton of warm connections; `fork()` (including autoscale spawning new children on demand) duplicated those sockets across processes and corrupted Postgres session state (`the last operation didn't produce records (command status: SET/BEGIN/ROLLBACK)`, `connection ... in transaction status INTRANS`, and matching server-side `there is already a transaction in progress` on one backend). Celery workers and beat now use Django's standard PostgreSQL backend (`CONN_MAX_AGE=0`, no warm pool); uWSGI keeps geventpool. Plugin discovery moved from `worker_ready` (arbiter) to `worker_process_init` (each child after `connections.close_all()`), so the prefork parent no longer opens DB handles that children would inherit. - **EPG channel parsing progress no longer exceeds 100%.** During `parsing_channels`, progress used the pre-parse database channel count as the denominator while the numerator counted channels found in the XML. When the guide grew (or on sources where the file had more channels than the DB), the UI could show values well above 100% (e.g. ~300%). The estimate now bumps when the XML exceeds the DB baseline, progress is capped at 98% until final cleanup, and update frequency adapts to the known total (~20 ticks on steady-state refreshes; coarse every-100-channel updates for first import or when the file outgrows the estimate). A final in-loop update reports the true parsed count before completion. +- **EPG refresh and per-channel parses no longer race.** Full-source refresh holds an `epg_source_file` lock through download, channel parse, and bulk programme swap; programme index builds acquire the same lock separately (after refresh releases it). Per-channel `parse_programs_for_tvg_id` tasks run concurrently for different tvg-ids (no file lock between them) but defer while a source refresh is in progress. Refresh waits until in-flight per-channel parses finish (Redis counter) before downloading. Channels matched mid-refresh are excluded from the bulk-parse snapshot but receive a follow-up per-channel parse when refresh finishes; orphan cleanup at swap time uses live channel assignments so mid-refresh matches are not wiped. Duplicate tasks for the same `epg_id` are deduped via `parse_epg_programs` lock with a log noting how many channels map to that EPG. Busy file/index deferrals retry for up to two hours (15s interval). +- **Per-channel EPG parse no longer wipes guide data on failure.** `parse_programs_for_tvg_id` used to delete existing programmes before streaming the XML and flush new rows in batches as it parsed, so any failure partway through left a mapped channel with no guide data. It also re-fetched the `EPGData` row mid-task instead of reusing the instance loaded at task start. The task now reuses the initial row and replaces programmes in one transaction at the end (delete old, insert new), so a parse failure leaves the previous guide intact. - **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run). - **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh. - **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1`→`\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index ecf05694..612e9804 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -28,6 +28,7 @@ from channels.layers import get_channel_layer from .models import EPGSource, EPGSourceIndex, EPGData, ProgramData, SDScheduleMD5, SDProgramMD5 from core.utils import ( + RedisClient, acquire_task_lock, is_task_lock_held, release_task_lock, @@ -39,6 +40,58 @@ from core.utils import ( logger = logging.getLogger(__name__) +_EPG_SOURCE_FILE_LOCK = 'epg_source_file' +_EPG_REFRESH_DEFER_SECONDS = 15 +_EPG_TVG_PARSE_DEFER_SECONDS = 5 +_EPG_PARSE_DEFER_MAX = 480 # 15s x 480 = 2h for refresh; 5s x 480 = 40m for index build + + +def _source_tvg_parse_count_key(source_id): + return f'epg_source_tvg_parse_{source_id}' + + +def _incr_source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if client: + client.incr(_source_tvg_parse_count_key(source_id)) + + +def _decr_source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if not client: + return + key = _source_tvg_parse_count_key(source_id) + value = client.decr(key) + if value is not None and value <= 0: + client.delete(key) + + +def _source_tvg_parse_count(source_id): + client = RedisClient.get_client() + if not client: + return 0 + return int(client.get(_source_tvg_parse_count_key(source_id)) or 0) + + +def _defer_refresh_epg_data(source, force, file_defer_retry, reason): + if file_defer_retry >= _EPG_PARSE_DEFER_MAX: + msg = f"EPG refresh blocked for too long ({reason}); retry later" + logger.error(f"Cannot refresh {source.name}: {msg}") + source.status = EPGSource.STATUS_ERROR + source.last_message = msg + source.save(update_fields=['status', 'last_message']) + return True + logger.info( + f"Deferring refresh for {source.name} for {_EPG_REFRESH_DEFER_SECONDS}s: {reason}" + ) + refresh_epg_data.apply_async( + args=[source.id], + kwargs={'force': force, '_file_defer_retry': file_defer_retry + 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + return True + + _NON_TERMINAL_REFRESH_STATUSES = frozenset({ EPGSource.STATUS_FETCHING, EPGSource.STATUS_PARSING, @@ -58,9 +111,9 @@ def _db_query_with_retry(fn, *, label="DB query", max_retries=2): Poisoned Celery worker connections often surface as OperationalError or as ``IndexError: list index out of range`` inside Django's row converters. """ - from django.db import InterfaceError, OperationalError + from django.db import DatabaseError, InterfaceError, OperationalError - transient_errors = (OperationalError, InterfaceError, IndexError) + transient_errors = (OperationalError, InterfaceError, IndexError, DatabaseError) for attempt in range(max_retries): try: return fn() @@ -589,7 +642,7 @@ def refresh_all_epg_data(): @shared_task(time_limit=14400) -def refresh_epg_data(source_id, force=False): +def refresh_epg_data(source_id, force=False, _file_defer_retry=0): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return @@ -600,7 +653,9 @@ def refresh_epg_data(source_id, force=False): _release_task_db_connection() try: - return _refresh_epg_data_impl(source_id, force=force) + return _refresh_epg_data_impl( + source_id, force=force, _file_defer_retry=_file_defer_retry + ) except Exception as e: logger.error( f"Error in refresh_epg_data for source {source_id}: {e}", @@ -621,7 +676,7 @@ def refresh_epg_data(source_id, force=False): release_task_lock('refresh_epg_data', source_id) -def _refresh_epg_data_impl(source_id, force=False): +def _refresh_epg_data_impl(source_id, force=False, _file_defer_retry=0): try: source = _get_epg_source(source_id) except EPGSource.DoesNotExist: @@ -656,20 +711,39 @@ def _refresh_epg_data_impl(source_id, force=False): EPGSourceIndex.objects.update_or_create( source_id=source.id, defaults={'data': None} ) - if not fetch_xmltv(source): - logger.error(f"Failed to fetch XMLTV for source {source.name}") + + if _source_tvg_parse_count(source.id) > 0: + _defer_refresh_epg_data( + source, force, _file_defer_retry, 'per-channel program parses in progress' + ) return - if not parse_channels_only(source): - logger.error(f"Failed to parse channels for source {source.name}") + if not acquire_task_lock(_EPG_SOURCE_FILE_LOCK, source.id): + _defer_refresh_epg_data( + source, force, _file_defer_retry, 'EPG file in use (index build)' + ) return - # Build byte-offset index after programme data is committed so refresh - # does not compete for memory/IO during the programme swap. - if not parse_programs_for_source(source): - logger.error(f"Failed to parse programs for source {source.name}") - return + file_lock_renewer = TaskLockRenewer(_EPG_SOURCE_FILE_LOCK, source.id) + file_lock_renewer.start() + try: + if not fetch_xmltv(source): + logger.error(f"Failed to fetch XMLTV for source {source.name}") + return + if not parse_channels_only(source): + logger.error(f"Failed to parse channels for source {source.name}") + return + + if not parse_programs_for_source(source): + logger.error(f"Failed to parse programs for source {source.name}") + return + finally: + file_lock_renewer.stop() + release_task_lock(_EPG_SOURCE_FILE_LOCK, source.id) + + # Index build runs after the file lock is released so it does not + # compete with download/parse for the same XML on disk. build_programme_index_task.delay(source.id) elif source.source_type == 'schedules_direct': @@ -1666,305 +1740,335 @@ def parse_channels_only(source): pass +def _defer_parse_programs_for_tvg_id(epg_id, force, defer_retry, reason): + if defer_retry >= _EPG_PARSE_DEFER_MAX: + logger.warning( + f"Giving up on parse_programs_for_tvg_id({epg_id}) after " + f"{defer_retry} deferrals: {reason}" + ) + return f"Deferred too many times: {reason}" + + logger.info( + f"Deferring parse_programs_for_tvg_id({epg_id}) for " + f"{_EPG_REFRESH_DEFER_SECONDS}s: {reason}" + ) + parse_programs_for_tvg_id.apply_async( + args=[epg_id], + kwargs={'force': force, '_defer_retry': defer_retry + 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + return "Deferred" + @shared_task(time_limit=3600, soft_time_limit=3500) -def parse_programs_for_tvg_id(epg_id, force=False): +def parse_programs_for_tvg_id(epg_id, force=False, _defer_retry=0): + epg_obj = None try: - from apps.epg.models import EPGData epg_obj = EPGData.objects.select_related('epg_source').filter(id=epg_id).first() if epg_obj and epg_obj.epg_source and epg_obj.epg_source.source_type == 'schedules_direct': return fetch_sd_guide_for_epg(epg_id, force=force) except Exception as e: logger.warning(f"Could not check EPG source type for id={epg_id}: {e}") - if not acquire_task_lock('parse_epg_programs', epg_id): - logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") - return "Task already running" + if not epg_obj or not epg_obj.epg_source: + return "EPG not found" - lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) - lock_renewer.start() + epg_source = epg_obj.epg_source + if epg_source.source_type == 'dummy': + return - source_file = None - program_parser = None - programs_to_create = [] - programs_processed = 0 + source_id = epg_source.id + if is_task_lock_held('refresh_epg_data', source_id): + return _defer_parse_programs_for_tvg_id( + epg_id, force, _defer_retry, 'source refresh in progress' + ) + + _incr_source_tvg_parse_count(source_id) try: - # Add memory tracking only in trace mode or higher + if not acquire_task_lock('parse_epg_programs', epg_id): + mapped_channels = Channel.objects.filter(epg_data_id=epg_id).count() + logger.info( + f"Program parse for epg_id={epg_id} ({epg_obj.tvg_id}) already in progress, " + f"skipping duplicate task ({mapped_channels} channel(s) mapped to this EPG)" + ) + return "Task already running" + + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + + source_file = None + program_parser = None + programs_to_create = [] + programs_processed = 0 try: - process = None - # Get current log level as a number - current_log_level = logger.getEffectiveLevel() + # Add memory tracking only in trace mode or higher + try: + process = None + # Get current log level as a number + current_log_level = logger.getEffectiveLevel() + + # Only track memory usage when log level is TRACE or more verbose or if running in DEBUG mode + should_log_memory = current_log_level <= 5 or settings.DEBUG + + if should_log_memory: + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Initial memory usage: {initial_memory:.2f} MB") + mem_before = initial_memory + except ImportError: + process = None + should_log_memory = False + + # Reuse the EPGData fetched at the top of the task (epg_source is already + # cached via select_related) instead of issuing a second query mid-task. + epg = epg_obj - # Only track memory usage when log level is TRACE or more verbose or if running in DEBUG mode - should_log_memory = current_log_level <= 5 or settings.DEBUG - - if should_log_memory: - process = psutil.Process() - initial_memory = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Initial memory usage: {initial_memory:.2f} MB") - mem_before = initial_memory - except ImportError: - process = None - should_log_memory = False - - epg = EPGData.objects.get(id=epg_id) - epg_source = epg.epg_source - - # Skip program parsing for dummy EPG sources - they don't have program data files - if epg_source.source_type == 'dummy': - logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - if not force and not Channel.objects.filter(epg_data=epg).exists(): - logger.info(f"No channels matched to EPG {epg.tvg_id}") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - - # Optimize deletion with a single delete query instead of chunking - # This is faster for most database engines - ProgramData.objects.filter(epg=epg).delete() - - file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path - if not file_path: - file_path = epg_source.get_cache_file() - - # Check if the file exists - if not os.path.exists(file_path): - logger.error(f"EPG file not found at: {file_path}") - - if epg_source.url: - # Update the file path in the database - new_path = epg_source.get_cache_file() - logger.info(f"Updating file_path from '{file_path}' to '{new_path}'") - epg_source.file_path = new_path - epg_source.save(update_fields=['file_path']) - logger.info(f"Fetching new EPG data from URL: {epg_source.url}") - else: - logger.info(f"EPG source does not have a URL, using existing file path: {file_path} to rebuild cache") - - # Fetch new data before continuing - if epg_source: - - # Properly check the return value from fetch_xmltv - fetch_success = fetch_xmltv(epg_source) - - # If fetch was not successful or the file still doesn't exist, abort - if not fetch_success: - logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") - # Update status to error if not already set - epg_source.status = 'error' - epg_source.last_message = f"Failed to download EPG data, cannot parse programs" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - # Also check if the file exists after download - if not os.path.exists(epg_source.file_path): - logger.error(f"Failed to fetch EPG data, file still missing at: {epg_source.file_path}") - epg_source.status = 'error' - epg_source.last_message = f"Failed to download EPG data, file missing after download" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) - return - - # Update file_path with the new location - if epg_source.extracted_file_path: - file_path = epg_source.extracted_file_path - else: - file_path = epg_source.file_path - else: - logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") - # Update status to error - epg_source.status = 'error' - epg_source.last_message = f"No URL provided, cannot fetch EPG data" - epg_source.save(update_fields=['status', 'last_message']) - send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) + if not force and not Channel.objects.filter(epg_data=epg).exists(): + logger.info(f"No channels matched to EPG {epg.tvg_id}") return - # Use streaming parsing to reduce memory usage - # No need to check file type anymore since it's always XML - logger.debug(f"Parsing programs for tvg_id={epg.tvg_id} from {file_path}") + logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - # Memory usage tracking - if process: - try: - mem_before = process.memory_info().rss / 1024 / 1024 - logger.debug(f"[parse_programs_for_tvg_id] Memory before parsing {epg.tvg_id} - {mem_before:.2f} MB") - except Exception as e: - logger.warning(f"Error tracking memory: {e}") - mem_before = 0 - - programs_to_create = [] - batch_size = 1000 # Process in batches to limit memory usage - - try: - # Open the file directly - no need to check compression - logger.debug(f"Opening file for parsing: {file_path}") - source_file = _open_xmltv_file(file_path) - - # Stream parse the file using lxml's iterparse - program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True) - - for _, elem in program_parser: - if elem.get('channel') == epg.tvg_id: - try: - start_time = parse_xmltv_time(elem.get('start')) - end_time = parse_xmltv_time(elem.get('stop')) - title = None - desc = None - sub_title = None - - # Efficiently process child elements - for child in elem: - if child.tag == 'title': - title = child.text or 'No Title' - elif child.tag == 'desc': - desc = child.text or '' - elif child.tag == 'sub-title': - sub_title = child.text or '' - - if not title: - title = 'No Title' - - # Extract custom properties - custom_props = extract_custom_properties(elem) - custom_properties_json = None - - if custom_props: - logger.trace(f"Number of custom properties: {len(custom_props)}") - custom_properties_json = custom_props - - # Fallback: extract S/E from description when episode-num - # elements didn't provide them - if desc: - has_season = (custom_properties_json or {}).get('season') is not None - has_episode = (custom_properties_json or {}).get('episode') is not None - if not has_season or not has_episode: - d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) - if d_season is not None and d_episode is not None: - if custom_properties_json is None: - custom_properties_json = {} - if not has_season: - custom_properties_json['season'] = d_season - if not has_episode: - custom_properties_json['episode'] = d_episode - custom_properties_json['season_episode_source'] = 'description' - desc = cleaned_desc - - programs_to_create.append(ProgramData( - epg=epg, - start_time=start_time, - end_time=end_time, - title=title[:255], - description=desc, - sub_title=sub_title, - tvg_id=epg.tvg_id, - custom_properties=custom_properties_json - )) - programs_processed += 1 - # Clear the element to free memory - clear_element(elem) - # Batch processing - if len(programs_to_create) >= batch_size: - ProgramData.objects.bulk_create(programs_to_create) - logger.debug(f"Saved batch of {len(programs_to_create)} programs for {epg.tvg_id}") - programs_to_create = [] - # Only call gc.collect() every few batches - if programs_processed % (batch_size * 5) == 0: - gc.collect() - - except Exception as e: - logger.error(f"Error processing program for {epg.tvg_id}: {e}", exc_info=True) + file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path + if not file_path: + file_path = epg_source.get_cache_file() + + # Check if the file exists + if not os.path.exists(file_path): + logger.error(f"EPG file not found at: {file_path}") + + if epg_source.url: + # Update the file path in the database + new_path = epg_source.get_cache_file() + logger.info(f"Updating file_path from '{file_path}' to '{new_path}'") + epg_source.file_path = new_path + epg_source.save(update_fields=['file_path']) + logger.info(f"Fetching new EPG data from URL: {epg_source.url}") else: - # Immediately clean up non-matching elements to reduce memory pressure - if elem is not None: - clear_element(elem) - continue - - # Make sure to close the file and release parser resources - if source_file: - source_file.close() - source_file = None - - if program_parser: - program_parser = None - - gc.collect() - - except zipfile.BadZipFile as zip_error: - logger.error(f"Bad ZIP file: {zip_error}") - raise - except etree.XMLSyntaxError as xml_error: - logger.error(f"XML syntax error parsing program data: {xml_error}") - raise - except Exception as e: - logger.error(f"Error parsing XML for programs: {e}", exc_info=True) - raise - finally: - # Ensure file is closed even if an exception occurs - if source_file: - source_file.close() - source_file = None - # Memory tracking after processing + logger.info(f"EPG source does not have a URL, using existing file path: {file_path} to rebuild cache") + + # Fetch new data before continuing + if epg_source: + + # Properly check the return value from fetch_xmltv + fetch_success = fetch_xmltv(epg_source) + + # If fetch was not successful or the file still doesn't exist, abort + if not fetch_success: + logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") + # Update status to error if not already set + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, cannot parse programs" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + return + + # Also check if the file exists after download + if not os.path.exists(epg_source.file_path): + logger.error(f"Failed to fetch EPG data, file still missing at: {epg_source.file_path}") + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, file missing after download" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + return + + # Update file_path with the new location + if epg_source.extracted_file_path: + file_path = epg_source.extracted_file_path + else: + file_path = epg_source.file_path + else: + logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") + # Update status to error + epg_source.status = 'error' + epg_source.last_message = f"No URL provided, cannot fetch EPG data" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + return + + # Use streaming parsing to reduce memory usage + # No need to check file type anymore since it's always XML + logger.debug(f"Parsing programs for tvg_id={epg.tvg_id} from {file_path}") + + # Memory usage tracking if process: try: - mem_after = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Memory after parsing 1 {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + mem_before = process.memory_info().rss / 1024 / 1024 + logger.debug(f"[parse_programs_for_tvg_id] Memory before parsing {epg.tvg_id} - {mem_before:.2f} MB") except Exception as e: logger.warning(f"Error tracking memory: {e}") - - # Process any remaining items - if programs_to_create: - ProgramData.objects.bulk_create(programs_to_create) - logger.debug(f"Saved final batch of {len(programs_to_create)} programs for {epg.tvg_id}") + mem_before = 0 + + # Accumulate all of this channel's programmes in memory and swap them in + # atomically at the end (delete-old + insert-new in one transaction), so a + # mid-parse failure leaves the previous guide data intact instead of the + # channel ending up with nothing. Per-channel volume is small enough + # (weeks of one channel's schedule) that this doesn't need batched flushing + # to the DB the way the whole-source bulk parse does. + programs_to_create = [] + + try: + # Open the file directly - no need to check compression + logger.debug(f"Opening file for parsing: {file_path}") + source_file = _open_xmltv_file(file_path) + + # Stream parse the file using lxml's iterparse + program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True) + + for _, elem in program_parser: + if elem.get('channel') == epg.tvg_id: + try: + start_time = parse_xmltv_time(elem.get('start')) + end_time = parse_xmltv_time(elem.get('stop')) + title = None + desc = None + sub_title = None + + # Efficiently process child elements + for child in elem: + if child.tag == 'title': + title = child.text or 'No Title' + elif child.tag == 'desc': + desc = child.text or '' + elif child.tag == 'sub-title': + sub_title = child.text or '' + + if not title: + title = 'No Title' + + # Extract custom properties + custom_props = extract_custom_properties(elem) + custom_properties_json = None + + if custom_props: + logger.trace(f"Number of custom properties: {len(custom_props)}") + custom_properties_json = custom_props + + # Fallback: extract S/E from description when episode-num + # elements didn't provide them + if desc: + has_season = (custom_properties_json or {}).get('season') is not None + has_episode = (custom_properties_json or {}).get('episode') is not None + if not has_season or not has_episode: + d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) + if d_season is not None and d_episode is not None: + if custom_properties_json is None: + custom_properties_json = {} + if not has_season: + custom_properties_json['season'] = d_season + if not has_episode: + custom_properties_json['episode'] = d_episode + custom_properties_json['season_episode_source'] = 'description' + desc = cleaned_desc + + programs_to_create.append(ProgramData( + epg=epg, + start_time=start_time, + end_time=end_time, + title=title[:255], + description=desc, + sub_title=sub_title, + tvg_id=epg.tvg_id, + custom_properties=custom_properties_json + )) + programs_processed += 1 + # Clear the element to free memory + clear_element(elem) + if programs_processed % 5000 == 0: + gc.collect() + + except Exception as e: + logger.error(f"Error processing program for {epg.tvg_id}: {e}", exc_info=True) + else: + # Immediately clean up non-matching elements to reduce memory pressure + if elem is not None: + clear_element(elem) + continue + + # Make sure to close the file and release parser resources + if source_file: + source_file.close() + source_file = None + + if program_parser: + program_parser = None + + gc.collect() + + except zipfile.BadZipFile as zip_error: + logger.error(f"Bad ZIP file: {zip_error}") + raise + except etree.XMLSyntaxError as xml_error: + logger.error(f"XML syntax error parsing program data: {xml_error}") + raise + except Exception as e: + logger.error(f"Error parsing XML for programs: {e}", exc_info=True) + raise + finally: + # Ensure file is closed even if an exception occurs + if source_file: + source_file.close() + source_file = None + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Memory after parsing 1 {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + + # Swap old programmes for the newly parsed ones atomically. If the insert + # fails (including a poisoned-connection blip), the transaction rolls back + # and the previous guide data for this channel is left untouched. + with transaction.atomic(): + deleted_count = ProgramData.objects.filter(epg=epg).delete()[0] + if programs_to_create: + for i in range(0, len(programs_to_create), _EPG_SWAP_BATCH_SIZE): + ProgramData.objects.bulk_create( + programs_to_create[i:i + _EPG_SWAP_BATCH_SIZE] + ) + logger.debug( + f"Replaced {deleted_count} program(s) with {len(programs_to_create)} " + f"for {epg.tvg_id}" + ) programs_to_create = None custom_props = None custom_properties_json = None - - logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") - finally: - # Reset internal caches and pools that lxml might be keeping - try: - etree.clear_error_log() - except: - pass - # Explicit cleanup of all potentially large objects - if source_file: + logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") + finally: + # Reset internal caches and pools that lxml might be keeping try: - source_file.close() + etree.clear_error_log() except: pass - source_file = None - program_parser = None - programs_to_create = None - - epg_source = None - # Add comprehensive cleanup before releasing lock - cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing - if process: - try: - mem_after = process.memory_info().rss / 1024 / 1024 - logger.info(f"[parse_programs_for_tvg_id] Final memory usage {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") - except Exception as e: - logger.warning(f"Error tracking memory: {e}") - process = None - epg = None - programs_processed = None - lock_renewer.stop() - release_task_lock('parse_epg_programs', epg_id) + # Explicit cleanup of all potentially large objects + if source_file: + try: + source_file.close() + except: + pass + source_file = None + program_parser = None + programs_to_create = None + + epg_source = None + # Add comprehensive cleanup before releasing lock + cleanup_memory(log_usage=should_log_memory, force_collection=True) + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Final memory usage {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + process = None + epg = None + programs_processed = None + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + finally: + _decr_source_tvg_parse_count(source_id) _EPG_PROGRAM_STAGING_TABLE = 'epg_program_staging' @@ -2043,6 +2147,57 @@ def _flush_epg_program_staging_batch(programs_batch): ) +def _epg_ids_mapped_to_channels(epg_source): + """EPGData ids currently assigned to at least one channel on this source.""" + return set( + Channel.objects.filter( + epg_data__epg_source=epg_source, + epg_data__isnull=False, + ).values_list('epg_data_id', flat=True) + ) + + +def _delete_orphaned_epg_programs(epg_source): + """ + Remove programme rows for EPG entries that have no channel mapped now. + + Uses live channel assignments, not the snapshot taken at bulk-parse start, + so channels matched mid-refresh are not treated as orphaned. + """ + currently_mapped = _epg_ids_mapped_to_channels(epg_source) + unmapped_epg_ids = list( + EPGData.objects.filter(epg_source=epg_source) + .exclude(id__in=currently_mapped) + .values_list('id', flat=True) + ) + if not unmapped_epg_ids: + return 0 + orphaned_count = ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete()[0] + if orphaned_count > 0: + logger.info( + f"Cleaned up {orphaned_count} orphaned programs for " + f"{len(unmapped_epg_ids)} unmapped EPG entries" + ) + return orphaned_count + + +def _dispatch_late_mapped_epg_parses(epg_source, bulk_parsed_epg_ids): + """ + Queue per-channel parses for EPG rows mapped after bulk parse began. + + Per-channel tasks triggered during refresh defer until refresh finishes; + this ensures they still run promptly even if a deferred retry is pending. + """ + missed_epg_ids = _epg_ids_mapped_to_channels(epg_source) - bulk_parsed_epg_ids + if not missed_epg_ids: + return 0 + logger.info( + f"Queueing per-channel program parse for {len(missed_epg_ids)} EPG row(s) " + f"mapped during bulk refresh of {epg_source.name}" + ) + return dispatch_program_refresh_for_epg_ids(missed_epg_ids) + + def _swap_staged_epg_programs(mapped_epg_ids, epg_source, batch_size=_EPG_SWAP_BATCH_SIZE): """ Atomically replace mapped programme rows with staged data. @@ -2057,18 +2212,7 @@ def _swap_staged_epg_programs(mapped_epg_ids, epg_source, batch_size=_EPG_SWAP_B deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0] logger.debug(f"Deleted {deleted_count} existing programs") - unmapped_epg_ids = list( - EPGData.objects.filter(epg_source=epg_source) - .exclude(id__in=mapped_epg_ids) - .values_list('id', flat=True) - ) - if unmapped_epg_ids: - orphaned_count = ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete()[0] - if orphaned_count > 0: - logger.info( - f"Cleaned up {orphaned_count} orphaned programs for " - f"{len(unmapped_epg_ids)} unmapped EPG entries" - ) + _delete_orphaned_epg_programs(epg_source) if not _epg_program_staging_supported(): raise RuntimeError('_swap_staged_epg_programs requires PostgreSQL staging support') @@ -2113,13 +2257,7 @@ def _swap_parsed_epg_programs(mapped_epg_ids, epg_source, programs_to_create, ba """SQLite/dev fallback: atomic delete + bulk insert from an in-memory batch list.""" with transaction.atomic(): deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0] - unmapped_epg_ids = list( - EPGData.objects.filter(epg_source=epg_source) - .exclude(id__in=mapped_epg_ids) - .values_list('id', flat=True) - ) - if unmapped_epg_ids: - ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete() + _delete_orphaned_epg_programs(epg_source) for i in range(0, len(programs_to_create), batch_size): ProgramData.objects.bulk_create(programs_to_create[i:i + batch_size]) return deleted_count @@ -2446,6 +2584,7 @@ def parse_programs_for_source(epg_source, tvg_id=None): logger.info(f"Completed parsing programs for source: {epg_source.name} - " f"{total_programs:,} programs for {channels_with_programs} channels, " f"skipped {skipped_programs:,} programs for unmapped channels") + _dispatch_late_mapped_epg_parses(epg_source, mapped_epg_ids) return True except Exception as e: @@ -4647,18 +4786,32 @@ def build_programme_index(source_id): @shared_task -def build_programme_index_task(source_id): - """Celery wrapper. Locks so refresh and preview don't both build the same source. Releases on finish rather than waiting out the TTL.""" - from core.utils import RedisClient - - redis_client = RedisClient.get_client() - lock_key = f'building_programme_index_{source_id}' - if not redis_client.set(lock_key, '1', nx=True, ex=300): +def build_programme_index_task(source_id, _defer_retry=0): + """Celery wrapper. Serializes index builds with other EPG file access on this source.""" + if not acquire_task_lock(_EPG_SOURCE_FILE_LOCK, source_id): + if _defer_retry >= _EPG_PARSE_DEFER_MAX: + logger.warning( + f"Giving up on programme index build for source {source_id} " + f"after {_defer_retry} deferrals: file busy" + ) + return + logger.debug( + f"EPG source file busy, deferring programme index build for source {source_id}" + ) + build_programme_index_task.apply_async( + args=[source_id], + kwargs={'_defer_retry': _defer_retry + 1}, + countdown=_EPG_TVG_PARSE_DEFER_SECONDS, + ) return + + lock_renewer = TaskLockRenewer(_EPG_SOURCE_FILE_LOCK, source_id) + lock_renewer.start() try: build_programme_index(source_id) finally: - redis_client.delete(lock_key) + lock_renewer.stop() + release_task_lock(_EPG_SOURCE_FILE_LOCK, source_id) def find_current_program_for_tvg_id(epg_or_id): diff --git a/apps/epg/tests/test_epg_source_file_lock.py b/apps/epg/tests/test_epg_source_file_lock.py new file mode 100644 index 00000000..9665e4ed --- /dev/null +++ b/apps/epg/tests/test_epg_source_file_lock.py @@ -0,0 +1,95 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase, TestCase + +from apps.epg.models import EPGSource, EPGData +from apps.epg.tasks import ( + _EPG_PARSE_DEFER_MAX, + _EPG_REFRESH_DEFER_SECONDS, + _defer_parse_programs_for_tvg_id, + _refresh_epg_data_impl, + parse_programs_for_tvg_id, +) + + +class DeferParseProgramsTests(SimpleTestCase): + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + def test_defer_schedules_retry_for_refresh(self, mock_apply_async): + result = _defer_parse_programs_for_tvg_id(5, False, 0, "source refresh in progress") + + self.assertEqual(result, "Deferred") + mock_apply_async.assert_called_once_with( + args=[5], + kwargs={"force": False, "_defer_retry": 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) + + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + def test_defer_gives_up_after_max_retries(self, mock_apply_async): + result = _defer_parse_programs_for_tvg_id( + 5, False, _EPG_PARSE_DEFER_MAX, "source refresh in progress" + ) + + self.assertIn("Deferred too many times", result) + mock_apply_async.assert_not_called() + + +class ParseProgramsForTvgIdLockTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name="Lock Test", + source_type="xmltv", + file_path="/tmp/unused.xml", + ) + self.epg = EPGData.objects.create( + tvg_id="test.channel", + name="Test", + epg_source=self.source, + ) + + @patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async") + @patch("apps.epg.tasks.is_task_lock_held", return_value=True) + def test_defers_while_source_refresh_running(self, mock_held, mock_apply_async): + result = parse_programs_for_tvg_id(self.epg.id) + + self.assertEqual(result, "Deferred") + mock_held.assert_called_once_with("refresh_epg_data", self.source.id) + mock_apply_async.assert_called_once() + + @patch("apps.epg.tasks._decr_source_tvg_parse_count") + @patch("apps.epg.tasks._incr_source_tvg_parse_count") + @patch("apps.epg.tasks.acquire_task_lock", return_value=False) + @patch("apps.epg.tasks.is_task_lock_held", return_value=False) + def test_skips_when_duplicate_epg_parse_running( + self, mock_held, mock_acquire, mock_incr, mock_decr + ): + result = parse_programs_for_tvg_id(self.epg.id) + + self.assertEqual(result, "Task already running") + mock_acquire.assert_called_once_with("parse_epg_programs", self.epg.id) + mock_incr.assert_called_once_with(self.source.id) + mock_decr.assert_called_once_with(self.source.id) + + +class RefreshEpgDataDeferTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name="Refresh Defer Test", + source_type="xmltv", + file_path="/tmp/unused.xml", + ) + + @patch("apps.epg.tasks.refresh_epg_data.apply_async") + @patch("apps.epg.tasks.fetch_xmltv") + @patch("apps.epg.tasks._source_tvg_parse_count", return_value=1) + def test_refresh_defers_while_per_channel_parses_running( + self, mock_count, mock_fetch, mock_apply_async + ): + _refresh_epg_data_impl(self.source.id) + + mock_fetch.assert_not_called() + mock_apply_async.assert_called_once_with( + args=[self.source.id], + kwargs={"force": False, "_file_defer_retry": 1}, + countdown=_EPG_REFRESH_DEFER_SECONDS, + ) diff --git a/apps/epg/tests/test_parse_programs_for_source.py b/apps/epg/tests/test_parse_programs_for_source.py index f49a5d70..cfddc76c 100644 --- a/apps/epg/tests/test_parse_programs_for_source.py +++ b/apps/epg/tests/test_parse_programs_for_source.py @@ -13,6 +13,8 @@ from apps.epg.tasks import ( parse_programs_for_source, _flush_epg_program_staging_batch, _swap_staged_epg_programs, + _delete_orphaned_epg_programs, + _dispatch_late_mapped_epg_parses, _EPG_PARSE_BATCH_SIZE, ) @@ -236,3 +238,37 @@ class ParseProgramsForSourceTests(TestCase): self.assertFalse(result) self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'Keep Me') + + def test_orphan_cleanup_respects_channels_mapped_during_bulk_parse(self): + late_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.unmapped_epg, + start_time=late_start, + end_time=late_start + timedelta(hours=1), + title='Late Match Programme', + tvg_id=self.unmapped_epg.tvg_id, + ) + Channel.objects.create( + channel_number=2, + name='Late Mapped Channel', + epg_data=self.unmapped_epg, + ) + + deleted = _delete_orphaned_epg_programs(self.source) + + self.assertEqual(deleted, 0) + self.assertEqual(ProgramData.objects.filter(epg=self.unmapped_epg).count(), 1) + + @patch('apps.epg.tasks.dispatch_program_refresh_for_epg_ids', return_value=1) + def test_late_mapped_dispatches_per_channel_parse(self, mock_dispatch): + Channel.objects.create( + channel_number=2, + name='Late Mapped Channel', + epg_data=self.unmapped_epg, + ) + bulk_snapshot = {self.mapped_epg.id} + + dispatched = _dispatch_late_mapped_epg_parses(self.source, bulk_snapshot) + + self.assertEqual(dispatched, 1) + mock_dispatch.assert_called_once_with({self.unmapped_epg.id}) diff --git a/apps/epg/tests/test_parse_programs_for_tvg_id.py b/apps/epg/tests/test_parse_programs_for_tvg_id.py new file mode 100644 index 00000000..e17c2d49 --- /dev/null +++ b/apps/epg/tests/test_parse_programs_for_tvg_id.py @@ -0,0 +1,128 @@ +import os +import tempfile +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel +from apps.epg.models import EPGSource, EPGData, ProgramData +from apps.epg.tasks import parse_programs_for_tvg_id + + +def _programme_xml(channel_id, title, start, stop): + return ( + f' \n' + f' {title}\n' + f' \n' + ) + + +def _xmltv_file(programmes): + body = ( + '\n' + '\n' + f'{programmes}' + '\n' + ) + handle = tempfile.NamedTemporaryFile( + mode='w', + suffix='.xml', + delete=False, + encoding='utf-8', + ) + handle.write(body) + handle.close() + return handle.name + + +class ParseProgramsForTvgIdSwapTests(TestCase): + def setUp(self): + self.source = EPGSource.objects.create( + name='Per-Channel Parse Test', + source_type='xmltv', + ) + self.epg = EPGData.objects.create( + epg_source=self.source, + tvg_id='test.channel', + name='Test Channel', + ) + self.channel = Channel.objects.create( + channel_number=1, + name='Test Channel', + epg_data=self.epg, + ) + self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0) + self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000') + self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000') + + def tearDown(self): + if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path): + os.unlink(self.xml_path) + + def _configure_source_file(self, programmes): + self.xml_path = _xmltv_file(programmes) + self.source.file_path = self.xml_path + self.source.save(update_fields=['file_path']) + + def test_replaces_programs_for_channel(self): + old_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.epg, + start_time=old_start, + end_time=old_start + timedelta(hours=1), + title='Old Programme', + tvg_id=self.epg.tvg_id, + ) + self._configure_source_file( + _programme_xml('test.channel', 'New Show', self.start, self.stop) + ) + + parse_programs_for_tvg_id(self.epg.id) + + programs = ProgramData.objects.filter(epg=self.epg) + self.assertEqual(programs.count(), 1) + self.assertEqual(programs.get().title, 'New Show') + + def test_failed_insert_preserves_existing_programs(self): + """A failed atomic swap must not leave the channel with no guide data.""" + old_start = self.base_time - timedelta(days=1) + ProgramData.objects.create( + epg=self.epg, + start_time=old_start, + end_time=old_start + timedelta(hours=1), + title='Keep Me', + tvg_id=self.epg.tvg_id, + ) + self._configure_source_file( + _programme_xml('test.channel', 'Replacement', self.start, self.stop) + ) + + with patch( + 'apps.epg.tasks.ProgramData.objects.bulk_create', + side_effect=RuntimeError('simulated insert failure'), + ): + with self.assertRaises(RuntimeError): + parse_programs_for_tvg_id(self.epg.id) + + remaining = ProgramData.objects.filter(epg=self.epg) + self.assertEqual(remaining.count(), 1) + self.assertEqual(remaining.get().title, 'Keep Me') + + def test_does_not_refetch_epg_data_mid_task(self): + """The task must reuse the EPGData row loaded at task start.""" + self._configure_source_file( + _programme_xml('test.channel', 'New Show', self.start, self.stop) + ) + + with patch( + 'apps.epg.tasks.EPGData.objects.get', + side_effect=AssertionError('should not re-fetch EPGData mid-task'), + ) as mock_get: + parse_programs_for_tvg_id(self.epg.id) + + mock_get.assert_not_called() + self.assertEqual( + ProgramData.objects.filter(epg=self.epg).get().title, 'New Show' + ) diff --git a/apps/epg/tests/test_programme_index.py b/apps/epg/tests/test_programme_index.py index 829afc87..b9c42bf9 100644 --- a/apps/epg/tests/test_programme_index.py +++ b/apps/epg/tests/test_programme_index.py @@ -1,7 +1,7 @@ import os import gzip import tempfile -from unittest.mock import patch, MagicMock +from unittest.mock import patch from django.test import TestCase from django.utils import timezone @@ -11,6 +11,7 @@ from apps.epg.tasks import ( find_current_program_for_tvg_id, build_programme_index, build_programme_index_task, + _EPG_TVG_PARSE_DEFER_SECONDS, ) FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -663,38 +664,40 @@ class BuildProgrammeIndexTests(TestCase): build_programme_index(99999) @patch("apps.epg.tasks.build_programme_index") - def test_task_builds_and_releases_lock_when_free(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = True # lock acquired - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): - build_programme_index_task(42) + @patch("apps.epg.tasks.release_task_lock") + @patch("apps.epg.tasks.acquire_task_lock", return_value=True) + def test_task_builds_and_releases_lock_when_free( + self, mock_acquire, mock_release, mock_build + ): + build_programme_index_task(42) mock_build.assert_called_once_with(42) - mock_redis.set.assert_called_once() - self.assertEqual( - mock_redis.set.call_args.args[0], "building_programme_index_42" - ) - mock_redis.delete.assert_called_once_with("building_programme_index_42") + mock_acquire.assert_called_once_with("epg_source_file", 42) + mock_release.assert_called_once_with("epg_source_file", 42) @patch("apps.epg.tasks.build_programme_index") - def test_task_skips_when_lock_held(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = False # another build in flight - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): + @patch("apps.epg.tasks.acquire_task_lock", return_value=False) + def test_task_defers_when_lock_held(self, mock_acquire, mock_build): + with patch("apps.epg.tasks.build_programme_index_task.apply_async") as mock_apply: build_programme_index_task(42) mock_build.assert_not_called() - mock_redis.delete.assert_not_called() + mock_apply.assert_called_once_with( + args=[42], + kwargs={'_defer_retry': 1}, + countdown=_EPG_TVG_PARSE_DEFER_SECONDS, + ) @patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom")) - def test_task_releases_lock_on_failure(self, mock_build): - mock_redis = MagicMock() - mock_redis.set.return_value = True - with patch("core.utils.RedisClient.get_client", return_value=mock_redis): - with self.assertRaises(RuntimeError): - build_programme_index_task(42) + @patch("apps.epg.tasks.release_task_lock") + @patch("apps.epg.tasks.acquire_task_lock", return_value=True) + def test_task_releases_lock_on_failure( + self, mock_acquire, mock_release, mock_build + ): + with self.assertRaises(RuntimeError): + build_programme_index_task(42) - mock_redis.delete.assert_called_once_with("building_programme_index_42") + mock_release.assert_called_once_with("epg_source_file", 42) def test_per_channel_interleaved_marking(self): xml = (