From 22978aa1327c2b805110ea2df4c0926eb909c162 Mon Sep 17 00:00:00 2001 From: Northern Powerhouse Date: Sat, 14 Feb 2026 15:48:02 +0100 Subject: [PATCH] Add EPG program search API endpoint with advanced filtering - Add ProgramSearchResultSerializer with nested channel/stream data - Implement ProgramSearchAPIView with comprehensive filtering: * Text search with AND/OR operators and parenthetical grouping * Regex pattern matching support * Whole word matching to avoid partial matches * Time-based filtering (airing_at, start/end ranges) * Channel/stream/group filtering * Configurable field selection for response customization * Pagination (50 default, 500 max per page) - Add /api/epg/programs/search/ endpoint - Add comprehensive API documentation - Enhanced Swagger/OpenAPI schema with examples Examples: - Complex queries: (Newcastle OR NEW) AND (Villa OR AST) - Whole words: title=NEW&title_whole_words=true - Regex: title=^Premier&title_regex=true - Time filtering: airing_at=2026-02-14T20:00:00Z --- apps/epg/api_urls.py | 3 +- apps/epg/api_views.py | 375 +++++++++++++++++++++++++++ apps/epg/serializers.py | 54 +++- docs/EPG_PROGRAM_SEARCH_API.md | 448 +++++++++++++++++++++++++++++++++ 4 files changed, 878 insertions(+), 2 deletions(-) create mode 100644 docs/EPG_PROGRAM_SEARCH_API.md diff --git a/apps/epg/api_urls.py b/apps/epg/api_urls.py index ed4b3105..bb688abf 100644 --- a/apps/epg/api_urls.py +++ b/apps/epg/api_urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView +from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView, ProgramSearchAPIView app_name = 'epg' @@ -13,6 +13,7 @@ urlpatterns = [ path('grid/', EPGGridAPIView.as_view(), name='epg_grid'), path('import/', EPGImportAPIView.as_view(), name='epg_import'), path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'), + path('programs/search/', ProgramSearchAPIView.as_view(), name='program_search'), ] urlpatterns += router.urls diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 00f7403f..866b5968 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,17 +1,21 @@ import logging, os from rest_framework import viewsets, status, serializers +from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.types import OpenApiTypes +from django.db.models import Q from django.utils import timezone +from django.utils.dateparse import parse_datetime from datetime import timedelta from .models import EPGSource, ProgramData, EPGData # Added ProgramData from .serializers import ( ProgramDataSerializer, EPGSourceSerializer, EPGDataSerializer, + ProgramSearchResultSerializer, ) # Updated serializer from .tasks import refresh_epg_data from apps.accounts.permissions import ( @@ -500,5 +504,376 @@ class CurrentProgramsAPIView(APIView): program_data['channel_id'] = channel.id current_programs.append(program_data) + return Response(current_programs, status=status.HTTP_200_OK) + +# ───────────────────────────── +# 7) Program Search API +# ───────────────────────────── +import re as regex_module + + +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: + # Use word boundary regex (case-insensitive) + pattern = r'\b' + regex_module.escape(term) + r'\b' + 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 with parentheses) into a Q object. + + Examples: + "sports AND football" → Q(field__icontains="sports") & Q(field__icontains="football") + "news OR weather" → Q(field__icontains="news") | Q(field__icontains="weather") + "(Newcastle OR NEW) AND (Villa OR AST)" → Grouped nested operations + "breaking news" → Q(field__icontains="breaking") & Q(field__icontains="news") [default AND] + + Args: + field_name: Django ORM field name to query + raw_value: Text value with optional AND/OR operators + 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)" + """ + + def parse_expression(expr): + """Recursively parse expression with parentheses support""" + expr = expr.strip() + + # Handle parentheses by recursively processing innermost groups + while '(' in expr: + paren_start = expr.rfind('(') + paren_end = expr.find(')', paren_start) + if paren_end == -1: + return Q() # Mismatched parentheses + + # Recursively parse the group + group_expr = expr[paren_start + 1:paren_end] + group_result = parse_expression(group_expr) + + # Replace group with placeholder to avoid re-parsing + # We build up results as we go + before = expr[:paren_start] + after = expr[paren_end + 1:] + + # For now, we need to handle this differently - evaluate left to right + # Extract operators around the group + if before: + before = before.rstrip() + if before.upper().endswith(' AND'): + before = before[:-5].rstrip() + operator = ' AND ' + elif before.upper().endswith(' OR'): + before = before[:-3].rstrip() + operator = ' OR ' + else: + operator = None + else: + operator = None + before = None + + if after: + after = after.lstrip() + if after.upper().startswith('AND '): + after = after[4:].lstrip() + operator = ' AND ' if not operator else operator + elif after.upper().startswith('OR '): + after = after[3:].lstrip() + operator = ' OR ' if not operator else operator + else: + after = None + + # Reconstruct without parentheses for simpler processing + parts = [before, after] + expr = ' AND '.join(p for p in parts if p) + + # Tokenize on " AND " and " OR " boundaries (no parentheses now) + tokens = [] + operators = [] + remaining = expr + + while remaining: + and_pos = remaining.upper().find(' AND ') + or_pos = remaining.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_object(field_name, tokens[0], use_regex, whole_words) + for i, op in enumerate(operators): + next_q = _build_q_object(field_name, tokens[i + 1], use_regex, whole_words) + if op == '&': + result = result & next_q + else: + result = result | next_q + + return result + + return parse_expression(raw_value) + + +class ProgramSearchPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = 'page_size' + max_page_size = 500 + + +class ProgramSearchAPIView(APIView): + """ + Advanced search for EPG programs with complex filtering capabilities. + + Features: + - **Text Search**: Title/description search with AND/OR operators, parenthetical grouping, regex, and whole-word matching + - **Time Filtering**: Find programs airing at specific times or within time ranges + - **Channel/Stream Filtering**: Filter by channel, stream, or group names + - **Field Selection**: Customize response to include only needed fields + - **Pagination**: Results paginated (default 50, max 500 per page) + + Text Query Syntax: + - Simple: `title=football` + - AND: `title=premier AND league` + - OR: `title=Newcastle OR Villa` + - Nested: `title=(Newcastle OR NEW) AND (Villa OR AST)` + - Regex: `title=^Premier&title_regex=true` + - Whole words: `title=NEW&title_whole_words=true` + + Examples: + - Find matches airing now: `?title=football&airing_at=2026-02-14T20:00:00Z` + - Complex search: `?title=(Newcastle OR NEW) AND (Villa OR AST)&airing_at=2026-02-14T20:00:00Z` + - Channel-specific: `?channel=BBC One&start_after=2026-02-14T18:00:00Z` + - Minimal response: `?title=sports&fields=title,start_time,end_time` + """ + pagination_class = ProgramSearchPagination + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="Search EPG programs", + description=""" +**Advanced EPG program search with multiple filter types and complex query support.** + +### Text Search Features + +**Title and Description Search**: +- Supports AND/OR logical operators +- Parenthetical grouping for complex queries: `(Newcastle OR NEW) AND (Villa OR AST)` +- Regex pattern matching with `title_regex=true` +- Whole word matching with `title_whole_words=true` to avoid partial matches + +**Examples**: +- Simple: `title=football` +- AND operator: `title=premier AND league` +- OR operator: `title=Newcastle OR Villa` +- Nested groups: `title=(Newcastle OR NEW) AND (Villa OR AST)` +- Regex: `title=^Premier&title_regex=true` (programs starting with "Premier") +- Whole words: `title=NEW&title_whole_words=true` (matches "NEW" but not "News") + +### Time Filtering + +**airing_at**: Find programs airing at a specific moment (start_time ≤ airing_at < end_time) + +**Time ranges**: Use combinations of start_after, start_before, end_after, end_before + +### Response Customization + +**fields**: Comma-separated list to include only specific fields in response +- Available: id, title, sub_title, description, start_time, end_time, tvg_id, custom_properties, epg_source, epg_name, epg_icon_url, channels, streams + +### Pagination + +- Default: 50 results per page +- Maximum: 500 results per page +- Use `page` and `page_size` parameters to navigate results + """, + parameters=[ + OpenApiParameter( + 'title', + OpenApiTypes.STR, + description='Title search query. Supports AND/OR operators and parentheses: `(Newcastle OR NEW) AND (Villa OR AST)`. Space-separated terms default to AND.', + examples=[ + 'football', + 'premier AND league', + 'Newcastle OR Villa', + '(Newcastle OR NEW) AND (Villa OR AST)' + ] + ), + OpenApiParameter('title_regex', OpenApiTypes.BOOL, description='Enable regex matching for title (case-insensitive). Example: `^The` matches titles starting with "The".'), + OpenApiParameter('title_whole_words', OpenApiTypes.BOOL, description='Match whole words only in title. Prevents "NEW" from matching "News".'), + OpenApiParameter( + 'description', + OpenApiTypes.STR, + description='Description search query. Same syntax and features as title search.' + ), + OpenApiParameter('description_regex', OpenApiTypes.BOOL, description='Enable regex matching for description (case-insensitive).'), + OpenApiParameter('description_whole_words', OpenApiTypes.BOOL, description='Match whole words only in description.'), + OpenApiParameter('start_after', OpenApiTypes.DATETIME, description='Filter programs starting at or after this time. ISO 8601 format.', examples=['2026-02-14T18:00:00Z']), + OpenApiParameter('start_before', OpenApiTypes.DATETIME, description='Filter programs starting at or before this time. ISO 8601 format.'), + OpenApiParameter('end_after', OpenApiTypes.DATETIME, description='Filter programs ending at or after this time. ISO 8601 format.'), + OpenApiParameter('end_before', OpenApiTypes.DATETIME, description='Filter programs ending at or before this time. ISO 8601 format.'), + OpenApiParameter('airing_at', OpenApiTypes.DATETIME, description='Find programs airing at this exact moment (start_time ≤ airing_at < end_time). ISO 8601 format.', examples=['2026-02-14T20:00:00Z']), + OpenApiParameter('channel', OpenApiTypes.STR, description='Filter by channel name (case-insensitive substring match).', examples=['BBC One', 'Sky Sports']), + OpenApiParameter('channel_id', OpenApiTypes.INT, description='Filter by exact channel ID.'), + 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).', examples=['Sports', 'UK Channels']), + OpenApiParameter('epg_source', OpenApiTypes.INT, description='Filter by EPG source ID.'), + OpenApiParameter('fields', OpenApiTypes.STR, description='Comma-separated list of fields to include. Omit to return all fields.', examples=['title,start_time,end_time', 'title,description,channels']), + OpenApiParameter('page', OpenApiTypes.INT, description='Page number for pagination (default: 1).'), + OpenApiParameter('page_size', OpenApiTypes.INT, description='Results per page (default: 50, max: 500).'), + ], + responses={200: ProgramSearchResultSerializer(many=True)}, + tags=['EPG'], + ) + def get(self, request, format=None): + params = request.query_params + + # Build base queryset with prefetching + queryset = ProgramData.objects.select_related( + 'epg', 'epg__epg_source' + ).prefetch_related( + 'epg__channels', 'epg__channels__channel_group', + 'epg__channels__streams', 'epg__channels__streams__channel_group', + 'epg__channels__streams__m3u_account', + ) + + filters = Q() + + # Text filters + title = params.get('title') + 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) + + 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) + + # Time filters + start_after = params.get('start_after') + if start_after: + dt = parse_datetime(start_after) + if dt: + filters &= Q(start_time__gte=dt) + + start_before = params.get('start_before') + if start_before: + dt = parse_datetime(start_before) + if dt: + filters &= Q(start_time__lte=dt) + + end_after = params.get('end_after') + if end_after: + dt = parse_datetime(end_after) + if dt: + filters &= Q(end_time__gte=dt) + + end_before = params.get('end_before') + if end_before: + dt = parse_datetime(end_before) + if dt: + filters &= Q(end_time__lte=dt) + + airing_at = params.get('airing_at') + if airing_at: + dt = parse_datetime(airing_at) + if dt: + filters &= Q(start_time__lte=dt, end_time__gt=dt) + + # Channel/stream filters + channel = params.get('channel') + if channel: + filters &= Q(epg__channels__name__icontains=channel) + + channel_id = params.get('channel_id') + if channel_id: + try: + filters &= Q(epg__channels__id=int(channel_id)) + except (ValueError, TypeError): + pass + + stream = params.get('stream') + if stream: + filters &= Q(epg__channels__streams__name__icontains=stream) + + group = params.get('group') + if group: + filters &= ( + Q(epg__channels__channel_group__name__icontains=group) + | Q(epg__channels__streams__channel_group__name__icontains=group) + ) + + epg_source = params.get('epg_source') + if epg_source: + try: + filters &= Q(epg__epg_source__id=int(epg_source)) + except (ValueError, TypeError): + pass + + queryset = queryset.filter(filters).distinct().order_by('start_time') + + # Paginate + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request) + serializer = ProgramSearchResultSerializer(page, many=True) + data = serializer.data + + # Field selection + requested_fields = params.get('fields') + if requested_fields: + allowed = set(f.strip() for f in requested_fields.split(',')) + data = [{k: v for k, v in item.items() if k in allowed} for item in data] + + return paginator.get_paginated_response(data) + diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index e4d5f466..83cd5a7c 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -1,7 +1,7 @@ from core.utils import validate_flexible_url from rest_framework import serializers from .models import EPGSource, EPGData, ProgramData -from apps.channels.models import Channel +from apps.channels.models import Channel, Stream class EPGSourceSerializer(serializers.ModelSerializer): epg_data_count = serializers.SerializerMethodField() @@ -58,3 +58,55 @@ class EPGDataSerializer(serializers.ModelSerializer): 'icon_url', 'epg_source', ] + + +class ProgramSearchChannelSerializer(serializers.ModelSerializer): + """Lightweight channel info for search results.""" + channel_group = serializers.CharField(source='channel_group.name', default=None) + + class Meta: + model = Channel + fields = ['id', 'name', 'channel_number', 'channel_group', 'tvg_id'] + + +class ProgramSearchStreamSerializer(serializers.ModelSerializer): + """Lightweight stream info for search results.""" + channel_group = serializers.CharField(source='channel_group.name', default=None) + m3u_account = serializers.CharField(source='m3u_account.name', default=None) + + class Meta: + model = Stream + fields = ['id', 'name', 'channel_group', 'tvg_id', 'm3u_account'] + + +class ProgramSearchResultSerializer(serializers.ModelSerializer): + """Full program data with associated channels and streams for search results.""" + epg_source = serializers.CharField(source='epg.epg_source.name', default=None) + epg_name = serializers.CharField(source='epg.name', default=None) + epg_icon_url = serializers.URLField(source='epg.icon_url', default=None) + channels = serializers.SerializerMethodField() + streams = serializers.SerializerMethodField() + + class Meta: + model = ProgramData + fields = [ + 'id', 'title', 'sub_title', 'description', + 'start_time', 'end_time', 'tvg_id', 'custom_properties', + 'epg_source', 'epg_name', 'epg_icon_url', + 'channels', 'streams', + ] + + def get_channels(self, obj): + channels = obj.epg.channels.all() if obj.epg else [] + return ProgramSearchChannelSerializer(channels, many=True).data + + def get_streams(self, obj): + channels = obj.epg.channels.all() if obj.epg else [] + stream_ids = set() + streams = [] + for ch in channels: + for s in ch.streams.all(): + if s.id not in stream_ids: + stream_ids.add(s.id) + streams.append(s) + return ProgramSearchStreamSerializer(streams, many=True).data diff --git a/docs/EPG_PROGRAM_SEARCH_API.md b/docs/EPG_PROGRAM_SEARCH_API.md new file mode 100644 index 00000000..118af9ba --- /dev/null +++ b/docs/EPG_PROGRAM_SEARCH_API.md @@ -0,0 +1,448 @@ +# EPG Program Search API + +## Overview + +The EPG Program Search API provides powerful filtering and search capabilities for EPG (Electronic Program Guide) program data. It supports complex text queries with AND/OR operators, parenthetical grouping, regex patterns, time-based filtering, and channel/stream matching. + +**Endpoint:** `GET /api/epg/programs/search/` + +**Authentication:** Required (JWT Bearer token) + +--- + +## Query Parameters + +### Text Search Parameters + +#### **`title`** (string, optional) +Search program titles with support for: +- Simple terms: `title=football` +- AND operator: `title=premier AND league` +- OR operator: `title=Newcastle OR Villa` +- Nested groups with parentheses: `title=(Newcastle OR NEW) AND (Villa OR AST)` +- Default behavior: Space-separated terms use AND + +**Examples:** +``` +title=sports +title=premier AND league +title=Newcastle OR Villa +title=(Newcastle OR NEW) AND (Villa OR AST) +``` + +#### **`title_regex`** (boolean, optional) +When `true`, interprets the `title` parameter as a case-insensitive regex pattern. + +**Examples:** +``` +title=^The&title_regex=true # Programs starting with "The" +title=\d{4}&title_regex=true # Programs with 4-digit years +title=\b(United|City)\b&title_regex=true # Match "United" or "City" as whole words +``` + +#### **`title_whole_words`** (boolean, optional) +When `true`, matches only whole words. Prevents "NEW" from matching "News" or "Newcastle". + +**Examples:** +``` +title=NEW&title_whole_words=true # Matches "NEW" but not "News" or "Newsletter" +``` + +#### **`description`** (string, optional) +Search program descriptions. Supports the same features as `title`: +- AND/OR operators +- Parenthetical grouping +- Works with `description_regex` and `description_whole_words` + +#### **`description_regex`** (boolean, optional) +When `true`, interprets `description` as a case-insensitive regex pattern. + +#### **`description_whole_words`** (boolean, optional) +When `true`, matches only whole words in descriptions. + +--- + +### Time Filtering Parameters + +#### **`airing_at`** (ISO 8601 datetime, optional) +Find programs airing at a specific moment in time. Matches programs where: +``` +start_time <= airing_at < end_time +``` + +**Example:** +``` +airing_at=2026-02-14T20:00:00Z +``` + +#### **`start_after`** (ISO 8601 datetime, optional) +Programs starting at or after this time. + +**Example:** +``` +start_after=2026-02-14T18:00:00Z +``` + +#### **`start_before`** (ISO 8601 datetime, optional) +Programs starting at or before this time. + +#### **`end_after`** (ISO 8601 datetime, optional) +Programs ending at or after this time. + +#### **`end_before`** (ISO 8601 datetime, optional) +Programs ending at or before this time. + +**Combined time range example:** +``` +start_after=2026-02-14T19:00:00Z&start_before=2026-02-14T22:00:00Z +``` + +--- + +### Channel & Stream Filtering + +#### **`channel`** (string, optional) +Filter by channel name (case-insensitive substring match). + +**Example:** +``` +channel=BBC One +``` + +#### **`channel_id`** (integer, optional) +Filter by exact channel ID. + +**Example:** +``` +channel_id=123 +``` + +#### **`stream`** (string, optional) +Filter by stream name (case-insensitive substring match). + +**Example:** +``` +stream=Sky Sports +``` + +#### **`group`** (string, optional) +Filter by channel group name OR stream group name (case-insensitive substring match). + +**Example:** +``` +group=Sports +``` + +#### **`epg_source`** (integer, optional) +Filter by EPG source ID. + +**Example:** +``` +epg_source=5 +``` + +--- + +### Response Customization + +#### **`fields`** (string, optional) +Comma-separated list of fields to include in response. Reduces payload size for specific use cases. + +**Available fields:** +- `id`, `title`, `sub_title`, `description` +- `start_time`, `end_time`, `tvg_id`, `custom_properties` +- `epg_source`, `epg_name`, `epg_icon_url` +- `channels`, `streams` + +**Example:** +``` +fields=title,start_time,end_time,channels +``` + +--- + +### Pagination Parameters + +#### **`page`** (integer, optional, default: 1) +Page number for paginated results. + +#### **`page_size`** (integer, optional, default: 50, max: 500) +Number of results per page. + +**Example:** +``` +page=2&page_size=100 +``` + +--- + +## Response Format + +### Standard Response (with pagination) + +```json +{ + "count": 345, + "next": "http://example.com/api/epg/programs/search/?page=2&...", + "previous": null, + "results": [ + { + "id": 21382804, + "title": "Premier League: Newcastle vs Aston Villa", + "sub_title": "Match Week 25", + "description": "LIVE coverage from St James' Park", + "start_time": "2026-02-14T20:00:00Z", + "end_time": "2026-02-14T22:00:00Z", + "tvg_id": "skysportspremierleague.uk", + "custom_properties": {"category": "Sports"}, + "epg_source": "Sky EPG UK", + "epg_name": "Sky Sports Premier League", + "epg_icon_url": "https://example.com/icon.png", + "channels": [ + { + "id": 14419, + "name": "Sky Sports Premier League", + "channel_number": 401.0, + "channel_group": "UK | Sky Sports", + "tvg_id": "skysportspremierleague.uk" + } + ], + "streams": [ + { + "id": 300, + "name": "Sky Sports PL HD", + "channel_group": "UK | Sports", + "tvg_id": "SkySportsPL.uk", + "m3u_account": "my_provider" + } + ] + } + ] +} +``` + +### With Field Selection + +Request: `?fields=title,start_time,end_time` + +```json +{ + "count": 345, + "next": "...", + "previous": null, + "results": [ + { + "title": "Premier League: Newcastle vs Aston Villa", + "start_time": "2026-02-14T20:00:00Z", + "end_time": "2026-02-14T22:00:00Z" + } + ] +} +``` + +--- + +## Usage Examples + +### Example 1: Find football matches airing now +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=football&airing_at=2026-02-14T20:00:00Z" +``` + +### Example 2: Complex team search with abbreviations +Find programs mentioning either full names or abbreviations: +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=(Newcastle%20OR%20NEW)%20AND%20(Villa%20OR%20AST)&airing_at=2026-02-14T20:00:00Z" +``` + +### Example 3: Whole word matching to avoid false positives +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=NEW&title_whole_words=true&airing_at=2026-02-14T20:00:00Z" +``` +This matches "NEW" but not "News", "Newcastle", or "Newsletter". + +### Example 4: Regex pattern for advanced matching +Find programs starting with "Premier" or "Champions": +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=^(Premier%7CChampions)&title_regex=true" +``` + +### Example 5: Time window search on specific channel +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?channel=BBC%20One&start_after=2026-02-14T18:00:00Z&start_before=2026-02-14T23:00:00Z" +``` + +### Example 6: Minimal response with field selection +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=sports&fields=title,start_time,end_time&page_size=10" +``` + +### Example 7: Find programs by stream group +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?group=Sports&airing_at=2026-02-14T20:00:00Z" +``` + +### Example 8: Find all news programs in next 2 hours +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://localhost:9191/api/epg/programs/search/?title=news&start_after=2026-02-14T20:00:00Z&start_before=2026-02-14T22:00:00Z" +``` + +--- + +## Text Query Syntax + +### Operators + +- **AND**: Both terms must be present + - Example: `premier AND league` + +- **OR**: Either term must be present + - Example: `Newcastle OR Villa` + +- **Parentheses**: Group operations for complex logic + - Example: `(A OR B) AND (C OR D)` + +### Operator Precedence + +Left-to-right evaluation with parentheses for grouping: +``` +A AND B OR C → Evaluated as: (A AND B) OR C +(A OR B) AND C → A or B must match, AND C must match +A AND (B OR C) → A must match, AND either B or C must match +``` + +### Default Behavior + +Space-separated terms without explicit operators default to AND: +``` +football premier league → football AND premier AND league +``` + +--- + +## Performance Considerations + +1. **Text searches** use database `LIKE` queries (or regex). For large datasets, consider: + - Using more specific search terms + - Combining with time filters to reduce result set + - Using field selection to reduce response size + +2. **Pagination**: Default page size is 50, max is 500. For large result sets, use pagination rather than increasing page_size to max. + +3. **Regex searches** are more expensive than standard text searches. Use only when necessary. + +4. **Prefetching**: The endpoint automatically prefetches related channels, streams, and groups to avoid N+1 queries. + +5. **Indexes**: The following fields are indexed for fast queries: + - `ProgramData.start_time`, `end_time` + - `EPGData.tvg_id` + - `Channel.channel_number` + +--- + +## Error Handling + +### Authentication Required +```json +{ + "detail": "Authentication credentials were not provided." +} +``` +**Status:** 401 Unauthorized + +### Invalid datetime format +Invalid ISO 8601 datetime values are silently ignored (filter not applied). + +### Invalid regex pattern +If `title_regex=true` and pattern is invalid, the query may fail at the database level. Ensure regex patterns are valid. + +--- + +## Integration Notes + +### Frontend Integration + +```javascript +// Example using fetch API +async function searchPrograms(query) { + const token = localStorage.getItem('accessToken'); + const params = new URLSearchParams(query); + + const response = await fetch( + `/api/epg/programs/search/?${params}`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + } + ); + + return await response.json(); +} + +// Usage +const results = await searchPrograms({ + title: '(Newcastle OR NEW) AND (Villa OR AST)', + airing_at: new Date().toISOString(), + page_size: 20 +}); +``` + +### Automation & Scripts + +```python +import requests +from datetime import datetime + +# Get token +response = requests.post('http://localhost:9191/api/accounts/token/', + json={'username': 'user', 'password': 'pass'}) +token = response.json()['access'] + +# Search programs +headers = {'Authorization': f'Bearer {token}'} +params = { + 'title': 'football', + 'airing_at': datetime.utcnow().isoformat() + 'Z', + 'page_size': 50 +} + +response = requests.get( + 'http://localhost:9191/api/epg/programs/search/', + headers=headers, + params=params +) + +programs = response.json() +print(f"Found {programs['count']} matching programs") +``` + +--- + +## API Versioning + +This endpoint was introduced in **version 1.0** and follows semantic versioning. Breaking changes will be announced with major version increments. + +--- + +## Related Endpoints + +- `GET /api/epg/grid/` - Get EPG grid view (24-hour window) +- `POST /api/epg/current-programs/` - Get currently airing programs for specific channels +- `GET /api/epg/programs/` - List all programs (CRUD endpoint) +- `GET /api/epg/sources/` - Manage EPG sources + +--- + +## Support & Feedback + +For issues, feature requests, or questions about this API, please file an issue on the GitHub repository or consult the project documentation.