diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3d0df5..5628d2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 756e2341..ff00e0f7 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -17,7 +17,7 @@ from .api_views import ( GetChannelStreamsAPIView, GetChannelStreamStatsAPIView, SeriesRulesAPIView, - DeleteSeriesRuleAPIView, + SeriesRulePreviewAPIView, EvaluateSeriesRulesAPIView, BulkRemoveSeriesRecordingsAPIView, BulkDeleteUpcomingRecordingsAPIView, @@ -47,9 +47,9 @@ urlpatterns = [ path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), # DVR series rules (order matters: specific routes before catch-all slug) path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'), + path('series-rules/preview/', SeriesRulePreviewAPIView.as_view(), name='series_rules_preview'), path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'), path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'), - path('series-rules//', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), path( 'recordings//hls/', diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5eaeab35..882e90b9 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 08c7737b..b039d478 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -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={ diff --git a/apps/channels/tests/test_channel_api.py b/apps/channels/tests/test_channel_api.py index 052560a0..363f5925 100644 --- a/apps/channels/tests/test_channel_api.py +++ b/apps/channels/tests/test_channel_api.py @@ -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) diff --git a/apps/channels/tests/test_series_rule_dedup.py b/apps/channels/tests/test_series_rule_dedup.py index 33aff695..9c498f99 100644 --- a/apps/channels/tests/test_series_rule_dedup.py +++ b/apps/channels/tests/test_series_rule_dedup.py @@ -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) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 1a43b938..296f91b8 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -19,6 +19,7 @@ from .serializers import ( ProgramSearchResultSerializer, ) from .tasks import refresh_epg_data +from .query_utils import parse_text_query from apps.accounts.permissions import ( Authenticated, IsStandardUser, @@ -110,6 +111,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet): # ───────────────────────────── # 2) Program API (CRUD) # ───────────────────────────── +class ProgramSearchPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = 'page_size' + max_page_size = 500 + + class ProgramViewSet(viewsets.ModelViewSet): """Handles CRUD operations for EPG programs""" @@ -199,6 +206,7 @@ class ProgramViewSet(viewsets.ModelViewSet): OpenApiParameter('airing_at', OpenApiTypes.DATETIME, description='Find programs airing at this exact moment (start_time ≤ airing_at < end_time). ISO 8601 format, e.g. `2026-02-14T20:00:00Z`.'), OpenApiParameter('channel', OpenApiTypes.STR, description='Filter by channel name (case-insensitive substring match). e.g. `BBC One`, `Sky Sports`.'), OpenApiParameter('channel_id', OpenApiTypes.INT, description='Filter by exact channel ID.'), + OpenApiParameter('tvg_id', OpenApiTypes.STR, description='Filter by EPG tvg_id (exact match). e.g. `bbcone.uk`.'), OpenApiParameter('stream', OpenApiTypes.STR, description='Filter by stream name (case-insensitive substring match).'), OpenApiParameter('group', OpenApiTypes.STR, description='Filter by channel group or stream group name (case-insensitive substring match). e.g. `Sports`, `UK Channels`.'), OpenApiParameter('epg_source', OpenApiTypes.INT, description='Filter by EPG source ID.'), @@ -229,13 +237,13 @@ class ProgramViewSet(viewsets.ModelViewSet): if title: title_regex = params.get('title_regex', '').lower() in ('true', '1', 'yes') title_whole_words = params.get('title_whole_words', '').lower() in ('true', '1', 'yes') - filters &= _parse_text_query('title', title, use_regex=title_regex, whole_words=title_whole_words) + filters &= parse_text_query('title', title, use_regex=title_regex, whole_words=title_whole_words) description = params.get('description') if description: desc_regex = params.get('description_regex', '').lower() in ('true', '1', 'yes') desc_whole_words = params.get('description_whole_words', '').lower() in ('true', '1', 'yes') - filters &= _parse_text_query('description', description, use_regex=desc_regex, whole_words=desc_whole_words) + filters &= parse_text_query('description', description, use_regex=desc_regex, whole_words=desc_whole_words) # Time filters with validation start_after = params.get('start_after') @@ -300,6 +308,10 @@ class ProgramViewSet(viewsets.ModelViewSet): except (ValueError, TypeError): pass + tvg_id = params.get('tvg_id') + if tvg_id: + filters &= Q(epg__tvg_id=tvg_id) + stream = params.get('stream') if stream: filters &= Q(epg__channels__streams__name__icontains=stream) @@ -765,180 +777,3 @@ class CurrentProgramsAPIView(APIView): return Response(current_programs, status=status.HTTP_200_OK) - -# ───────────────────────────── -# 7) Program Search API -# ───────────────────────────── - - -def _build_q_object(field_name, term, use_regex=False, whole_words=False): - """ - Build a single Q object for a search term. - - Args: - field_name: Django ORM field name - term: Search term - use_regex: If True, use regex matching - whole_words: If True, use word boundary matching - """ - term = term.strip() - if not term: - return Q() - - if use_regex: - # Use Django's __iregex (case-insensitive regex) - return Q(**{f'{field_name}__iregex': term}) - elif whole_words: - # Word boundary syntax differs by database engine: - # PostgreSQL uses \y (or \m/\M); Python re (SQLite) uses \b. - from django.db import connection - if connection.vendor == 'postgresql': - boundary = r'\y' - else: - boundary = r'\b' - pattern = boundary + re.escape(term) + boundary - return Q(**{f'{field_name}__iregex': pattern}) - else: - # Standard case-insensitive contains - return Q(**{f'{field_name}__icontains': term}) - - -def _parse_text_query(field_name, raw_value, use_regex=False, whole_words=False): - """ - Parse a text search value with AND/OR operators (including nested groups, parentheses, - and quoted phrases) into a Q object. - - Quoted phrases (double-quoted) are treated as atomic literals and are never split on - AND/OR. Outside of quotes, AND and OR are case-insensitive boolean operators. - - Examples: - "sports AND football" → Q(field__icontains="sports") & Q(field__icontains="football") - "news or weather" → Q(field__icontains="news") | Q(field__icontains="weather") - '"Law and Order"' → Q(field__icontains="Law and Order") [quoted phrase] - '"Law and Order" AND crime' → phrase AND bare term - "(Newcastle OR NEW) AND (Villa OR AST)" → Grouped nested operations - "breaking news" → Q(field__icontains="breaking news") [unquoted phrase match] - - Args: - field_name: Django ORM field name to query - raw_value: Text value with optional AND/OR operators and quoted phrases - use_regex: If True, use regex matching instead of icontains - whole_words: If True, match whole words only (requires word boundaries) - - Supports mixed operators evaluated left-to-right: "sports and football or basketball" - Supports nested groups: "(A OR B) AND (C OR D)" - """ - - # Step 1: Extract quoted phrases into a lookup dict and replace with opaque placeholders. - # This lets AND/OR detection be case-insensitive without splitting phrases like - # "Law and Order" that happen to contain conjunctions. - phrases = {} - - def extract_quoted(text): - def replacer(m): - key = f'\x00P{len(phrases)}\x00' - phrases[key] = m.group(1) - return key - return re.sub(r'"([^"]*)"', replacer, text) - - processed = extract_quoted(raw_value) - - def build_q(token): - """Build a Q object, resolving any quoted-phrase placeholder first.""" - return _build_q_object(field_name, phrases.get(token, token), use_regex, whole_words) - - def parse_expression(expr): - """Recursively parse expression with parentheses support.""" - expr = expr.strip() - - # Handle parentheses: parse the innermost group into a Q object, then - # recursively combine it with the expressions on either side. - if '(' in expr: - paren_start = expr.rfind('(') - paren_end = expr.find(')', paren_start) - if paren_end == -1: - return Q() # Mismatched parentheses - - group_q = parse_expression(expr[paren_start + 1:paren_end]) - - before_str = expr[:paren_start].rstrip() - after_str = expr[paren_end + 1:].lstrip() - - # Operator connecting before-expression to the group (case-insensitive) - before_op = '&' - if before_str.upper().endswith(' AND'): - before_str = before_str[:-4].rstrip() - elif before_str.upper().endswith(' OR'): - before_str = before_str[:-3].rstrip() - before_op = '|' - - # Operator connecting the group to the after-expression (case-insensitive) - after_op = '&' - after_upper = after_str.upper() - if after_upper.startswith('AND '): - after_str = after_str[4:].lstrip() - elif after_upper.startswith('OR '): - after_str = after_str[3:].lstrip() - after_op = '|' - - result = group_q - if before_str: - before_q = parse_expression(before_str) - result = (before_q | result) if before_op == '|' else (before_q & result) - if after_str: - after_q = parse_expression(after_str) - result = (result | after_q) if after_op == '|' else (result & after_q) - return result - - # No parentheses: tokenize on AND/OR boundaries (case-insensitive). - # Quoted phrases have been replaced with placeholders and are never split. - tokens = [] - operators = [] - remaining = expr - - while remaining: - upper = remaining.upper() - and_pos = upper.find(' AND ') - or_pos = upper.find(' OR ') - - if and_pos == -1 and or_pos == -1: - tokens.append(remaining.strip()) - break - - if and_pos == -1: - pos, op, op_len = or_pos, '|', 4 - elif or_pos == -1: - pos, op, op_len = and_pos, '&', 5 - elif and_pos < or_pos: - pos, op, op_len = and_pos, '&', 5 - else: - pos, op, op_len = or_pos, '|', 4 - - token = remaining[:pos].strip() - if token: - tokens.append(token) - operators.append(op) - remaining = remaining[pos + op_len:] - - if not tokens: - return Q() - - # Build Q chain - result = build_q(tokens[0]) - for i, op in enumerate(operators): - next_q = build_q(tokens[i + 1]) - if op == '&': - result = result & next_q - else: - result = result | next_q - - return result - - return parse_expression(processed) - - -class ProgramSearchPagination(PageNumberPagination): - page_size = 50 - page_size_query_param = 'page_size' - max_page_size = 500 - diff --git a/apps/epg/query_utils.py b/apps/epg/query_utils.py new file mode 100644 index 00000000..f2f06b9a --- /dev/null +++ b/apps/epg/query_utils.py @@ -0,0 +1,137 @@ +"""Shared query parsing helpers for EPG program text search. + +Used by the EPG search API and the DVR series rule evaluator to apply +the same boolean / quoted-phrase / regex / whole-word matching semantics. + +Public API: + parse_text_query(field, raw_value, use_regex=False, whole_words=False) -> Q +""" + +import re + +from django.db.models import Q + + +def build_q_object(field_name, term, use_regex=False, whole_words=False): + """Build a single Q object for one search term. + + `whole_words` uses `\\y` on PostgreSQL and `\\b` everywhere else so the + pattern actually anchors on a word boundary regardless of backend. + """ + term = term.strip() + if not term: + return Q() + + if use_regex: + return Q(**{f'{field_name}__iregex': term}) + if whole_words: + from django.db import connection + boundary = r'\y' if connection.vendor == 'postgresql' else r'\b' + pattern = boundary + re.escape(term) + boundary + return Q(**{f'{field_name}__iregex': pattern}) + return Q(**{f'{field_name}__icontains': term}) + + +def parse_text_query(field_name, raw_value, use_regex=False, whole_words=False): + """Parse a search expression into a Q object. + + Supports: + - AND / OR (case-insensitive) between bare terms. + - Double-quoted phrases (atomic; never split on operators). + - Parenthetical grouping with arbitrary nesting. + - Regex mode (entire raw value is treated as a single regex). + - Whole-word mode (each bare term anchored with word boundaries). + + A bare value with no operators is matched as a single phrase via + icontains (or regex / whole-word as configured). + """ + phrases = {} + + def extract_quoted(text): + def replacer(m): + key = f'\x00P{len(phrases)}\x00' + phrases[key] = m.group(1) + return key + return re.sub(r'"([^"]*)"', replacer, text) + + processed = extract_quoted(raw_value) + + def build_q(token): + return build_q_object(field_name, phrases.get(token, token), use_regex, whole_words) + + def parse_expression(expr): + expr = expr.strip() + + if '(' in expr: + paren_start = expr.rfind('(') + paren_end = expr.find(')', paren_start) + if paren_end == -1: + return Q() + + group_q = parse_expression(expr[paren_start + 1:paren_end]) + + before_str = expr[:paren_start].rstrip() + after_str = expr[paren_end + 1:].lstrip() + + before_op = '&' + if before_str.upper().endswith(' AND'): + before_str = before_str[:-4].rstrip() + elif before_str.upper().endswith(' OR'): + before_str = before_str[:-3].rstrip() + before_op = '|' + + after_op = '&' + after_upper = after_str.upper() + if after_upper.startswith('AND '): + after_str = after_str[4:].lstrip() + elif after_upper.startswith('OR '): + after_str = after_str[3:].lstrip() + after_op = '|' + + result = group_q + if before_str: + before_q = parse_expression(before_str) + result = (before_q | result) if before_op == '|' else (before_q & result) + if after_str: + after_q = parse_expression(after_str) + result = (result | after_q) if after_op == '|' else (result & after_q) + return result + + tokens = [] + operators = [] + remaining = expr + + while remaining: + upper = remaining.upper() + and_pos = upper.find(' AND ') + or_pos = upper.find(' OR ') + + if and_pos == -1 and or_pos == -1: + tokens.append(remaining.strip()) + break + + if and_pos == -1: + pos, op, op_len = or_pos, '|', 4 + elif or_pos == -1: + pos, op, op_len = and_pos, '&', 5 + elif and_pos < or_pos: + pos, op, op_len = and_pos, '&', 5 + else: + pos, op, op_len = or_pos, '|', 4 + + token = remaining[:pos].strip() + if token: + tokens.append(token) + operators.append(op) + remaining = remaining[pos + op_len:] + + if not tokens: + return Q() + + result = build_q(tokens[0]) + for i, op in enumerate(operators): + next_q = build_q(tokens[i + 1]) + result = (result & next_q) if op == '&' else (result | next_q) + return result + + return parse_expression(processed) diff --git a/frontend/src/api.js b/frontend/src/api.js index 6690b07c..6c003bf3 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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, diff --git a/frontend/src/components/forms/ProgramRecordingModal.jsx b/frontend/src/components/forms/ProgramRecordingModal.jsx index 82da2674..1ce58fb9 100644 --- a/frontend/src/components/forms/ProgramRecordingModal.jsx +++ b/frontend/src/components/forms/ProgramRecordingModal.jsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { Modal, Flex, Button } from '@mantine/core'; +import React, { useState } from 'react'; +import { Modal, Flex, Button, Anchor } from '@mantine/core'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js'; import { deleteSeriesRuleByTvgId } from '../../utils/guideUtils.js'; +import SeriesRuleEditorModal from './SeriesRuleEditorModal.jsx'; export default function ProgramRecordingModal({ opened, @@ -10,11 +11,14 @@ export default function ProgramRecordingModal({ program, recording, existingRuleMode, + existingRule, onRecordOne, onRecordSeriesAll, onRecordSeriesNew, onExistingRuleModeChange, }) { + const [editorOpen, setEditorOpen] = useState(false); + const handleRemoveRecording = async () => { try { await deleteRecordingById(recording.id); @@ -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 + setEditorOpen(true)} + > + Customize rule... + + {recording && ( <> - - - - ))} - - + <> + + + + + + + {(!rules || rules.length === 0) && ( + + No series rules configured + + )} + {rules && + rules.map((r) => ( + + + + + {r.title || r.tvg_id} + + {r.channel_id && ( + + Pinned channel + + )} + + + {renderRuleSummary(r)} + + + + + + + + + ))} + + + + setEditorOpen(false)} + initialRule={editorRule} + onSaved={handleEditorSaved} + /> + ); } diff --git a/frontend/src/components/forms/SeriesRuleEditorModal.jsx b/frontend/src/components/forms/SeriesRuleEditorModal.jsx new file mode 100644 index 00000000..201f285f --- /dev/null +++ b/frontend/src/components/forms/SeriesRuleEditorModal.jsx @@ -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 ( + + +