We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Mature filter is broken
## Description
<!-- Concisely describe the bug. Compare your experience with what you expected to happen. -->
<!-- For example: "I clicked the 'submit' button and instead of seeing a thank you message, I saw a blank page." -->
The `mature` flag on our API is currently failing.

Here's the stacktrace we're seeing

```python
AttributeError: 'NoneType' object has no attribute 'to_dict'
  File "django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
  File "django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "rest_framework/viewsets.py", line 125, in view
    return self.dispatch(request, *args, **kwargs)
  File "rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "rest_framework/views.py", line 469, in handle_exception
    self.raise_uncaught_exception(exc)
  File "rest_framework/views.py", line 480, in raise_uncaught_exception
    raise exc
  File "rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "django/utils/decorators.py", line 46, in _wrapper
    return bound_method(*args, **kwargs)
  File "catalog/api/views/media_views.py", line 68, in list
    results, num_pages, num_results = perform_search(
  File "catalog/api/controllers/elasticsearch/search.py", line 166, in perform_search
    start, end = get_query_slice(
  File "catalog/api/controllers/elasticsearch/utils.py", line 88, in get_query_slice
    start_slice, end_slice = paginate_with_dead_link_mask(s, page_size, page)
  File "catalog/api/controllers/elasticsearch/utils.py", line 58, in paginate_with_dead_link_mask
    query_hash = get_query_hash(s)
  File "catalog/api/utils/dead_link_mask.py", line 21, in get_query_hash
    serialized_search_obj = s.to_dict()
  File "elasticsearch_dsl/search.py", line 658, in to_dict
    d["query"] = self.query.to_dict()
  File "elasticsearch_dsl/utils.py", line 382, in to_dict
    value = list(map(lambda x: x.to_dict(), value))
  File "elasticsearch_dsl/utils.py", line 382, in <lambda>
    value = list(map(lambda x: x.to_dict(), value))
```

## Reproduction
<!-- Provide detailed steps to reproduce the bug. -->
1. Visit http://localhost:8000/v1/images/?mature=true
2. Observe 500 error

## Additional context
<!-- Add any other context about the problem here; or delete the section entirely. -->
Sentry issue: https://sentry.io/share/issue/84aad5fd22354d20844f9fd5873958be/

## Resolution
<!-- Replace the [ ] with [x] to check the box. -->
- [ ] 🙋 I would be interested in resolving this bug.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `api/catalog/api/controllers/elasticsearch/search.py`
Content:
```
1 from __future__ import annotations
2 
3 import json
4 import logging as log
5 import pprint
6 from typing import List, Literal, Tuple, Union
7 
8 from django.conf import settings
9 
10 from elasticsearch.exceptions import RequestError
11 from elasticsearch_dsl import Q, Search
12 from elasticsearch_dsl.response import Hit
13 
14 from catalog.api.controllers.elasticsearch.utils import (
15     exclude_filtered_providers,
16     get_query_slice,
17     get_result_and_page_count,
18     post_process_results,
19 )
20 from catalog.api.serializers.media_serializers import MediaSearchRequestSerializer
21 
22 
23 def _quote_escape(query_string: str) -> str:
24     """
25     If there are any unmatched quotes in the query supplied by the user, ignore
26     them by escaping.
27 
28     :param query_string: the string in which to escape unbalanced quotes
29     :return: the given string, if the quotes are balanced, the escaped string otherwise
30     """
31 
32     num_quotes = query_string.count('"')
33     if num_quotes % 2 == 1:
34         return query_string.replace('"', '\\"')
35     else:
36         return query_string
37 
38 
39 def _apply_filter(
40     s: Search,
41     query_serializer: MediaSearchRequestSerializer,
42     basis: Union[str, tuple[str, str]],
43     behaviour: Literal["filter", "exclude"] = "filter",
44 ) -> Search:
45     """
46     Parse and apply a filter from the search parameters serializer. The
47     parameter key is assumed to have the same name as the corresponding
48     Elasticsearch property. Each parameter value is assumed to be a comma
49     separated list encoded as a string.
50 
51     :param s: the search query to issue to Elasticsearch
52     :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query
53     :param basis: the name of the field in the serializer and Elasticsearch
54     :param behaviour: whether to accept (``filter``) or reject (``exclude``) the hit
55     :return: the modified search query
56     """
57 
58     search_params = query_serializer.data
59     if isinstance(basis, tuple):
60         serializer_field, es_field = basis
61     else:
62         serializer_field = es_field = basis
63     if serializer_field in search_params:
64         filters = []
65         for arg in search_params[serializer_field].split(","):
66             filters.append(Q("term", **{es_field: arg}))
67         method = getattr(s, behaviour)  # can be ``s.filter`` or ``s.exclude``
68         return method("bool", should=filters)
69     else:
70         return s
71 
72 
73 def perform_search(
74     query_serializer: MediaSearchRequestSerializer,
75     index: Literal["image", "audio"],
76     ip: int,
77 ) -> Tuple[List[Hit], int, int]:
78     """
79     Perform a ranked, paginated search based on the query and filters given in the
80     search request.
81 
82     :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query
83     :param index: The Elasticsearch index to search (e.g. 'image')
84     :param ip: the users' hashed IP to consistently route to the same ES shard
85     :return: the list of search results with the page and result count
86     """
87 
88     s = Search(using="default", index=index)
89     search_params = query_serializer.data
90 
91     rules: dict[Literal["filter", "exclude"], list[Union[str, tuple[str, str]]]] = {
92         "filter": [
93             "extension",
94             "category",
95             ("categories", "category"),
96             "aspect_ratio",
97             "size",
98             "length",
99             "source",
100             ("license", "license.keyword"),
101             ("license_type", "license.keyword"),
102         ],
103         "exclude": [
104             ("excluded_source", "source"),
105         ],
106     }
107     for behaviour, bases in rules.items():
108         for basis in bases:
109             s = _apply_filter(s, query_serializer, basis, behaviour)
110 
111     # Exclude mature content
112     if not search_params["mature"]:
113         s = s.exclude("term", mature=True)
114     # Exclude sources with ``filter_content`` enabled
115     s = exclude_filtered_providers(s)
116 
117     # Search either by generic multimatch or by "advanced search" with
118     # individual field-level queries specified.
119 
120     search_fields = ["tags.name", "title", "description"]
121     if "q" in search_params:
122         query = _quote_escape(search_params["q"])
123         s = s.query(
124             "simple_query_string",
125             query=query,
126             fields=search_fields,
127             default_operator="AND",
128         )
129         # Boost exact matches
130         quotes_stripped = query.replace('"', "")
131         exact_match_boost = Q(
132             "simple_query_string",
133             fields=["title"],
134             query=f'"{quotes_stripped}"',
135             boost=10000,
136         )
137         s.query = Q("bool", must=s.query, should=exact_match_boost)
138     else:
139         query_bases = ["creator", "title", ("tags", "tags.name")]
140         for query_basis in query_bases:
141             if isinstance(query_basis, tuple):
142                 serializer_field, es_field = query_basis
143             else:
144                 serializer_field = es_field = query_basis
145             if serializer_field in search_params:
146                 value = _quote_escape(search_params[serializer_field])
147                 s = s.query("simple_query_string", fields=[es_field], query=value)
148 
149     if settings.USE_RANK_FEATURES:
150         feature_boost = {"standardized_popularity": 10000}
151         rank_queries = []
152         for field, boost in feature_boost.items():
153             rank_queries.append(Q("rank_feature", field=field, boost=boost))
154         s.query = Q("bool", must=s.query, should=rank_queries)
155 
156     # Use highlighting to determine which fields contribute to the selection of
157     # top results.
158     s = s.highlight(*search_fields)
159     s = s.highlight_options(order="score")
160 
161     # Route users to the same Elasticsearch worker node to reduce
162     # pagination inconsistencies and increase cache hits.
163     s = s.params(preference=str(ip), request_timeout=7)
164 
165     # Paginate
166     start, end = get_query_slice(
167         s,
168         search_params["page_size"],
169         search_params["page"],
170         search_params["filter_dead"],
171     )
172     s = s[start:end]
173 
174     try:
175         if settings.VERBOSE_ES_RESPONSE:
176             log.info(pprint.pprint(s.to_dict()))
177         search_response = s.execute()
178         log.info(
179             f"query={json.dumps(s.to_dict())}," f" es_took_ms={search_response.took}"
180         )
181         if settings.VERBOSE_ES_RESPONSE:
182             log.info(pprint.pprint(search_response.to_dict()))
183     except RequestError as e:
184         raise ValueError(e)
185 
186     results = post_process_results(
187         s,
188         start,
189         end,
190         search_params["page_size"],
191         search_response,
192         search_params["filter_dead"],
193     )
194 
195     result_count, page_count = get_result_and_page_count(
196         search_response, results, search_params["page_size"]
197     )
198     return results, page_count, result_count
199
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/api/catalog/api/controllers/elasticsearch/search.py b/api/catalog/api/controllers/elasticsearch/search.py
--- a/api/catalog/api/controllers/elasticsearch/search.py
+++ b/api/catalog/api/controllers/elasticsearch/search.py
@@ -9,6 +9,7 @@
 
 from elasticsearch.exceptions import RequestError
 from elasticsearch_dsl import Q, Search
+from elasticsearch_dsl.query import EMPTY_QUERY
 from elasticsearch_dsl.response import Hit
 
 from catalog.api.controllers.elasticsearch.utils import (
@@ -151,7 +152,7 @@
         rank_queries = []
         for field, boost in feature_boost.items():
             rank_queries.append(Q("rank_feature", field=field, boost=boost))
-        s.query = Q("bool", must=s.query, should=rank_queries)
+        s.query = Q("bool", must=s.query or EMPTY_QUERY, should=rank_queries)
 
     # Use highlighting to determine which fields contribute to the selection of
     # top results.

{"golden_diff": "diff --git a/api/catalog/api/controllers/elasticsearch/search.py b/api/catalog/api/controllers/elasticsearch/search.py\n--- a/api/catalog/api/controllers/elasticsearch/search.py\n+++ b/api/catalog/api/controllers/elasticsearch/search.py\n@@ -9,6 +9,7 @@\n \n from elasticsearch.exceptions import RequestError\n from elasticsearch_dsl import Q, Search\n+from elasticsearch_dsl.query import EMPTY_QUERY\n from elasticsearch_dsl.response import Hit\n \n from catalog.api.controllers.elasticsearch.utils import (\n@@ -151,7 +152,7 @@\n         rank_queries = []\n         for field, boost in feature_boost.items():\n             rank_queries.append(Q(\"rank_feature\", field=field, boost=boost))\n-        s.query = Q(\"bool\", must=s.query, should=rank_queries)\n+        s.query = Q(\"bool\", must=s.query or EMPTY_QUERY, should=rank_queries)\n \n     # Use highlighting to determine which fields contribute to the selection of\n     # top results.\n", "issue": "Mature filter is broken\n## Description\n<!-- Concisely describe the bug. Compare your experience with what you expected to happen. -->\n<!-- For example: \"I clicked the 'submit' button and instead of seeing a thank you message, I saw a blank page.\" -->\nThe `mature` flag on our API is currently failing.\n\nHere's the stacktrace we're seeing\n\n```python\nAttributeError: 'NoneType' object has no attribute 'to_dict'\n  File \"django/core/handlers/exception.py\", line 55, in inner\n    response = get_response(request)\n  File \"django/core/handlers/base.py\", line 197, in _get_response\n    response = wrapped_callback(request, *callback_args, **callback_kwargs)\n  File \"django/views/decorators/csrf.py\", line 54, in wrapped_view\n    return view_func(*args, **kwargs)\n  File \"rest_framework/viewsets.py\", line 125, in view\n    return self.dispatch(request, *args, **kwargs)\n  File \"rest_framework/views.py\", line 509, in dispatch\n    response = self.handle_exception(exc)\n  File \"rest_framework/views.py\", line 469, in handle_exception\n    self.raise_uncaught_exception(exc)\n  File \"rest_framework/views.py\", line 480, in raise_uncaught_exception\n    raise exc\n  File \"rest_framework/views.py\", line 506, in dispatch\n    response = handler(request, *args, **kwargs)\n  File \"django/utils/decorators.py\", line 46, in _wrapper\n    return bound_method(*args, **kwargs)\n  File \"catalog/api/views/media_views.py\", line 68, in list\n    results, num_pages, num_results = perform_search(\n  File \"catalog/api/controllers/elasticsearch/search.py\", line 166, in perform_search\n    start, end = get_query_slice(\n  File \"catalog/api/controllers/elasticsearch/utils.py\", line 88, in get_query_slice\n    start_slice, end_slice = paginate_with_dead_link_mask(s, page_size, page)\n  File \"catalog/api/controllers/elasticsearch/utils.py\", line 58, in paginate_with_dead_link_mask\n    query_hash = get_query_hash(s)\n  File \"catalog/api/utils/dead_link_mask.py\", line 21, in get_query_hash\n    serialized_search_obj = s.to_dict()\n  File \"elasticsearch_dsl/search.py\", line 658, in to_dict\n    d[\"query\"] = self.query.to_dict()\n  File \"elasticsearch_dsl/utils.py\", line 382, in to_dict\n    value = list(map(lambda x: x.to_dict(), value))\n  File \"elasticsearch_dsl/utils.py\", line 382, in <lambda>\n    value = list(map(lambda x: x.to_dict(), value))\n```\n\n## Reproduction\n<!-- Provide detailed steps to reproduce the bug. -->\n1. Visit http://localhost:8000/v1/images/?mature=true\n2. Observe 500 error\n\n## Additional context\n<!-- Add any other context about the problem here; or delete the section entirely. -->\nSentry issue: https://sentry.io/share/issue/84aad5fd22354d20844f9fd5873958be/\n\n## Resolution\n<!-- Replace the [ ] with [x] to check the box. -->\n- [ ] \ud83d\ude4b I would be interested in resolving this bug.\n\n", "before_files": [{"content": "from __future__ import annotations\n\nimport json\nimport logging as log\nimport pprint\nfrom typing import List, Literal, Tuple, Union\n\nfrom django.conf import settings\n\nfrom elasticsearch.exceptions import RequestError\nfrom elasticsearch_dsl import Q, Search\nfrom elasticsearch_dsl.response import Hit\n\nfrom catalog.api.controllers.elasticsearch.utils import (\n    exclude_filtered_providers,\n    get_query_slice,\n    get_result_and_page_count,\n    post_process_results,\n)\nfrom catalog.api.serializers.media_serializers import MediaSearchRequestSerializer\n\n\ndef _quote_escape(query_string: str) -> str:\n    \"\"\"\n    If there are any unmatched quotes in the query supplied by the user, ignore\n    them by escaping.\n\n    :param query_string: the string in which to escape unbalanced quotes\n    :return: the given string, if the quotes are balanced, the escaped string otherwise\n    \"\"\"\n\n    num_quotes = query_string.count('\"')\n    if num_quotes % 2 == 1:\n        return query_string.replace('\"', '\\\\\"')\n    else:\n        return query_string\n\n\ndef _apply_filter(\n    s: Search,\n    query_serializer: MediaSearchRequestSerializer,\n    basis: Union[str, tuple[str, str]],\n    behaviour: Literal[\"filter\", \"exclude\"] = \"filter\",\n) -> Search:\n    \"\"\"\n    Parse and apply a filter from the search parameters serializer. The\n    parameter key is assumed to have the same name as the corresponding\n    Elasticsearch property. Each parameter value is assumed to be a comma\n    separated list encoded as a string.\n\n    :param s: the search query to issue to Elasticsearch\n    :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query\n    :param basis: the name of the field in the serializer and Elasticsearch\n    :param behaviour: whether to accept (``filter``) or reject (``exclude``) the hit\n    :return: the modified search query\n    \"\"\"\n\n    search_params = query_serializer.data\n    if isinstance(basis, tuple):\n        serializer_field, es_field = basis\n    else:\n        serializer_field = es_field = basis\n    if serializer_field in search_params:\n        filters = []\n        for arg in search_params[serializer_field].split(\",\"):\n            filters.append(Q(\"term\", **{es_field: arg}))\n        method = getattr(s, behaviour)  # can be ``s.filter`` or ``s.exclude``\n        return method(\"bool\", should=filters)\n    else:\n        return s\n\n\ndef perform_search(\n    query_serializer: MediaSearchRequestSerializer,\n    index: Literal[\"image\", \"audio\"],\n    ip: int,\n) -> Tuple[List[Hit], int, int]:\n    \"\"\"\n    Perform a ranked, paginated search based on the query and filters given in the\n    search request.\n\n    :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query\n    :param index: The Elasticsearch index to search (e.g. 'image')\n    :param ip: the users' hashed IP to consistently route to the same ES shard\n    :return: the list of search results with the page and result count\n    \"\"\"\n\n    s = Search(using=\"default\", index=index)\n    search_params = query_serializer.data\n\n    rules: dict[Literal[\"filter\", \"exclude\"], list[Union[str, tuple[str, str]]]] = {\n        \"filter\": [\n            \"extension\",\n            \"category\",\n            (\"categories\", \"category\"),\n            \"aspect_ratio\",\n            \"size\",\n            \"length\",\n            \"source\",\n            (\"license\", \"license.keyword\"),\n            (\"license_type\", \"license.keyword\"),\n        ],\n        \"exclude\": [\n            (\"excluded_source\", \"source\"),\n        ],\n    }\n    for behaviour, bases in rules.items():\n        for basis in bases:\n            s = _apply_filter(s, query_serializer, basis, behaviour)\n\n    # Exclude mature content\n    if not search_params[\"mature\"]:\n        s = s.exclude(\"term\", mature=True)\n    # Exclude sources with ``filter_content`` enabled\n    s = exclude_filtered_providers(s)\n\n    # Search either by generic multimatch or by \"advanced search\" with\n    # individual field-level queries specified.\n\n    search_fields = [\"tags.name\", \"title\", \"description\"]\n    if \"q\" in search_params:\n        query = _quote_escape(search_params[\"q\"])\n        s = s.query(\n            \"simple_query_string\",\n            query=query,\n            fields=search_fields,\n            default_operator=\"AND\",\n        )\n        # Boost exact matches\n        quotes_stripped = query.replace('\"', \"\")\n        exact_match_boost = Q(\n            \"simple_query_string\",\n            fields=[\"title\"],\n            query=f'\"{quotes_stripped}\"',\n            boost=10000,\n        )\n        s.query = Q(\"bool\", must=s.query, should=exact_match_boost)\n    else:\n        query_bases = [\"creator\", \"title\", (\"tags\", \"tags.name\")]\n        for query_basis in query_bases:\n            if isinstance(query_basis, tuple):\n                serializer_field, es_field = query_basis\n            else:\n                serializer_field = es_field = query_basis\n            if serializer_field in search_params:\n                value = _quote_escape(search_params[serializer_field])\n                s = s.query(\"simple_query_string\", fields=[es_field], query=value)\n\n    if settings.USE_RANK_FEATURES:\n        feature_boost = {\"standardized_popularity\": 10000}\n        rank_queries = []\n        for field, boost in feature_boost.items():\n            rank_queries.append(Q(\"rank_feature\", field=field, boost=boost))\n        s.query = Q(\"bool\", must=s.query, should=rank_queries)\n\n    # Use highlighting to determine which fields contribute to the selection of\n    # top results.\n    s = s.highlight(*search_fields)\n    s = s.highlight_options(order=\"score\")\n\n    # Route users to the same Elasticsearch worker node to reduce\n    # pagination inconsistencies and increase cache hits.\n    s = s.params(preference=str(ip), request_timeout=7)\n\n    # Paginate\n    start, end = get_query_slice(\n        s,\n        search_params[\"page_size\"],\n        search_params[\"page\"],\n        search_params[\"filter_dead\"],\n    )\n    s = s[start:end]\n\n    try:\n        if settings.VERBOSE_ES_RESPONSE:\n            log.info(pprint.pprint(s.to_dict()))\n        search_response = s.execute()\n        log.info(\n            f\"query={json.dumps(s.to_dict())},\" f\" es_took_ms={search_response.took}\"\n        )\n        if settings.VERBOSE_ES_RESPONSE:\n            log.info(pprint.pprint(search_response.to_dict()))\n    except RequestError as e:\n        raise ValueError(e)\n\n    results = post_process_results(\n        s,\n        start,\n        end,\n        search_params[\"page_size\"],\n        search_response,\n        search_params[\"filter_dead\"],\n    )\n\n    result_count, page_count = get_result_and_page_count(\n        search_response, results, search_params[\"page_size\"]\n    )\n    return results, page_count, result_count\n", "path": "api/catalog/api/controllers/elasticsearch/search.py"}], "after_files": [{"content": "from __future__ import annotations\n\nimport json\nimport logging as log\nimport pprint\nfrom typing import List, Literal, Tuple, Union\n\nfrom django.conf import settings\n\nfrom elasticsearch.exceptions import RequestError\nfrom elasticsearch_dsl import Q, Search\nfrom elasticsearch_dsl.query import EMPTY_QUERY\nfrom elasticsearch_dsl.response import Hit\n\nfrom catalog.api.controllers.elasticsearch.utils import (\n    exclude_filtered_providers,\n    get_query_slice,\n    get_result_and_page_count,\n    post_process_results,\n)\nfrom catalog.api.serializers.media_serializers import MediaSearchRequestSerializer\n\n\ndef _quote_escape(query_string: str) -> str:\n    \"\"\"\n    If there are any unmatched quotes in the query supplied by the user, ignore\n    them by escaping.\n\n    :param query_string: the string in which to escape unbalanced quotes\n    :return: the given string, if the quotes are balanced, the escaped string otherwise\n    \"\"\"\n\n    num_quotes = query_string.count('\"')\n    if num_quotes % 2 == 1:\n        return query_string.replace('\"', '\\\\\"')\n    else:\n        return query_string\n\n\ndef _apply_filter(\n    s: Search,\n    query_serializer: MediaSearchRequestSerializer,\n    basis: Union[str, tuple[str, str]],\n    behaviour: Literal[\"filter\", \"exclude\"] = \"filter\",\n) -> Search:\n    \"\"\"\n    Parse and apply a filter from the search parameters serializer. The\n    parameter key is assumed to have the same name as the corresponding\n    Elasticsearch property. Each parameter value is assumed to be a comma\n    separated list encoded as a string.\n\n    :param s: the search query to issue to Elasticsearch\n    :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query\n    :param basis: the name of the field in the serializer and Elasticsearch\n    :param behaviour: whether to accept (``filter``) or reject (``exclude``) the hit\n    :return: the modified search query\n    \"\"\"\n\n    search_params = query_serializer.data\n    if isinstance(basis, tuple):\n        serializer_field, es_field = basis\n    else:\n        serializer_field = es_field = basis\n    if serializer_field in search_params:\n        filters = []\n        for arg in search_params[serializer_field].split(\",\"):\n            filters.append(Q(\"term\", **{es_field: arg}))\n        method = getattr(s, behaviour)  # can be ``s.filter`` or ``s.exclude``\n        return method(\"bool\", should=filters)\n    else:\n        return s\n\n\ndef perform_search(\n    query_serializer: MediaSearchRequestSerializer,\n    index: Literal[\"image\", \"audio\"],\n    ip: int,\n) -> Tuple[List[Hit], int, int]:\n    \"\"\"\n    Perform a ranked, paginated search based on the query and filters given in the\n    search request.\n\n    :param query_serializer: the ``MediaSearchRequestSerializer`` object with the query\n    :param index: The Elasticsearch index to search (e.g. 'image')\n    :param ip: the users' hashed IP to consistently route to the same ES shard\n    :return: the list of search results with the page and result count\n    \"\"\"\n\n    s = Search(using=\"default\", index=index)\n    search_params = query_serializer.data\n\n    rules: dict[Literal[\"filter\", \"exclude\"], list[Union[str, tuple[str, str]]]] = {\n        \"filter\": [\n            \"extension\",\n            \"category\",\n            (\"categories\", \"category\"),\n            \"aspect_ratio\",\n            \"size\",\n            \"length\",\n            \"source\",\n            (\"license\", \"license.keyword\"),\n            (\"license_type\", \"license.keyword\"),\n        ],\n        \"exclude\": [\n            (\"excluded_source\", \"source\"),\n        ],\n    }\n    for behaviour, bases in rules.items():\n        for basis in bases:\n            s = _apply_filter(s, query_serializer, basis, behaviour)\n\n    # Exclude mature content\n    if not search_params[\"mature\"]:\n        s = s.exclude(\"term\", mature=True)\n    # Exclude sources with ``filter_content`` enabled\n    s = exclude_filtered_providers(s)\n\n    # Search either by generic multimatch or by \"advanced search\" with\n    # individual field-level queries specified.\n\n    search_fields = [\"tags.name\", \"title\", \"description\"]\n    if \"q\" in search_params:\n        query = _quote_escape(search_params[\"q\"])\n        s = s.query(\n            \"simple_query_string\",\n            query=query,\n            fields=search_fields,\n            default_operator=\"AND\",\n        )\n        # Boost exact matches\n        quotes_stripped = query.replace('\"', \"\")\n        exact_match_boost = Q(\n            \"simple_query_string\",\n            fields=[\"title\"],\n            query=f'\"{quotes_stripped}\"',\n            boost=10000,\n        )\n        s.query = Q(\"bool\", must=s.query, should=exact_match_boost)\n    else:\n        query_bases = [\"creator\", \"title\", (\"tags\", \"tags.name\")]\n        for query_basis in query_bases:\n            if isinstance(query_basis, tuple):\n                serializer_field, es_field = query_basis\n            else:\n                serializer_field = es_field = query_basis\n            if serializer_field in search_params:\n                value = _quote_escape(search_params[serializer_field])\n                s = s.query(\"simple_query_string\", fields=[es_field], query=value)\n\n    if settings.USE_RANK_FEATURES:\n        feature_boost = {\"standardized_popularity\": 10000}\n        rank_queries = []\n        for field, boost in feature_boost.items():\n            rank_queries.append(Q(\"rank_feature\", field=field, boost=boost))\n        s.query = Q(\"bool\", must=s.query or EMPTY_QUERY, should=rank_queries)\n\n    # Use highlighting to determine which fields contribute to the selection of\n    # top results.\n    s = s.highlight(*search_fields)\n    s = s.highlight_options(order=\"score\")\n\n    # Route users to the same Elasticsearch worker node to reduce\n    # pagination inconsistencies and increase cache hits.\n    s = s.params(preference=str(ip), request_timeout=7)\n\n    # Paginate\n    start, end = get_query_slice(\n        s,\n        search_params[\"page_size\"],\n        search_params[\"page\"],\n        search_params[\"filter_dead\"],\n    )\n    s = s[start:end]\n\n    try:\n        if settings.VERBOSE_ES_RESPONSE:\n            log.info(pprint.pprint(s.to_dict()))\n        search_response = s.execute()\n        log.info(\n            f\"query={json.dumps(s.to_dict())},\" f\" es_took_ms={search_response.took}\"\n        )\n        if settings.VERBOSE_ES_RESPONSE:\n            log.info(pprint.pprint(search_response.to_dict()))\n    except RequestError as e:\n        raise ValueError(e)\n\n    results = post_process_results(\n        s,\n        start,\n        end,\n        search_params[\"page_size\"],\n        search_response,\n        search_params[\"filter_dead\"],\n    )\n\n    result_count, page_count = get_result_and_page_count(\n        search_response, results, search_params[\"page_size\"]\n    )\n    return results, page_count, result_count\n", "path": "api/catalog/api/controllers/elasticsearch/search.py"}]}