Merge pull request #1227 from Dispatcharr/dvr-scheduling-enhancements

Enhance series rule management and evaluation features
This commit is contained in:
SergeantPanda 2026-05-05 10:13:53 -05:00 committed by GitHub
commit 0d20cb8fa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1368 additions and 351 deletions

View file

@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **DVR series rules: EPG channel is now optional.** Series recording rules no longer require a `tvg_id`. Rules with only a title or description filter search across all EPG channels in the 7-day horizon. The evaluator pre-loads one channel per EPG source (lowest channel number) in a single query and resolves the recording channel per-program, so cross-channel searches remain efficient. Saves and the preview endpoint now require at least one of `title`, or `description` instead of requiring `tvg_id`. The preview response includes a `warn: true` flag when more than 50 programs match, and the editor shows an orange alert when this flag is set. The upsert key for rules changed from `tvg_id` alone to `(tvg_id, title)` so multiple rules can target the same channel.
- **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,7 +17,7 @@ from .api_views import (
GetChannelStreamsAPIView,
GetChannelStreamStatsAPIView,
SeriesRulesAPIView,
DeleteSeriesRuleAPIView,
SeriesRulePreviewAPIView,
EvaluateSeriesRulesAPIView,
BulkRemoveSeriesRecordingsAPIView,
BulkDeleteUpcomingRecordingsAPIView,
@ -47,9 +47,9 @@ 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'),
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
path(
'recordings/<int:pk>/hls/<path:seg_path>',

View file

@ -14,7 +14,6 @@ from django.db.models import Q
import os, json, requests, logging, mimetypes, threading, time
from datetime import timedelta
from django.utils.http import http_date
from urllib.parse import unquote
from apps.accounts.permissions import (
Authenticated,
IsAdmin,
@ -3919,9 +3918,13 @@ class SeriesRulesAPIView(APIView):
request=inline_serializer(
name="SeriesRuleRequest",
fields={
'tvg_id': serializers.CharField(help_text='Channel TVG ID'),
'tvg_id': serializers.CharField(required=False, allow_blank=True, help_text='Optional channel TVG ID. Omit to match across all channels.'),
'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,62 +3933,96 @@ 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 not tvg_id:
return Response({"error": "tvg_id is required"}, 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 title.strip() and not description.strip():
return Response({"error": "A title or description 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)
# Upsert by tvg_id + title so multiple rules can target the same channel
existing = next(
(r for r in rules if
str(r.get("tvg_id") or "") == tvg_id and
str(r.get("title") or "") == title),
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
# avoid a race that creates duplicate recordings.
return Response({"success": True, "rules": rules})
class DeleteSeriesRuleAPIView(APIView):
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_method[self.request.method]]
except KeyError:
return [Authenticated()]
@extend_schema(
summary="Delete a series rule",
description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.",
description="Remove a series recording rule by tvg_id + title and clean up future scheduled recordings.",
parameters=[
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
OpenApiParameter('tvg_id', str, OpenApiParameter.QUERY, required=False, description='Channel TVG ID (may be blank for title-only rules)'),
OpenApiParameter('title', str, OpenApiParameter.QUERY, required=False, description='Series title'),
],
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
def delete(self, request):
tvg_id = str(request.query_params.get("tvg_id") or "").strip()
title = request.query_params.get("title")
# Find the rule before removing to retain the title for cleanup
rules = CoreSettings.get_dvr_series_rules()
deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None)
remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id]
def _matches(r):
tvg_match = str(r.get("tvg_id") or "") == tvg_id
title_match = title is None or str(r.get("title") or "") == title
return tvg_match and title_match
deleted_rule = next((r for r in rules if _matches(r)), None)
remaining = [r for r in rules if not _matches(r)]
CoreSettings.set_dvr_series_rules(remaining)
# Delete only FUTURE recordings — preserve previously recorded episodes
removed = 0
if deleted_rule:
from .models import Recording
qs = Recording.objects.filter(
start_time__gte=timezone.now(),
custom_properties__program__tvg_id=tvg_id,
)
title = deleted_rule.get("title")
if title:
qs = qs.filter(custom_properties__program__title=title)
qs = Recording.objects.filter(start_time__gte=timezone.now())
rule_tvg_id = deleted_rule.get("tvg_id") or ""
if rule_tvg_id:
qs = qs.filter(custom_properties__program__tvg_id=rule_tvg_id)
rule_title = deleted_rule.get("title") or ""
if rule_title:
qs = qs.filter(custom_properties__program__title=rule_title)
removed = qs.count()
qs.delete()
# Notify frontend to refresh recordings list
try:
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
@ -3997,6 +4034,118 @@ class DeleteSeriesRuleAPIView(APIView):
return Response({"success": True, "rules": remaining, "removed": removed})
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(required=False, allow_blank=True, help_text='Optional channel TVG ID. Omit to search across all channels.'),
'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()
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))
if not title and not description:
return Response({"error": "A title or description is required"}, status=status.HTTP_400_BAD_REQUEST)
now = timezone.now()
horizon = now + timedelta(days=7)
if tvg_id:
epg = EPGData.objects.filter(tvg_id=tvg_id).first()
if not epg:
return Response({"matches": [], "total": 0, "epg_found": False})
qs = ProgramData.objects.filter(epg=epg, end_time__gt=now, start_time__lte=horizon)
else:
qs = ProgramData.objects.filter(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,
"warn": total > 50,
})
class EvaluateSeriesRulesAPIView(APIView):
def get_permissions(self):
try:
@ -4052,13 +4201,12 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
tvg_id = str(request.data.get("tvg_id") or "").strip()
title = request.data.get("title")
scope = (request.data.get("scope") or "title").lower()
if not tvg_id:
return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST)
if not tvg_id and not title:
return Response({"error": "tvg_id or title is required"}, status=status.HTTP_400_BAD_REQUEST)
qs = Recording.objects.filter(
start_time__gte=timezone.now(),
custom_properties__program__tvg_id=tvg_id,
)
qs = Recording.objects.filter(start_time__gte=timezone.now())
if tvg_id:
qs = qs.filter(custom_properties__program__tvg_id=tvg_id)
if scope == "title" and title:
qs = qs.filter(custom_properties__program__title=title)

View file

@ -1136,6 +1136,15 @@ def _evaluate_series_rules_locked(tvg_id, result):
now = timezone.now()
horizon = now + timedelta(days=7)
try:
pre_min = int(CoreSettings.get_dvr_pre_offset_minutes())
except Exception:
pre_min = 0
try:
post_min = int(CoreSettings.get_dvr_post_offset_minutes())
except Exception:
post_min = 0
# Preload existing recordings keyed by stable program attributes that
# survive EPG refreshes (tvg_id + original start/end times stored in
# custom_properties). ProgramData.id changes on every EPG refresh so
@ -1160,37 +1169,74 @@ 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
if not rv_tvg:
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 series_title and not description:
result["details"].append({"tvg_id": rv_tvg, "status": "invalid_rule"})
continue
epg = EPGData.objects.filter(tvg_id=rv_tvg).first()
if not epg:
result["details"].append({"tvg_id": rv_tvg, "status": "no_epg_match"})
continue
programs_qs = ProgramData.objects.filter(
if rv_tvg:
epg = EPGData.objects.filter(tvg_id=rv_tvg).first()
if not epg:
result["details"].append({"tvg_id": rv_tvg, "status": "no_epg_match"})
continue
programs_qs = ProgramData.objects.filter(
epg=epg,
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,
else:
epg = None
programs_qs = ProgramData.objects.select_related("epg").filter(
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()
if not channel:
result["details"].append({"tvg_id": rv_tvg, "status": "no_channel_for_epg"})
continue
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:
pinned_channel = Channel.objects.filter(id=pinned_channel_id).first()
if pinned_channel is None:
result["details"].append({"tvg_id": rv_tvg, "status": "pinned_channel_missing", "channel_id": pinned_channel_id})
continue
channels_by_epg_id = None
elif rv_tvg:
pinned_channel = Channel.objects.filter(epg_data=epg).order_by("channel_number").first()
if not pinned_channel:
result["details"].append({"tvg_id": rv_tvg, "status": "no_channel_for_epg"})
continue
channels_by_epg_id = None
else:
pinned_channel = None
epg_ids = {p.epg_id for p in programs}
channels_by_epg_id = {}
for ch in Channel.objects.filter(epg_data_id__in=epg_ids).order_by("channel_number"):
if ch.epg_data_id not in channels_by_epg_id:
channels_by_epg_id[ch.epg_data_id] = ch
#
# Many providers list multiple future airings of the same episode
@ -1247,6 +1293,12 @@ def _evaluate_series_rules_locked(tvg_id, result):
created_here = 0
for prog in unique_programs:
try:
if pinned_channel is not None:
rec_channel = pinned_channel
else:
rec_channel = channels_by_epg_id.get(prog.epg_id)
if rec_channel is None:
continue
# Skip if a recording already exists for this exact airing
# (keyed by tvg_id + original program times, which are stable
# across EPG refreshes unlike ProgramData.id).
@ -1266,16 +1318,6 @@ def _evaluate_series_rules_locked(tvg_id, result):
except Exception:
continue # already scheduled/recorded
# Apply global DVR pre/post offsets (in minutes)
try:
pre_min = int(CoreSettings.get_dvr_pre_offset_minutes())
except Exception:
pre_min = 0
try:
post_min = int(CoreSettings.get_dvr_post_offset_minutes())
except Exception:
post_min = 0
adj_start = prog.start_time
adj_end = prog.end_time
try:
@ -1290,7 +1332,7 @@ def _evaluate_series_rules_locked(tvg_id, result):
pass
rec = Recording.objects.create(
channel=channel,
channel=rec_channel,
start_time=adj_start,
end_time=adj_end,
custom_properties={

View file

@ -1,5 +1,7 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
from rest_framework.test import APIClient
from rest_framework import status
@ -339,3 +341,166 @@ class ChannelManagerEffectiveValuesTests(TestCase):
helper_row.effective_channel_group_id,
shortcut_row.effective_channel_group_id,
)
class SeriesRuleAPITests(TestCase):
"""API tests for series rule CRUD and bulk-remove endpoints."""
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(username="admin_sr", password="pass")
self.admin.user_level = 10
self.admin.save()
self.client = APIClient()
self.client.force_authenticate(user=self.admin)
from core.models import CoreSettings
CoreSettings.set_dvr_series_rules([])
self.rules_url = "/api/channels/series-rules/"
self.bulk_remove_url = "/api/channels/series-rules/bulk-remove/"
# --- POST (create/upsert) ---
def test_create_rule_with_tvg_id(self):
resp = self.client.post(self.rules_url, {
"tvg_id": "some.channel", "title": "My Show", "mode": "all",
}, format="json")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(len(resp.data["rules"]), 1)
self.assertEqual(resp.data["rules"][0]["tvg_id"], "some.channel")
def test_create_title_only_rule_no_tvg_id(self):
"""A rule with no tvg_id (title-only) is accepted when title is provided."""
resp = self.client.post(self.rules_url, {
"tvg_id": "", "title": "Untethered Show", "mode": "all",
}, format="json")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
rule = resp.data["rules"][0]
self.assertEqual(rule["tvg_id"], "")
self.assertEqual(rule["title"], "Untethered Show")
def test_create_rule_requires_title_or_description(self):
resp = self.client.post(self.rules_url, {
"tvg_id": "some.channel", "title": "", "description": "",
}, format="json")
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
def test_upsert_key_is_tvg_id_and_title(self):
"""Two POST requests with same tvg_id but different titles create two rules."""
self.client.post(self.rules_url, {
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
}, format="json")
self.client.post(self.rules_url, {
"tvg_id": "ch.1", "title": "Show B", "mode": "all",
}, format="json")
resp = self.client.get(self.rules_url)
self.assertEqual(len(resp.data["rules"]), 2)
def test_upsert_updates_existing_rule(self):
"""POSTing with an existing (tvg_id, title) pair updates in place."""
self.client.post(self.rules_url, {
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
}, format="json")
self.client.post(self.rules_url, {
"tvg_id": "ch.1", "title": "Show A", "mode": "new",
}, format="json")
resp = self.client.get(self.rules_url)
self.assertEqual(len(resp.data["rules"]), 1)
self.assertEqual(resp.data["rules"][0]["mode"], "new")
# --- DELETE (query params) ---
def test_delete_rule_by_tvg_id_and_title(self):
self.client.post(self.rules_url, {
"tvg_id": "ch.1", "title": "Show A", "mode": "all",
}, format="json")
resp = self.client.delete(
self.rules_url + "?tvg_id=ch.1&title=Show+A"
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["rules"], [])
def test_delete_title_only_rule(self):
"""Title-only rules (tvg_id='') are deleted via empty tvg_id query param."""
self.client.post(self.rules_url, {
"tvg_id": "", "title": "Untethered Show", "mode": "all",
}, format="json")
resp = self.client.delete(
self.rules_url + "?tvg_id=&title=Untethered+Show"
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["rules"], [])
def test_delete_only_removes_matching_rule(self):
"""Delete by (tvg_id, title) leaves other rules intact."""
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show B", "mode": "all"}, format="json")
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
resp = self.client.get(self.rules_url)
self.assertEqual(len(resp.data["rules"]), 1)
self.assertEqual(resp.data["rules"][0]["title"], "Show B")
def test_delete_removes_future_recordings(self):
"""DELETE cleans up future recordings that matched the rule."""
from apps.channels.models import Recording
group = ChannelGroup.objects.create(name="G")
channel = Channel.objects.create(channel_number=1, name="Ch", channel_group=group)
now = timezone.now()
Recording.objects.create(
channel=channel,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
custom_properties={"program": {"tvg_id": "ch.1", "title": "Show A"}},
)
self.client.post(self.rules_url, {"tvg_id": "ch.1", "title": "Show A", "mode": "all"}, format="json")
self.client.delete(self.rules_url + "?tvg_id=ch.1&title=Show+A")
self.assertEqual(Recording.objects.count(), 0)
# --- POST bulk-remove ---
def test_bulk_remove_with_tvg_id(self):
from apps.channels.models import Recording
group = ChannelGroup.objects.create(name="G2")
channel = Channel.objects.create(channel_number=2, name="Ch2", channel_group=group)
now = timezone.now()
Recording.objects.create(
channel=channel,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
custom_properties={"program": {"tvg_id": "ch.x", "title": "Show X"}},
)
resp = self.client.post(self.bulk_remove_url, {"tvg_id": "ch.x"}, format="json")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["removed"], 1)
self.assertEqual(Recording.objects.count(), 0)
def test_bulk_remove_title_only_no_tvg_id(self):
"""Bulk-remove accepts title alone (no tvg_id) for title-only rules."""
from apps.channels.models import Recording
group = ChannelGroup.objects.create(name="G3")
channel = Channel.objects.create(channel_number=3, name="Ch3", channel_group=group)
now = timezone.now()
Recording.objects.create(
channel=channel,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
custom_properties={"program": {"tvg_id": "ch.a", "title": "Cross Show"}},
)
Recording.objects.create(
channel=channel,
start_time=now + timedelta(hours=3),
end_time=now + timedelta(hours=4),
custom_properties={"program": {"tvg_id": "ch.b", "title": "Cross Show"}},
)
resp = self.client.post(self.bulk_remove_url, {"title": "Cross Show"}, format="json")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["removed"], 2)
def test_bulk_remove_requires_tvg_id_or_title(self):
resp = self.client.post(self.bulk_remove_url, {}, format="json")
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)

View file

@ -716,3 +716,121 @@ class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase):
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
# ---------------------------------------------------------------------------
# Title-only rule tests: tvg_id is empty / omitted (cross-EPG matching)
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class TitleOnlyRuleTests(SeriesRuleDedupBaseTestCase):
"""Tests for series rules where tvg_id is omitted (searches all EPG channels)."""
def setUp(self):
super().setUp()
_set_series_rules([{
"tvg_id": "",
"mode": "all",
"title": "Test Show",
}])
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_title_only_rule_schedules_recording(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""A rule with no tvg_id matches programs on any EPG channel by title."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(Recording.objects.first().channel, self.channel)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_title_only_rule_matches_across_multiple_epg_channels(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Title-only rule creates a recording per matching EPG channel (distinct programs)."""
from apps.channels.tasks import evaluate_series_rules_impl
epg2 = EPGData.objects.create(
tvg_id="test.channel.2", name="Channel 2 EPG",
epg_source=self.epg_source,
)
Channel.objects.create(
channel_number=2, name="Test Channel 2", epg_data=epg2
)
start1 = self.now + timedelta(hours=2)
ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Test Show", sub_title="Episode 1",
)
start2 = self.now + timedelta(hours=3)
ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Test Show", sub_title="Episode 2",
)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 2)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_title_only_rule_dedup_after_epg_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Dedup works for title-only rules after EPG refresh reassigns program IDs."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_invalid_rule_no_title_no_description_skipped(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Rules with neither title nor description are skipped and flagged invalid."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_series_rules([{"tvg_id": "", "mode": "all", "title": "", "description": ""}])
self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 0)
statuses = [d.get("status") for d in result["details"]]
self.assertIn("invalid_rule", statuses)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_program_on_epg_with_no_channel_skipped(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Programs on an EPG source that has no Channel assigned are skipped gracefully."""
from apps.channels.tasks import evaluate_series_rules_impl
epg_orphan = EPGData.objects.create(
tvg_id="orphan.channel", name="Orphan EPG",
epg_source=self.epg_source,
)
start = self.now + timedelta(hours=2)
ProgramData.objects.create(
epg=epg_orphan, tvg_id="orphan.channel",
start_time=start, end_time=start + timedelta(hours=1),
title="Test Show", sub_title="Episode 1",
)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 0)

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

@ -3074,10 +3074,12 @@ export default class API {
}
}
static async deleteSeriesRule(tvgId) {
static async deleteSeriesRule(tvgId, title) {
try {
const encodedTvgId = encodeURIComponent(tvgId);
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, {
const params = new URLSearchParams();
if (tvgId) params.set('tvg_id', tvgId);
if (title) params.set('title', title);
await request(`${host}/api/channels/series-rules/?${params}`, {
method: 'DELETE',
});
notifications.show({ title: 'Series rule removed' });
@ -3115,6 +3117,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);
@ -35,7 +39,7 @@ export default function ProgramRecordingModal({
};
const handleRemoveSeriesRule = async () => {
await deleteSeriesRuleByTvgId(program.tvg_id);
await deleteSeriesRuleByTvgId(program.tvg_id, program.title);
onExistingRuleModeChange(null);
onClose();
};
@ -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,423 @@
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 &&
!debouncedPreviewKey.title &&
!debouncedPreviewKey.description
) {
setPreview({ matches: [], total: 0, epg_found: true });
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.title || payload.description);
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 ? '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 (optional)"
description="Limit matching to a specific EPG channel. Leave blank to search all channels."
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)
);
}}
/>
<Stack gap={4}>
<TextInput
label="Title (optional)"
description="At least a title or description is required."
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>
)}
{preview.warn && !previewLoading && (
<Alert color="orange" variant="light">
This rule matches many programs. Consider selecting a specific EPG
channel or adding more search criteria.
</Alert>
)}
<ScrollArea.Autosize mah={240}>
<Stack gap={4}>
{(preview.matches || []).map((p) => (
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
<Stack gap={0} style={{ minWidth: 160 }}>
<Text size="xs" c="dimmed">
{formatRange(p.start_time, p.end_time)}
</Text>
{p.tvg_id && !tvgId && (
<Text size="xs" c="dimmed" fs="italic">
{p.tvg_id}
</Text>
)}
</Stack>
<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 || title || description) &&
preview.epg_found !== false && (
<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')
}

View file

@ -1282,10 +1282,22 @@ describe('guideUtils', () => {
});
describe('deleteSeriesRuleByTvgId', () => {
it('should delete series rule via API', async () => {
it('should delete series rule via API with tvg_id and title', async () => {
await guideUtils.deleteSeriesRuleByTvgId('tvg-1', 'My Show');
expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', 'My Show');
});
it('should forward undefined title when not provided', async () => {
await guideUtils.deleteSeriesRuleByTvgId('tvg-1');
expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1');
expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', undefined);
});
it('should work with empty tvg_id for title-only rules', async () => {
await guideUtils.deleteSeriesRuleByTvgId('', 'Title-Only Show');
expect(API.deleteSeriesRule).toHaveBeenCalledWith('', 'Title-Only Show');
});
});

View file

@ -84,21 +84,19 @@ export const extendRecordingById = async (recordingId, extraMinutes) => {
export const deleteSeriesAndRule = async (seriesInfo) => {
const { tvg_id, title } = seriesInfo;
if (tvg_id) {
try {
await API.bulkRemoveSeriesRecordings({
tvg_id,
title,
scope: 'title',
});
} catch (error) {
console.error('Failed to remove series recordings', error);
}
try {
await API.deleteSeriesRule(tvg_id);
} catch (error) {
console.error('Failed to delete series rule', error);
}
try {
await API.bulkRemoveSeriesRecordings({
tvg_id: tvg_id || '',
title,
scope: 'title',
});
} catch (error) {
console.error('Failed to remove series recordings', error);
}
try {
await API.deleteSeriesRule(tvg_id, title);
} catch (error) {
console.error('Failed to delete series rule', error);
}
};

View file

@ -198,16 +198,28 @@ describe('RecordingCardUtils', () => {
title: 'Test Series',
scope: 'title',
});
expect(API.deleteSeriesRule).toHaveBeenCalledWith('series-123');
expect(API.deleteSeriesRule).toHaveBeenCalledWith(
'series-123',
'Test Series'
);
});
it('does nothing when tvg_id is not provided', async () => {
const seriesInfo = { title: 'Test Series' };
it('works for title-only rules with no tvg_id', async () => {
API.bulkRemoveSeriesRecordings.mockResolvedValue();
API.deleteSeriesRule.mockResolvedValue();
const seriesInfo = { tvg_id: undefined, title: 'Title-Only Show' };
await deleteSeriesAndRule(seriesInfo);
expect(API.bulkRemoveSeriesRecordings).not.toHaveBeenCalled();
expect(API.deleteSeriesRule).not.toHaveBeenCalled();
expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({
tvg_id: '',
title: 'Title-Only Show',
scope: 'title',
});
expect(API.deleteSeriesRule).toHaveBeenCalledWith(
undefined,
'Title-Only Show'
);
});
it('handles bulk remove error gracefully', async () => {

View file

@ -420,8 +420,8 @@ export const formatSeasonEpisode = (season, episode) => {
return null;
};
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
await API.deleteSeriesRule(tvg_id);
export const deleteSeriesRuleByTvgId = async (tvg_id, title) => {
await API.deleteSeriesRule(tvg_id, title);
};
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {