Skip to content
This repository was archived by the owner on May 2, 2026. It is now read-only.

Commit efa1340

Browse files
authored
Merge pull request #177 from viu-media/dynamic-search-filters
Implement dynamic search enhancements (eg filters) and media info differentiation
2 parents 0524af6 + ac7e90a commit efa1340

9 files changed

Lines changed: 600 additions & 44 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Filter Parser for Dynamic Search
4+
5+
This module provides a parser for the special filter syntax used in dynamic search.
6+
Filter syntax allows users to add filters inline with their search query.
7+
8+
SYNTAX:
9+
@filter:value - Apply a filter with the given value
10+
@filter:value1,value2 - Apply multiple values (for array filters)
11+
@filter:!value - Exclude/negate a filter value
12+
13+
SUPPORTED FILTERS:
14+
@genre:action,comedy - Filter by genres
15+
@genre:!hentai - Exclude genre
16+
@status:airing - Filter by status (airing, finished, upcoming, cancelled, hiatus)
17+
@year:2024 - Filter by season year
18+
@season:winter - Filter by season (winter, spring, summer, fall)
19+
@format:tv,movie - Filter by format (tv, movie, ova, ona, special, music)
20+
@sort:score - Sort by (score, popularity, trending, title, date)
21+
@score:>80 - Minimum score
22+
@score:<50 - Maximum score
23+
@popularity:>10000 - Minimum popularity
24+
@onlist - Only show anime on user's list
25+
@onlist:false - Only show anime NOT on user's list
26+
27+
EXAMPLES:
28+
"naruto @genre:action @status:finished"
29+
"isekai @year:2024 @season:winter @sort:score"
30+
"@genre:action,adventure @status:airing"
31+
"romance @genre:!hentai @format:tv,movie"
32+
"""
33+
34+
import re
35+
from typing import Any, Dict, List, Optional, Tuple
36+
37+
# Mapping of user-friendly filter names to GraphQL variable names
38+
FILTER_ALIASES = {
39+
# Status aliases
40+
"airing": "RELEASING",
41+
"releasing": "RELEASING",
42+
"finished": "FINISHED",
43+
"completed": "FINISHED",
44+
"upcoming": "NOT_YET_RELEASED",
45+
"not_yet_released": "NOT_YET_RELEASED",
46+
"unreleased": "NOT_YET_RELEASED",
47+
"cancelled": "CANCELLED",
48+
"canceled": "CANCELLED",
49+
"hiatus": "HIATUS",
50+
"paused": "HIATUS",
51+
# Format aliases
52+
"tv": "TV",
53+
"tv_short": "TV_SHORT",
54+
"tvshort": "TV_SHORT",
55+
"movie": "MOVIE",
56+
"film": "MOVIE",
57+
"ova": "OVA",
58+
"ona": "ONA",
59+
"special": "SPECIAL",
60+
"music": "MUSIC",
61+
# Season aliases
62+
"winter": "WINTER",
63+
"spring": "SPRING",
64+
"summer": "SUMMER",
65+
"fall": "FALL",
66+
"autumn": "FALL",
67+
# Sort aliases
68+
"score": "SCORE_DESC",
69+
"score_desc": "SCORE_DESC",
70+
"score_asc": "SCORE",
71+
"popularity": "POPULARITY_DESC",
72+
"popularity_desc": "POPULARITY_DESC",
73+
"popularity_asc": "POPULARITY",
74+
"trending": "TRENDING_DESC",
75+
"trending_desc": "TRENDING_DESC",
76+
"trending_asc": "TRENDING",
77+
"title": "TITLE_ROMAJI",
78+
"title_desc": "TITLE_ROMAJI_DESC",
79+
"date": "START_DATE_DESC",
80+
"date_desc": "START_DATE_DESC",
81+
"date_asc": "START_DATE",
82+
"newest": "START_DATE_DESC",
83+
"oldest": "START_DATE",
84+
"favourites": "FAVOURITES_DESC",
85+
"favorites": "FAVOURITES_DESC",
86+
"episodes": "EPISODES_DESC",
87+
}
88+
89+
# Genre name normalization (lowercase -> proper case)
90+
GENRE_NAMES = {
91+
"action": "Action",
92+
"adventure": "Adventure",
93+
"comedy": "Comedy",
94+
"drama": "Drama",
95+
"ecchi": "Ecchi",
96+
"fantasy": "Fantasy",
97+
"horror": "Horror",
98+
"mahou_shoujo": "Mahou Shoujo",
99+
"mahou": "Mahou Shoujo",
100+
"magical_girl": "Mahou Shoujo",
101+
"mecha": "Mecha",
102+
"music": "Music",
103+
"mystery": "Mystery",
104+
"psychological": "Psychological",
105+
"romance": "Romance",
106+
"sci-fi": "Sci-Fi",
107+
"scifi": "Sci-Fi",
108+
"sci_fi": "Sci-Fi",
109+
"slice_of_life": "Slice of Life",
110+
"sol": "Slice of Life",
111+
"sports": "Sports",
112+
"supernatural": "Supernatural",
113+
"thriller": "Thriller",
114+
"hentai": "Hentai",
115+
}
116+
117+
# Filter pattern: @key:value or @key (boolean flags)
118+
FILTER_PATTERN = re.compile(r"@(\w+)(?::([^\s]+))?", re.IGNORECASE)
119+
120+
# Comparison operators for numeric filters
121+
COMPARISON_PATTERN = re.compile(r"^([<>]=?)?(\d+)$")
122+
123+
124+
def normalize_value(value: str, value_type: str) -> str:
125+
"""Normalize a filter value based on its type."""
126+
value_lower = value.lower().strip()
127+
128+
if value_type == "genre":
129+
return GENRE_NAMES.get(value_lower, value.title())
130+
elif value_type in ("status", "format", "season", "sort"):
131+
return FILTER_ALIASES.get(value_lower, value.upper())
132+
133+
return value
134+
135+
136+
def parse_value_list(value_str: str) -> Tuple[List[str], List[str]]:
137+
"""
138+
Parse a comma-separated value string, separating includes from excludes.
139+
140+
Returns:
141+
Tuple of (include_values, exclude_values)
142+
"""
143+
includes = []
144+
excludes = []
145+
146+
for val in value_str.split(","):
147+
val = val.strip()
148+
if not val:
149+
continue
150+
if val.startswith("!"):
151+
excludes.append(val[1:])
152+
else:
153+
includes.append(val)
154+
155+
return includes, excludes
156+
157+
158+
def parse_comparison(value: str) -> Tuple[Optional[str], Optional[int]]:
159+
"""
160+
Parse a comparison value like ">80" or "<50".
161+
162+
Returns:
163+
Tuple of (operator, number) or (None, None) if invalid
164+
"""
165+
match = COMPARISON_PATTERN.match(value)
166+
if match:
167+
operator = match.group(1) or ">" # Default to greater than
168+
number = int(match.group(2))
169+
return operator, number
170+
return None, None
171+
172+
173+
def parse_filters(query: str) -> Tuple[str, Dict[str, Any]]:
174+
"""
175+
Parse a search query and extract filter directives.
176+
177+
Args:
178+
query: The full search query including filter syntax
179+
180+
Returns:
181+
Tuple of (clean_query, filters_dict)
182+
- clean_query: The query with filter syntax removed
183+
- filters_dict: Dictionary of GraphQL variables to apply
184+
"""
185+
filters: Dict[str, Any] = {}
186+
187+
# Find all filter matches
188+
matches = list(FILTER_PATTERN.finditer(query))
189+
190+
for match in matches:
191+
filter_name = match.group(1).lower()
192+
filter_value = match.group(2) # May be None for boolean flags
193+
194+
# Handle different filter types
195+
if filter_name == "genre":
196+
if filter_value:
197+
includes, excludes = parse_value_list(filter_value)
198+
if includes:
199+
normalized = [normalize_value(v, "genre") for v in includes]
200+
filters.setdefault("genre_in", []).extend(normalized)
201+
if excludes:
202+
normalized = [normalize_value(v, "genre") for v in excludes]
203+
filters.setdefault("genre_not_in", []).extend(normalized)
204+
205+
elif filter_name == "status":
206+
if filter_value:
207+
includes, excludes = parse_value_list(filter_value)
208+
if includes:
209+
normalized = [normalize_value(v, "status") for v in includes]
210+
filters.setdefault("status_in", []).extend(normalized)
211+
if excludes:
212+
normalized = [normalize_value(v, "status") for v in excludes]
213+
filters.setdefault("status_not_in", []).extend(normalized)
214+
215+
elif filter_name == "format":
216+
if filter_value:
217+
includes, _ = parse_value_list(filter_value)
218+
if includes:
219+
normalized = [normalize_value(v, "format") for v in includes]
220+
filters.setdefault("format_in", []).extend(normalized)
221+
222+
elif filter_name == "year":
223+
if filter_value:
224+
try:
225+
filters["seasonYear"] = int(filter_value)
226+
except ValueError:
227+
pass # Invalid year, skip
228+
229+
elif filter_name == "season":
230+
if filter_value:
231+
filters["season"] = normalize_value(filter_value, "season")
232+
233+
elif filter_name == "sort":
234+
if filter_value:
235+
sort_val = normalize_value(filter_value, "sort")
236+
filters["sort"] = [sort_val]
237+
238+
elif filter_name == "score":
239+
if filter_value:
240+
op, num = parse_comparison(filter_value)
241+
if num is not None:
242+
if op in (">", ">="):
243+
filters["averageScore_greater"] = num
244+
elif op in ("<", "<="):
245+
filters["averageScore_lesser"] = num
246+
247+
elif filter_name == "popularity":
248+
if filter_value:
249+
op, num = parse_comparison(filter_value)
250+
if num is not None:
251+
if op in (">", ">="):
252+
filters["popularity_greater"] = num
253+
elif op in ("<", "<="):
254+
filters["popularity_lesser"] = num
255+
256+
elif filter_name == "onlist":
257+
if filter_value is None or filter_value.lower() in ("true", "yes", "1"):
258+
filters["on_list"] = True
259+
elif filter_value.lower() in ("false", "no", "0"):
260+
filters["on_list"] = False
261+
262+
elif filter_name == "tag":
263+
if filter_value:
264+
includes, excludes = parse_value_list(filter_value)
265+
if includes:
266+
# Tags use title case typically
267+
normalized = [v.replace("_", " ").title() for v in includes]
268+
filters.setdefault("tag_in", []).extend(normalized)
269+
if excludes:
270+
normalized = [v.replace("_", " ").title() for v in excludes]
271+
filters.setdefault("tag_not_in", []).extend(normalized)
272+
273+
# Remove filter syntax from query to get clean search text
274+
clean_query = FILTER_PATTERN.sub("", query).strip()
275+
# Clean up multiple spaces
276+
clean_query = re.sub(r"\s+", " ", clean_query).strip()
277+
278+
return clean_query, filters
279+
280+
281+
def get_help_text() -> str:
282+
"""Return a help string describing the filter syntax."""
283+
return """
284+
╭─────────────────── Filter Syntax Help ───────────────────╮
285+
│ │
286+
│ @genre:action,comedy Filter by genres │
287+
│ @genre:!hentai Exclude genre │
288+
│ @status:airing Status: airing, finished, │
289+
│ upcoming, cancelled, hiatus │
290+
│ @year:2024 Filter by year │
291+
│ @season:winter winter, spring, summer, fall │
292+
│ @format:tv,movie tv, movie, ova, ona, special │
293+
│ @sort:score score, popularity, trending, │
294+
│ date, title, newest, oldest │
295+
│ @score:>80 Minimum score │
296+
│ @score:<50 Maximum score │
297+
│ @popularity:>10000 Minimum popularity │
298+
│ @onlist Only on your list │
299+
│ @onlist:false Not on your list │
300+
│ @tag:isekai,reincarnation Filter by tags │
301+
│ │
302+
│ Examples: │
303+
│ naruto @genre:action @status:finished │
304+
│ @genre:action,adventure @year:2024 @sort:score │
305+
│ isekai @season:winter @year:2024 │
306+
│ │
307+
╰──────────────────────────────────────────────────────────╯
308+
""".strip()
309+
310+
311+
if __name__ == "__main__":
312+
# Test the parser
313+
import json
314+
import sys
315+
316+
if len(sys.argv) > 1:
317+
test_query = " ".join(sys.argv[1:])
318+
clean, filters = parse_filters(test_query)
319+
print(f"Original: {test_query}")
320+
print(f"Clean query: {clean}")
321+
print(f"Filters: {json.dumps(filters, indent=2)}")
322+
else:
323+
print(get_help_text())

0 commit comments

Comments
 (0)