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.
This commit is contained in:
SergeantPanda 2026-05-04 20:45:40 -05:00
parent c1d0189126
commit 5940efbddf
11 changed files with 934 additions and 266 deletions

View file

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

View file

@ -17,6 +17,7 @@ from .api_views import (
GetChannelStreamsAPIView,
GetChannelStreamStatsAPIView,
SeriesRulesAPIView,
SeriesRulePreviewAPIView,
DeleteSeriesRuleAPIView,
EvaluateSeriesRulesAPIView,
BulkRemoveSeriesRecordingsAPIView,
@ -47,6 +48,7 @@ urlpatterns = [
path('profiles/<int:profile_id>/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/<path:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'),

View file

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

View file

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

View file

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

137
apps/epg/query_utils.py Normal file
View file

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

View file

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

View file

@ -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
</Button>
<Anchor
component="button"
type="button"
size="xs"
ta="center"
onClick={() => setEditorOpen(true)}
>
Customize rule...
</Anchor>
{recording && (
<>
<Button
@ -106,6 +120,25 @@ export default function ProgramRecordingModal({
</Button>
)}
</Flex>
<SeriesRuleEditorModal
opened={editorOpen}
onClose={() => setEditorOpen(false)}
initialRule={
existingRule ||
(program
? {
tvg_id: program.tvg_id,
title: program.title,
title_mode: 'exact',
mode: 'all',
}
: null)
}
onSaved={() => {
onClose();
}}
/>
</Modal>
);
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
import React, { useState } from 'react';
import { Modal, Stack, Text, Flex, Group, Button, Badge } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import {
@ -7,6 +7,14 @@ import {
fetchRules,
} from '../../utils/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import SeriesRuleEditorModal from './SeriesRuleEditorModal.jsx';
const TITLE_MODE_LABEL = {
exact: 'Exact title',
contains: 'Title contains',
search: 'Whole word title',
regex: 'Title regex',
};
export default function SeriesRecordingModal({
opened,
@ -14,6 +22,9 @@ export default function SeriesRecordingModal({
rules,
onRulesUpdate,
}) {
const [editorOpen, setEditorOpen] = useState(false);
const [editorRule, setEditorRule] = useState(null);
const handleEvaluateNow = async (r) => {
await evaluateSeriesRulesByTvgId(r.tvg_id);
try {
@ -38,58 +49,116 @@ export default function SeriesRecordingModal({
onRulesUpdate(updated);
};
const openEditor = (rule) => {
setEditorRule(rule || null);
setEditorOpen(true);
};
const handleEditorSaved = async () => {
const updated = await fetchRules();
onRulesUpdate(updated);
};
const renderRuleSummary = (r) => {
const titleMode = (r.title_mode || 'exact').toLowerCase();
const parts = [];
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
if (r.title) {
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
}
if (r.description) {
parts.push(`Description: "${r.description}"`);
}
return parts.join(' | ');
};
return (
<Modal
opened={opened}
onClose={onClose}
title="Series Recording Rules"
centered
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
<Stack gap="sm">
{(!rules || rules.length === 0) && (
<Text size="sm" c="dimmed">
No series rules configured
</Text>
)}
{rules &&
rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}`}
justify="space-between"
align="center"
>
<Text size="sm">
{r.title || r.tvg_id} {' '}
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
</Text>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove this series (scheduled)
</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
<>
<Modal
opened={opened}
onClose={onClose}
title="Series Recording Rules"
centered
size="lg"
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
<Stack gap="sm">
<Group justify="flex-end">
<Button size="xs" onClick={() => openEditor(null)}>
Add rule
</Button>
</Group>
{(!rules || rules.length === 0) && (
<Text size="sm" c="dimmed">
No series rules configured
</Text>
)}
{rules &&
rules.map((r) => (
<Flex
key={`${r.tvg_id}-${r.mode}-${r.title || ''}`}
justify="space-between"
align="center"
gap="sm"
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500} truncate>
{r.title || r.tvg_id}
</Text>
{r.channel_id && (
<Badge size="xs" variant="light" color="blue">
Pinned channel
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{renderRuleSummary(r)}
</Text>
</Stack>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
onClick={() => openEditor(r)}
>
Edit
</Button>
<Button
size="xs"
variant="subtle"
onClick={() => handleEvaluateNow(r)}
>
Evaluate Now
</Button>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRemoveSeries(r)}
>
Remove
</Button>
</Group>
</Flex>
))}
</Stack>
</Modal>
<SeriesRuleEditorModal
opened={editorOpen}
onClose={() => setEditorOpen(false)}
initialRule={editorRule}
onSaved={handleEditorSaved}
/>
</>
);
}

View file

@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={initialRule?.tvg_id ? 'Edit Series Rule' : 'New Series Rule'}
centered
size="lg"
radius="md"
zIndex={9999}
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
<Stack gap="sm">
<Select
label="EPG channel"
description="Programs are matched within this EPG channel's listings."
placeholder="Search by name or tvg_id..."
searchable
clearable
data={tvgOptions}
value={tvgId || null}
onChange={(v) => setTvgId(v || '')}
nothingFoundMessage="No EPG channels found"
comboboxProps={{ zIndex: 10000 }}
filter={({ options, search }) => {
const q = search.toLowerCase().trim();
if (!q) return options;
return options.filter(
({ label, value }) =>
label.toLowerCase().includes(q) ||
value.toLowerCase().includes(q)
);
}}
required
/>
<Stack gap={4}>
<TextInput
label="Title"
placeholder='e.g. The Daily Show, or "Law and Order" AND crime'
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
<SegmentedControl
data={TITLE_MODES}
value={titleMode}
onChange={setTitleMode}
size="xs"
/>
</Stack>
<Stack gap={4}>
<Textarea
label="Description (optional)"
description="Match against the program description. Supports AND / OR and quoted phrases."
placeholder="e.g. (Newcastle OR NEW) AND (Villa OR AST)"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
autosize
minRows={1}
maxRows={3}
/>
<SegmentedControl
data={DESCRIPTION_MODES}
value={descriptionMode}
onChange={setDescriptionMode}
size="xs"
/>
</Stack>
<Group grow align="end">
<Stack gap={4}>
<Text size="sm" fw={500}>
Episodes
</Text>
<SegmentedControl
data={EPISODE_MODES}
value={mode}
onChange={setMode}
size="xs"
/>
</Stack>
<Select
label="Pinned channel (optional)"
description="Recordings will be created on this channel. Defaults to lowest channel number for the EPG."
placeholder="Default"
data={channelOptions}
value={channelId || null}
onChange={(v) => setChannelId(v || '')}
clearable
searchable
comboboxProps={{ zIndex: 10000 }}
/>
</Group>
<Divider />
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
Preview
</Text>
<Badge
size="sm"
variant="light"
color={previewLoading ? 'yellow' : 'blue'}
>
{previewLoading
? 'loading'
: `${preview.matches?.length || 0} of ${preview.total || 0}`}
</Badge>
</Group>
{!preview.epg_found && tvgId && !previewLoading && (
<Text size="xs" c="orange">
No EPG channel matches this tvg_id.
</Text>
)}
</Group>
{previewError && (
<Alert color="red" variant="light">
{previewError}
</Alert>
)}
<ScrollArea.Autosize mah={240}>
<Stack gap={4}>
{(preview.matches || []).map((p) => (
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" style={{ minWidth: 160 }}>
{formatRange(p.start_time, p.end_time)}
</Text>
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" lineClamp={1}>
{p.title}
{p.sub_title ? ` - ${p.sub_title}` : ''}
{p.is_new ? ' (NEW)' : ''}
</Text>
{p.description && (
<Text size="xs" c="dimmed" lineClamp={2}>
{p.description}
</Text>
)}
</Stack>
</Group>
))}
{!previewLoading &&
(preview.matches?.length || 0) === 0 &&
tvgId &&
preview.epg_found && (
<Text size="xs" c="dimmed">
No matching upcoming programs in the next 7 days.
</Text>
)}
</Stack>
</ScrollArea.Autosize>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} loading={saving} disabled={!canSave}>
Save rule
</Button>
</Group>
</Stack>
</Modal>
);
}

View file

@ -116,6 +116,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [recordChoiceProgram, setRecordChoiceProgram] = useState(null);
const [recordChoiceChannel, setRecordChoiceChannel] = useState(null);
const [existingRuleMode, setExistingRuleMode] = useState(null);
const [existingRule, setExistingRule] = useState(null);
const [rulesOpen, setRulesOpen] = useState(false);
const [rules, setRules] = useState([]);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
@ -718,6 +719,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const rules = await fetchRules();
const rule = getRuleByProgram(rules, program);
setExistingRuleMode(rule ? rule.mode : null);
setExistingRule(rule || null);
} catch (error) {
console.warn('Failed to fetch series rules metadata', error);
}
@ -727,22 +729,19 @@ export default function TVChannelGuide({ startDate, endDate }) {
[recordingsByProgramId]
);
const recordOne = useCallback(
async (program, channel) => {
if (!channel) {
showNotification({
title: 'Unable to schedule recording',
message: 'No channel found for this program.',
color: 'red.6',
});
return;
}
const recordOne = useCallback(async (program, channel) => {
if (!channel) {
showNotification({
title: 'Unable to schedule recording',
message: 'No channel found for this program.',
color: 'red.6',
});
return;
}
await createRecording(channel, program);
showNotification({ title: 'Recording scheduled' });
},
[]
);
await createRecording(channel, program);
showNotification({ title: 'Recording scheduled' });
}, []);
const saveSeriesRule = useCallback(async (program, mode) => {
await createSeriesRule(program, mode);
@ -1464,7 +1463,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
program={recordChoiceProgram}
recording={recordingForProgram}
existingRuleMode={existingRuleMode}
onRecordOne={() => recordOne(recordChoiceProgram, recordChoiceChannel)}
existingRule={existingRule}
onRecordOne={() =>
recordOne(recordChoiceProgram, recordChoiceChannel)
}
onRecordSeriesAll={() =>
saveSeriesRule(recordChoiceProgram, 'all')
}