From 5940efbddf176a5749d51325a398fe143d86bd1b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 4 May 2026 20:45:40 -0500 Subject: [PATCH] feat: Enhance series rule evaluation and management - Added support for customizable title and description matching modes in series rules. - Implemented a preview feature for series rules to show matching upcoming programs. - Created a new SeriesRuleEditorModal for editing series rules with improved UI. - Refactored query parsing logic into reusable functions for better maintainability. - Updated API to handle new parameters for series rule creation and evaluation. - Enhanced the ProgramRecordingModal and SeriesRecordingModal to integrate the new rule editor. - Added pagination for program search results in the API. --- CHANGELOG.md | 11 + apps/channels/api_urls.py | 2 + apps/channels/api_views.py | 147 ++++++- apps/channels/tasks.py | 48 ++- apps/epg/api_views.py | 193 +-------- apps/epg/query_utils.py | 137 ++++++ frontend/src/api.js | 9 + .../forms/ProgramRecordingModal.jsx | 37 +- .../components/forms/SeriesRecordingModal.jsx | 177 +++++--- .../forms/SeriesRuleEditorModal.jsx | 405 ++++++++++++++++++ frontend/src/pages/Guide.jsx | 34 +- 11 files changed, 934 insertions(+), 266 deletions(-) create mode 100644 apps/epg/query_utils.py create mode 100644 frontend/src/components/forms/SeriesRuleEditorModal.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3d0df5..d9153cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **DVR series rules: rich title and description matching.** Series recording rules now accept the same boolean / quoted-phrase / regex / whole-word semantics as the EPG Program Search API on both the title and description fields, plus an optional pinned channel. Existing rules continue to work unchanged (default `title_mode=exact`, no description filter, no pinned channel). + - New rule fields: `title_mode` (`exact` / `contains` / `search` / `regex`), `description`, `description_mode` (`contains` / `search` / `regex`), and `channel_id` (optional integer to pin recordings to a specific channel; defaults to the lowest-numbered channel for the EPG as before). + - The boolean/quoted/regex/whole-word parser was extracted from `apps/epg/api_views.py` into a shared `apps/epg/query_utils.py` so the rule evaluator and the search endpoint use the same code path. No behavioral changes to the existing `/api/epg/programs/search/` endpoint. + - The evaluator builds a single `ProgramData` queryset with `.distinct()` and reuses the existing `(tvg_id, start_time, end_time)` dedup key, so dropping in a description filter or a fuzzy title still preserves the dedup, episode collapsing, and offset-adjustment behavior. No N+1 introduced. +- **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types. +- **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches. +- **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule. + +### Changed + +- **Series rules modal redesign.** Rules now display a one-line summary with the title-match mode, description filter (when present), and a "Pinned channel" badge when a `channel_id` is configured. The previous list rendering remains for legacy rules. - **EPG Program Search API** (`GET /api/epg/programs/search/`): a new endpoint for querying EPG program data with rich filtering and query support. - Thanks [@northernpowerhouse](https://github.com/northernpowerhouse) - **Text search** on title and description with AND/OR boolean operators (case-insensitive), quoted phrase matching (`"Law and Order"` treats _and_ as literal text), parenthetical grouping (`(Newcastle OR NEW) AND (Villa OR AST)`), whole-word mode (`title_whole_words=true`), and regex mode (`title_regex=true`). - **Time filters**: `airing_at` (programs live at a specific instant), `start_after`, `start_before`, `end_after`, `end_before`. diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 756e2341..33e33ed9 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -17,6 +17,7 @@ from .api_views import ( GetChannelStreamsAPIView, GetChannelStreamStatsAPIView, SeriesRulesAPIView, + SeriesRulePreviewAPIView, DeleteSeriesRuleAPIView, EvaluateSeriesRulesAPIView, BulkRemoveSeriesRecordingsAPIView, @@ -47,6 +48,7 @@ urlpatterns = [ path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), # DVR series rules (order matters: specific routes before catch-all slug) path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'), + path('series-rules/preview/', SeriesRulePreviewAPIView.as_view(), name='series_rules_preview'), path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'), path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'), path('series-rules//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5eaeab35..f06fcd78 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -3922,6 +3922,10 @@ class SeriesRulesAPIView(APIView): 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), 'title': serializers.CharField(help_text='Series title', required=False), + 'title_mode': serializers.ChoiceField(choices=['exact', 'contains', 'search', 'regex'], default='exact', required=False, help_text='How to match the title field'), + 'description': serializers.CharField(required=False, help_text='Optional description match expression'), + 'description_mode': serializers.ChoiceField(choices=['contains', 'search', 'regex'], default='contains', required=False, help_text='How to match the description field'), + 'channel_id': serializers.IntegerField(required=False, help_text='Optional channel to pin recordings to (defaults to lowest-numbered channel for the EPG)'), }, ), ) @@ -3930,17 +3934,49 @@ class SeriesRulesAPIView(APIView): tvg_id = str(data.get("tvg_id") or "").strip() mode = (data.get("mode") or "all").lower() title = data.get("title") or "" + title_mode = (data.get("title_mode") or "exact").lower() + description = data.get("description") or "" + description_mode = (data.get("description_mode") or "contains").lower() + channel_id = data.get("channel_id") if mode not in ("all", "new"): return Response({"error": "mode must be 'all' or 'new'"}, status=status.HTTP_400_BAD_REQUEST) + if title_mode not in ("exact", "contains", "search", "regex"): + return Response({"error": "title_mode must be one of exact, contains, search, regex"}, status=status.HTTP_400_BAD_REQUEST) + if description_mode not in ("contains", "search", "regex"): + return Response({"error": "description_mode must be one of contains, search, regex"}, status=status.HTTP_400_BAD_REQUEST) if not tvg_id: return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + # Coerce / validate optional pinned channel + pinned_channel_id = None + if channel_id not in (None, ""): + try: + pinned_channel_id = int(channel_id) + except (TypeError, ValueError): + return Response({"error": "channel_id must be an integer"}, status=status.HTTP_400_BAD_REQUEST) + from .models import Channel + if not Channel.objects.filter(id=pinned_channel_id).exists(): + return Response({"error": "channel_id does not exist"}, status=status.HTTP_400_BAD_REQUEST) + + rule_record = { + "tvg_id": tvg_id, + "mode": mode, + "title": title, + "title_mode": title_mode, + "description": description, + "description_mode": description_mode, + } + if pinned_channel_id is not None: + rule_record["channel_id"] = pinned_channel_id + rules = CoreSettings.get_dvr_series_rules() # Upsert by tvg_id existing = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None) if existing: - existing.update({"mode": mode, "title": title}) + existing.clear() + existing.update(rule_record) else: - rules.append({"tvg_id": tvg_id, "mode": mode, "title": title}) + rules.append(rule_record) CoreSettings.set_dvr_series_rules(rules) # Note: frontend calls the evaluate endpoint explicitly after creating # the rule, so do NOT fire evaluate_series_rules.delay() here to @@ -3948,6 +3984,113 @@ class SeriesRulesAPIView(APIView): return Response({"success": True, "rules": rules}) +class SeriesRulePreviewAPIView(APIView): + """Preview which upcoming programs a series rule would match. + + Accepts the same payload as SeriesRulesAPIView.post but does not persist + anything. Returns up to `limit` upcoming programs (default 25, max 100) + within the standard 7-day evaluation horizon. + """ + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_method[self.request.method]] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="Preview series rule matches", + description="Return upcoming programs that the given rule would match without persisting the rule.", + request=inline_serializer( + name="SeriesRulePreviewRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', required=False), + 'title': serializers.CharField(required=False), + 'title_mode': serializers.ChoiceField(choices=['exact', 'contains', 'search', 'regex'], default='exact', required=False), + 'description': serializers.CharField(required=False), + 'description_mode': serializers.ChoiceField(choices=['contains', 'search', 'regex'], default='contains', required=False), + 'limit': serializers.IntegerField(required=False, help_text='Max programs to return (default 25, max 100)'), + }, + ), + ) + def post(self, request): + from apps.epg.models import EPGData, ProgramData + from apps.epg.query_utils import parse_text_query + + data = request.data or {} + tvg_id = str(data.get("tvg_id") or "").strip() + if not tvg_id: + return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST) + mode = (data.get("mode") or "all").lower() + title = (data.get("title") or "").strip() + title_mode = (data.get("title_mode") or "exact").lower() + description = (data.get("description") or "").strip() + description_mode = (data.get("description_mode") or "contains").lower() + try: + limit = int(data.get("limit") or 25) + except (TypeError, ValueError): + limit = 25 + limit = max(1, min(limit, 100)) + + epg = EPGData.objects.filter(tvg_id=tvg_id).first() + if not epg: + return Response({"matches": [], "total": 0, "epg_found": False}) + + now = timezone.now() + horizon = now + timedelta(days=7) + qs = ProgramData.objects.filter(epg=epg, end_time__gt=now, start_time__lte=horizon) + + if title: + if title_mode == "exact": + qs = qs.filter(title__iexact=title) + else: + qs = qs.filter(parse_text_query( + "title", title, + use_regex=(title_mode == "regex"), + whole_words=(title_mode == "search"), + )) + if description: + qs = qs.filter(parse_text_query( + "description", description, + use_regex=(description_mode == "regex"), + whole_words=(description_mode == "search"), + )) + + qs = qs.distinct().order_by("start_time") + + # Apply "new" filter in Python (custom_properties JSON lookup), but only + # over the bounded result set we already filtered down to. + candidates = list(qs[:limit * 4]) # small overshoot to allow new-only filtering + if mode == "new": + candidates = [p for p in candidates if (p.custom_properties or {}).get("new")] + + total = len(candidates) + candidates = candidates[:limit] + + matches = [] + for p in candidates: + cp = p.custom_properties or {} + matches.append({ + "id": p.id, + "tvg_id": p.tvg_id, + "title": p.title, + "sub_title": p.sub_title, + "description": p.description, + "start_time": p.start_time.isoformat(), + "end_time": p.end_time.isoformat(), + "season": cp.get("season"), + "episode": cp.get("episode"), + "is_new": bool(cp.get("new")), + }) + + return Response({ + "matches": matches, + "total": total, + "limit": limit, + "epg_found": True, + }) + + class DeleteSeriesRuleAPIView(APIView): def get_permissions(self): try: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 08c7737b..53a5027f 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1160,7 +1160,13 @@ def _evaluate_series_rules_locked(tvg_id, result): rv_tvg = str(rule.get("tvg_id") or "").strip() mode = (rule.get("mode") or "all").lower() series_title = (rule.get("title") or "").strip() - norm_series = normalize_name(series_title) if series_title else None + title_mode = (rule.get("title_mode") or "exact").lower() + description = (rule.get("description") or "").strip() + description_mode = (rule.get("description_mode") or "contains").lower() + try: + pinned_channel_id = int(rule["channel_id"]) if rule.get("channel_id") not in (None, "") else None + except (TypeError, ValueError): + pinned_channel_id = None if not rv_tvg: result["details"].append({"tvg_id": rv_tvg, "status": "invalid_rule"}) continue @@ -1175,19 +1181,35 @@ def _evaluate_series_rules_locked(tvg_id, result): end_time__gt=now, start_time__lte=horizon, ) - if series_title: - programs_qs = programs_qs.filter(title__iexact=series_title) - programs = list(programs_qs.order_by("start_time")) - # Fallback: if no direct matches and we have a title, try normalized comparison in Python - if series_title and not programs: - all_progs = ProgramData.objects.filter( - epg=epg, - end_time__gt=now, - start_time__lte=horizon, - ).only("id", "title", "start_time", "end_time", "custom_properties", "tvg_id") - programs = [p for p in all_progs if normalize_name(p.title) == norm_series] - channel = Channel.objects.filter(epg_data=epg).order_by("channel_number").first() + from apps.epg.query_utils import parse_text_query + + if series_title: + if title_mode == "exact": + programs_qs = programs_qs.filter(title__iexact=series_title) + else: + use_regex = title_mode == "regex" + whole_words = title_mode == "search" + programs_qs = programs_qs.filter( + parse_text_query("title", series_title, use_regex=use_regex, whole_words=whole_words) + ) + + if description: + use_regex = description_mode == "regex" + whole_words = description_mode == "search" + programs_qs = programs_qs.filter( + parse_text_query("description", description, use_regex=use_regex, whole_words=whole_words) + ) + + programs = list(programs_qs.distinct().order_by("start_time")) + + if pinned_channel_id is not None: + channel = Channel.objects.filter(id=pinned_channel_id).first() + if channel is None: + result["details"].append({"tvg_id": rv_tvg, "status": "pinned_channel_missing", "channel_id": pinned_channel_id}) + continue + else: + channel = Channel.objects.filter(epg_data=epg).order_by("channel_number").first() if not channel: result["details"].append({"tvg_id": rv_tvg, "status": "no_channel_for_epg"}) continue diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 1a43b938..296f91b8 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -19,6 +19,7 @@ from .serializers import ( ProgramSearchResultSerializer, ) from .tasks import refresh_epg_data +from .query_utils import parse_text_query from apps.accounts.permissions import ( Authenticated, IsStandardUser, @@ -110,6 +111,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet): # ───────────────────────────── # 2) Program API (CRUD) # ───────────────────────────── +class ProgramSearchPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = 'page_size' + max_page_size = 500 + + class ProgramViewSet(viewsets.ModelViewSet): """Handles CRUD operations for EPG programs""" @@ -199,6 +206,7 @@ class ProgramViewSet(viewsets.ModelViewSet): OpenApiParameter('airing_at', OpenApiTypes.DATETIME, description='Find programs airing at this exact moment (start_time ≤ airing_at < end_time). ISO 8601 format, e.g. `2026-02-14T20:00:00Z`.'), OpenApiParameter('channel', OpenApiTypes.STR, description='Filter by channel name (case-insensitive substring match). e.g. `BBC One`, `Sky Sports`.'), OpenApiParameter('channel_id', OpenApiTypes.INT, description='Filter by exact channel ID.'), + OpenApiParameter('tvg_id', OpenApiTypes.STR, description='Filter by EPG tvg_id (exact match). e.g. `bbcone.uk`.'), OpenApiParameter('stream', OpenApiTypes.STR, description='Filter by stream name (case-insensitive substring match).'), OpenApiParameter('group', OpenApiTypes.STR, description='Filter by channel group or stream group name (case-insensitive substring match). e.g. `Sports`, `UK Channels`.'), OpenApiParameter('epg_source', OpenApiTypes.INT, description='Filter by EPG source ID.'), @@ -229,13 +237,13 @@ class ProgramViewSet(viewsets.ModelViewSet): if title: title_regex = params.get('title_regex', '').lower() in ('true', '1', 'yes') title_whole_words = params.get('title_whole_words', '').lower() in ('true', '1', 'yes') - filters &= _parse_text_query('title', title, use_regex=title_regex, whole_words=title_whole_words) + filters &= parse_text_query('title', title, use_regex=title_regex, whole_words=title_whole_words) description = params.get('description') if description: desc_regex = params.get('description_regex', '').lower() in ('true', '1', 'yes') desc_whole_words = params.get('description_whole_words', '').lower() in ('true', '1', 'yes') - filters &= _parse_text_query('description', description, use_regex=desc_regex, whole_words=desc_whole_words) + filters &= parse_text_query('description', description, use_regex=desc_regex, whole_words=desc_whole_words) # Time filters with validation start_after = params.get('start_after') @@ -300,6 +308,10 @@ class ProgramViewSet(viewsets.ModelViewSet): except (ValueError, TypeError): pass + tvg_id = params.get('tvg_id') + if tvg_id: + filters &= Q(epg__tvg_id=tvg_id) + stream = params.get('stream') if stream: filters &= Q(epg__channels__streams__name__icontains=stream) @@ -765,180 +777,3 @@ class CurrentProgramsAPIView(APIView): return Response(current_programs, status=status.HTTP_200_OK) - -# ───────────────────────────── -# 7) Program Search API -# ───────────────────────────── - - -def _build_q_object(field_name, term, use_regex=False, whole_words=False): - """ - Build a single Q object for a search term. - - Args: - field_name: Django ORM field name - term: Search term - use_regex: If True, use regex matching - whole_words: If True, use word boundary matching - """ - term = term.strip() - if not term: - return Q() - - if use_regex: - # Use Django's __iregex (case-insensitive regex) - return Q(**{f'{field_name}__iregex': term}) - elif whole_words: - # Word boundary syntax differs by database engine: - # PostgreSQL uses \y (or \m/\M); Python re (SQLite) uses \b. - from django.db import connection - if connection.vendor == 'postgresql': - boundary = r'\y' - else: - boundary = r'\b' - pattern = boundary + re.escape(term) + boundary - return Q(**{f'{field_name}__iregex': pattern}) - else: - # Standard case-insensitive contains - return Q(**{f'{field_name}__icontains': term}) - - -def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False): - """ - Parse a text search value with AND/OR operators (including nested groups, parentheses, - and quoted phrases) into a Q object. - - Quoted phrases (double-quoted) are treated as atomic literals and are never split on - AND/OR. Outside of quotes, AND and OR are case-insensitive boolean operators. - - Examples: - "sports AND football" → Q(field__icontains="sports") & Q(field__icontains="football") - "news or weather" → Q(field__icontains="news") | Q(field__icontains="weather") - '"Law and Order"' → Q(field__icontains="Law and Order") [quoted phrase] - '"Law and Order" AND crime' → phrase AND bare term - "(Newcastle OR NEW) AND (Villa OR AST)" → Grouped nested operations - "breaking news" → Q(field__icontains="breaking news") [unquoted phrase match] - - Args: - field_name: Django ORM field name to query - raw_value: Text value with optional AND/OR operators and quoted phrases - use_regex: If True, use regex matching instead of icontains - whole_words: If True, match whole words only (requires word boundaries) - - Supports mixed operators evaluated left-to-right: "sports and football or basketball" - Supports nested groups: "(A OR B) AND (C OR D)" - """ - - # Step 1: Extract quoted phrases into a lookup dict and replace with opaque placeholders. - # This lets AND/OR detection be case-insensitive without splitting phrases like - # "Law and Order" that happen to contain conjunctions. - phrases = {} - - def extract_quoted(text): - def replacer(m): - key = f'\x00P{len(phrases)}\x00' - phrases[key] = m.group(1) - return key - return re.sub(r'"([^"]*)"', replacer, text) - - processed = extract_quoted(raw_value) - - def build_q(token): - """Build a Q object, resolving any quoted-phrase placeholder first.""" - return _build_q_object(field_name, phrases.get(token, token), use_regex, whole_words) - - def parse_expression(expr): - """Recursively parse expression with parentheses support.""" - expr = expr.strip() - - # Handle parentheses: parse the innermost group into a Q object, then - # recursively combine it with the expressions on either side. - if '(' in expr: - paren_start = expr.rfind('(') - paren_end = expr.find(')', paren_start) - if paren_end == -1: - return Q() # Mismatched parentheses - - group_q = parse_expression(expr[paren_start + 1:paren_end]) - - before_str = expr[:paren_start].rstrip() - after_str = expr[paren_end + 1:].lstrip() - - # Operator connecting before-expression to the group (case-insensitive) - before_op = '&' - if before_str.upper().endswith(' AND'): - before_str = before_str[:-4].rstrip() - elif before_str.upper().endswith(' OR'): - before_str = before_str[:-3].rstrip() - before_op = '|' - - # Operator connecting the group to the after-expression (case-insensitive) - after_op = '&' - after_upper = after_str.upper() - if after_upper.startswith('AND '): - after_str = after_str[4:].lstrip() - elif after_upper.startswith('OR '): - after_str = after_str[3:].lstrip() - after_op = '|' - - result = group_q - if before_str: - before_q = parse_expression(before_str) - result = (before_q | result) if before_op == '|' else (before_q & result) - if after_str: - after_q = parse_expression(after_str) - result = (result | after_q) if after_op == '|' else (result & after_q) - return result - - # No parentheses: tokenize on AND/OR boundaries (case-insensitive). - # Quoted phrases have been replaced with placeholders and are never split. - tokens = [] - operators = [] - remaining = expr - - while remaining: - upper = remaining.upper() - and_pos = upper.find(' AND ') - or_pos = upper.find(' OR ') - - if and_pos == -1 and or_pos == -1: - tokens.append(remaining.strip()) - break - - if and_pos == -1: - pos, op, op_len = or_pos, '|', 4 - elif or_pos == -1: - pos, op, op_len = and_pos, '&', 5 - elif and_pos < or_pos: - pos, op, op_len = and_pos, '&', 5 - else: - pos, op, op_len = or_pos, '|', 4 - - token = remaining[:pos].strip() - if token: - tokens.append(token) - operators.append(op) - remaining = remaining[pos + op_len:] - - if not tokens: - return Q() - - # Build Q chain - result = build_q(tokens[0]) - for i, op in enumerate(operators): - next_q = build_q(tokens[i + 1]) - if op == '&': - result = result & next_q - else: - result = result | next_q - - return result - - return parse_expression(processed) - - -class ProgramSearchPagination(PageNumberPagination): - page_size = 50 - page_size_query_param = 'page_size' - max_page_size = 500 - diff --git a/apps/epg/query_utils.py b/apps/epg/query_utils.py new file mode 100644 index 00000000..f2f06b9a --- /dev/null +++ b/apps/epg/query_utils.py @@ -0,0 +1,137 @@ +"""Shared query parsing helpers for EPG program text search. + +Used by the EPG search API and the DVR series rule evaluator to apply +the same boolean / quoted-phrase / regex / whole-word matching semantics. + +Public API: + parse_text_query(field, raw_value, use_regex=False, whole_words=False) -> Q +""" + +import re + +from django.db.models import Q + + +def build_q_object(field_name, term, use_regex=False, whole_words=False): + """Build a single Q object for one search term. + + `whole_words` uses `\\y` on PostgreSQL and `\\b` everywhere else so the + pattern actually anchors on a word boundary regardless of backend. + """ + term = term.strip() + if not term: + return Q() + + if use_regex: + return Q(**{f'{field_name}__iregex': term}) + if whole_words: + from django.db import connection + boundary = r'\y' if connection.vendor == 'postgresql' else r'\b' + pattern = boundary + re.escape(term) + boundary + return Q(**{f'{field_name}__iregex': pattern}) + return Q(**{f'{field_name}__icontains': term}) + + +def parse_text_query(field_name, raw_value, use_regex=False, whole_words=False): + """Parse a search expression into a Q object. + + Supports: + - AND / OR (case-insensitive) between bare terms. + - Double-quoted phrases (atomic; never split on operators). + - Parenthetical grouping with arbitrary nesting. + - Regex mode (entire raw value is treated as a single regex). + - Whole-word mode (each bare term anchored with word boundaries). + + A bare value with no operators is matched as a single phrase via + icontains (or regex / whole-word as configured). + """ + phrases = {} + + def extract_quoted(text): + def replacer(m): + key = f'\x00P{len(phrases)}\x00' + phrases[key] = m.group(1) + return key + return re.sub(r'"([^"]*)"', replacer, text) + + processed = extract_quoted(raw_value) + + def build_q(token): + return build_q_object(field_name, phrases.get(token, token), use_regex, whole_words) + + def parse_expression(expr): + expr = expr.strip() + + if '(' in expr: + paren_start = expr.rfind('(') + paren_end = expr.find(')', paren_start) + if paren_end == -1: + return Q() + + group_q = parse_expression(expr[paren_start + 1:paren_end]) + + before_str = expr[:paren_start].rstrip() + after_str = expr[paren_end + 1:].lstrip() + + before_op = '&' + if before_str.upper().endswith(' AND'): + before_str = before_str[:-4].rstrip() + elif before_str.upper().endswith(' OR'): + before_str = before_str[:-3].rstrip() + before_op = '|' + + after_op = '&' + after_upper = after_str.upper() + if after_upper.startswith('AND '): + after_str = after_str[4:].lstrip() + elif after_upper.startswith('OR '): + after_str = after_str[3:].lstrip() + after_op = '|' + + result = group_q + if before_str: + before_q = parse_expression(before_str) + result = (before_q | result) if before_op == '|' else (before_q & result) + if after_str: + after_q = parse_expression(after_str) + result = (result | after_q) if after_op == '|' else (result & after_q) + return result + + tokens = [] + operators = [] + remaining = expr + + while remaining: + upper = remaining.upper() + and_pos = upper.find(' AND ') + or_pos = upper.find(' OR ') + + if and_pos == -1 and or_pos == -1: + tokens.append(remaining.strip()) + break + + if and_pos == -1: + pos, op, op_len = or_pos, '|', 4 + elif or_pos == -1: + pos, op, op_len = and_pos, '&', 5 + elif and_pos < or_pos: + pos, op, op_len = and_pos, '&', 5 + else: + pos, op, op_len = or_pos, '|', 4 + + token = remaining[:pos].strip() + if token: + tokens.append(token) + operators.append(op) + remaining = remaining[pos + op_len:] + + if not tokens: + return Q() + + result = build_q(tokens[0]) + for i, op in enumerate(operators): + next_q = build_q(tokens[i + 1]) + result = (result & next_q) if op == '&' else (result | next_q) + return result + + return parse_expression(processed) diff --git a/frontend/src/api.js b/frontend/src/api.js index 6690b07c..a8cbdd48 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3115,6 +3115,15 @@ export default class API { } } + static async previewSeriesRule(values, { signal } = {}) { + // Throws on error so callers can ignore aborted requests vs real failures. + return request(`${host}/api/channels/series-rules/preview/`, { + method: 'POST', + body: values, + signal, + }); + } + static async bulkRemoveSeriesRecordings({ tvg_id, title = null, diff --git a/frontend/src/components/forms/ProgramRecordingModal.jsx b/frontend/src/components/forms/ProgramRecordingModal.jsx index 82da2674..68ebb187 100644 --- a/frontend/src/components/forms/ProgramRecordingModal.jsx +++ b/frontend/src/components/forms/ProgramRecordingModal.jsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { Modal, Flex, Button } from '@mantine/core'; +import React, { useState } from 'react'; +import { Modal, Flex, Button, Anchor } from '@mantine/core'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js'; import { deleteSeriesRuleByTvgId } from '../../utils/guideUtils.js'; +import SeriesRuleEditorModal from './SeriesRuleEditorModal.jsx'; export default function ProgramRecordingModal({ opened, @@ -10,11 +11,14 @@ export default function ProgramRecordingModal({ program, recording, existingRuleMode, + existingRule, onRecordOne, onRecordSeriesAll, onRecordSeriesNew, onExistingRuleModeChange, }) { + const [editorOpen, setEditorOpen] = useState(false); + const handleRemoveRecording = async () => { try { await deleteRecordingById(recording.id); @@ -85,6 +89,16 @@ export default function ProgramRecordingModal({ New episodes only + setEditorOpen(true)} + > + Customize rule... + + {recording && ( <> - - - - ))} - - + <> + + + + + + + {(!rules || rules.length === 0) && ( + + No series rules configured + + )} + {rules && + rules.map((r) => ( + + + + + {r.title || r.tvg_id} + + {r.channel_id && ( + + Pinned channel + + )} + + + {renderRuleSummary(r)} + + + + + + + + + ))} + + + + setEditorOpen(false)} + initialRule={editorRule} + onSaved={handleEditorSaved} + /> + ); } diff --git a/frontend/src/components/forms/SeriesRuleEditorModal.jsx b/frontend/src/components/forms/SeriesRuleEditorModal.jsx new file mode 100644 index 00000000..87f24a9b --- /dev/null +++ b/frontend/src/components/forms/SeriesRuleEditorModal.jsx @@ -0,0 +1,405 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Modal, + Stack, + Text, + TextInput, + Textarea, + SegmentedControl, + Group, + Button, + Select, + Badge, + Divider, + ScrollArea, + Alert, +} from '@mantine/core'; +import API from '../../api.js'; +import useChannelsStore from '../../store/channels.jsx'; +import useEPGsStore from '../../store/epgs.jsx'; +import { useDebounce } from '../../utils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; + +const TITLE_MODES = [ + { label: 'Exact', value: 'exact' }, + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; + +const DESCRIPTION_MODES = [ + { label: 'Contains', value: 'contains' }, + { label: 'Whole word', value: 'search' }, + { label: 'Regex', value: 'regex' }, +]; + +const EPISODE_MODES = [ + { label: 'All episodes', value: 'all' }, + { label: 'New only', value: 'new' }, +]; + +function formatRange(start, end) { + try { + const s = new Date(start); + const e = new Date(end); + const sameDay = s.toDateString() === e.toDateString(); + const dateStr = s.toLocaleDateString(); + const startStr = s.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + const endStr = e.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + return sameDay + ? `${dateStr} ${startStr} - ${endStr}` + : `${dateStr} ${startStr} -> ${e.toLocaleString()}`; + } catch { + return `${start} - ${end}`; + } +} + +export default function SeriesRuleEditorModal({ + opened, + onClose, + initialRule, + onSaved, +}) { + const tvgs = useEPGsStore((s) => s.tvgs); + const tvgsById = useEPGsStore((s) => s.tvgsById); + + const [tvgId, setTvgId] = useState(''); + const [mode, setMode] = useState('all'); + const [title, setTitle] = useState(''); + const [titleMode, setTitleMode] = useState('exact'); + const [description, setDescription] = useState(''); + const [descriptionMode, setDescriptionMode] = useState('contains'); + const [channelId, setChannelId] = useState(''); + const [allChannels, setAllChannels] = useState([]); + + const [preview, setPreview] = useState({ + matches: [], + total: 0, + epg_found: true, + }); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [saving, setSaving] = useState(false); + const abortRef = useRef(null); + + // Hydrate state when opened or initial rule changes + useEffect(() => { + if (!opened) return; + setTvgId(String(initialRule?.tvg_id || '')); + setMode(initialRule?.mode || 'all'); + setTitle(initialRule?.title || ''); + setTitleMode(initialRule?.title_mode || 'exact'); + setDescription(initialRule?.description || ''); + setDescriptionMode(initialRule?.description_mode || 'contains'); + setChannelId(initialRule?.channel_id ? String(initialRule.channel_id) : ''); + setPreview({ matches: [], total: 0, epg_found: true }); + setPreviewError(null); + }, [opened, initialRule]); + + // Load channels from the summary API on modal open (same as Recording.jsx). + useEffect(() => { + if (!opened) return; + let cancelled = false; + API.getChannelsSummary() + .then((chans) => { + if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); + }) + .catch(() => { + if (!cancelled) setAllChannels([]); + }); + return () => { + cancelled = true; + }; + }, [opened]); + + // Build the payload for both preview and save + const payload = useMemo( + () => ({ + tvg_id: tvgId.trim(), + mode, + title: title.trim(), + title_mode: titleMode, + description: description.trim(), + description_mode: descriptionMode, + ...(channelId ? { channel_id: Number(channelId) } : {}), + }), + [tvgId, mode, title, titleMode, description, descriptionMode, channelId] + ); + + // Debounce the part of the payload that affects preview + const debouncedPreviewKey = useDebounce(payload, 500); + + useEffect(() => { + if (!opened) return; + if (!debouncedPreviewKey.tvg_id) { + setPreview({ matches: [], total: 0, epg_found: false }); + return; + } + // Abort any in-flight request + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setPreviewLoading(true); + setPreviewError(null); + API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + .then((resp) => { + if (controller.signal.aborted) return; + setPreview(resp || { matches: [], total: 0 }); + }) + .catch((err) => { + if (err?.name === 'AbortError' || controller.signal.aborted) return; + const msg = err?.body?.error || err?.message || 'Preview failed'; + setPreviewError(msg); + setPreview({ matches: [], total: 0, epg_found: true }); + }) + .finally(() => { + if (!controller.signal.aborted) setPreviewLoading(false); + }); + + return () => controller.abort(); + }, [opened, debouncedPreviewKey]); + + // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value + // since the same channel can appear across multiple EPG sources. + const tvgOptions = useMemo(() => { + const seen = new Set(); + const options = []; + for (const t of tvgs || []) { + if (!t.tvg_id || seen.has(t.tvg_id)) continue; + seen.add(t.tvg_id); + options.push({ + value: t.tvg_id, + label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, + }); + } + return options.sort((a, b) => a.label.localeCompare(b.label)); + }, [tvgs]); + + // Channel select options: prefer channels matching the selected tvg_id. + const channelOptions = useMemo(() => { + const sorted = [...allChannels].sort((a, b) => { + const aNum = Number(a.channel_number) || 0; + const bNum = Number(b.channel_number) || 0; + if (aNum !== bNum) return aNum - bNum; + return (a.name || '').localeCompare(b.name || ''); + }); + const matching = []; + const others = []; + for (const c of sorted) { + const item = { + value: String(c.id), + label: c.channel_number + ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` + : c.name || `Channel ${c.id}`, + }; + const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; + if (tvgId && cTvg && String(cTvg) === String(tvgId)) { + matching.push(item); + } else { + others.push(item); + } + } + return [...matching, ...others]; + }, [allChannels, tvgsById, tvgId]); + + const canSave = !!payload.tvg_id; + + const handleSave = async () => { + setSaving(true); + try { + await API.createSeriesRule(payload); + // Trigger evaluation so matching upcoming programs get scheduled. + try { + await API.evaluateSeriesRules(payload.tvg_id); + await useChannelsStore.getState().fetchRecordings(); + } catch (e) { + console.warn('Failed to evaluate after save', e); + } + showNotification({ title: 'Series rule saved' }); + if (onSaved) await onSaved(); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + + +