Skip to content

Commit 56a8df6

Browse files
Update algorithm for parsing smart filters (#1276)
* Fix minor typo * Update algorithm for parsing #1274 * Match style guide of the module * Fix Edge Cases - `and` is now considered the default operation instead of raising error - fix algorithm so that the reserved keys are parsed in the filters dictionary instead of filters group when located later in the feed. - removed some code repetition * Join multiple filters by default If multiple filter groups are parsed they are joined by `and` instead of raising error. * fix `==` operator parsing transfers "=" from the value to key * fix typehinting * Add new test for smart Filters this test would fail on old algorithm despite it being logically the exact same filter. * add test for deeply nested filters * combine filters test for playlist * combine filters test for collections * fix typo * add test to check parsed fitlers as it is * edited test to be independent of the server * Apply suggestions from code review adhere to style guide of the project Co-authored-by: JonnyWong16 <[email protected]> --------- Co-authored-by: JonnyWong16 <[email protected]>
1 parent c03d515 commit 56a8df6

File tree

3 files changed

+157
-67
lines changed

3 files changed

+157
-67
lines changed

plexapi/mixins.py

Lines changed: 82 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
2+
from collections import deque
23
from datetime import datetime
4+
from typing import Tuple
35
from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit
46

57
from plexapi import media, settings, utils
@@ -61,63 +63,95 @@ def defaultAdvanced(self):
6163

6264

6365
class SmartFilterMixin:
64-
""" Mixing for Plex objects that can have smart filters. """
66+
""" Mixin for Plex objects that can have smart filters. """
67+
68+
def _parseFilterGroups(
69+
self, feed: "deque[Tuple[str, str]]", returnOn: "set[str]|None" = None
70+
) -> dict:
71+
""" Parse filter groups from input lines between push and pop. """
72+
currentFiltersStack: list[dict] = []
73+
operatorForStack = None
74+
if returnOn is None:
75+
returnOn = set("pop")
76+
else:
77+
returnOn.add("pop")
78+
allowedLogicalOperators = ["and", "or"] # first is the default
79+
80+
while feed:
81+
key, value = feed.popleft() # consume the first item
82+
if key == "push":
83+
# recurse and add the result to the current stack
84+
currentFiltersStack.append(
85+
self._parseFilterGroups(feed, returnOn)
86+
)
87+
elif key in returnOn:
88+
# stop iterating and return the current stack
89+
if not key == "pop":
90+
feed.appendleft((key, value)) # put the item back
91+
break
92+
93+
elif key in allowedLogicalOperators:
94+
# set the operator
95+
if operatorForStack and not operatorForStack == key:
96+
raise ValueError(
97+
"cannot have different logical operators for the same"
98+
" filter group"
99+
)
100+
operatorForStack = key
101+
102+
else:
103+
# add the key value pair to the current filter
104+
currentFiltersStack.append({key: value})
105+
106+
if not operatorForStack and len(currentFiltersStack) > 1:
107+
# consider 'and' as the default operator
108+
operatorForStack = allowedLogicalOperators[0]
109+
110+
if operatorForStack:
111+
return {operatorForStack: currentFiltersStack}
112+
return currentFiltersStack.pop()
113+
114+
def _parseQueryFeed(self, feed: "deque[Tuple[str, str]]") -> dict:
115+
""" Parse the query string into a dict. """
116+
filtersDict = {}
117+
special_keys = {"type", "sort"}
118+
integer_keys = {"includeGuids", "limit"}
119+
reserved_keys = special_keys | integer_keys
120+
while feed:
121+
key, value = feed.popleft()
122+
if key in integer_keys:
123+
filtersDict[key] = int(value)
124+
elif key == "type":
125+
filtersDict["libtype"] = utils.reverseSearchType(value)
126+
elif key == "sort":
127+
filtersDict["sort"] = value.split(",")
128+
else:
129+
feed.appendleft((key, value)) # put the item back
130+
filter_group = self._parseFilterGroups(
131+
feed, returnOn=reserved_keys
132+
)
133+
if "filters" in filtersDict:
134+
filtersDict["filters"] = {
135+
"and": [filtersDict["filters"], filter_group]
136+
}
137+
else:
138+
filtersDict["filters"] = filter_group
139+
140+
return filtersDict
65141

66142
def _parseFilters(self, content):
67143
""" Parse the content string and returns the filter dict. """
68144
content = urlsplit(unquote(content))
69-
filters = {}
70-
filterOp = 'and'
71-
filterGroups = [[]]
145+
feed = deque()
72146

73147
for key, value in parse_qsl(content.query):
74148
# Move = sign to key when operator is ==
75-
if value.startswith('='):
76-
key += '='
77-
value = value[1:]
78-
79-
if key == 'includeGuids':
80-
filters['includeGuids'] = int(value)
81-
elif key == 'type':
82-
filters['libtype'] = utils.reverseSearchType(value)
83-
elif key == 'sort':
84-
filters['sort'] = value.split(',')
85-
elif key == 'limit':
86-
filters['limit'] = int(value)
87-
elif key == 'push':
88-
filterGroups[-1].append([])
89-
filterGroups.append(filterGroups[-1][-1])
90-
elif key == 'and':
91-
filterOp = 'and'
92-
elif key == 'or':
93-
filterOp = 'or'
94-
elif key == 'pop':
95-
filterGroups[-1].insert(0, filterOp)
96-
filterGroups.pop()
97-
else:
98-
filterGroups[-1].append({key: value})
99-
100-
if filterGroups:
101-
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
102-
return filters
103-
104-
def _formatFilterGroups(self, groups):
105-
""" Formats the filter groups into the advanced search rules. """
106-
if len(groups) == 1 and isinstance(groups[0], list):
107-
groups = groups.pop()
108-
109-
filterOp = 'and'
110-
rules = []
149+
if value.startswith("="):
150+
key, value = f"{key}=", value[1:]
111151

112-
for g in groups:
113-
if isinstance(g, list):
114-
rules.append(self._formatFilterGroups(g))
115-
elif isinstance(g, dict):
116-
rules.append(g)
117-
elif g in {'and', 'or'}:
118-
filterOp = g
152+
feed.append((key, value))
119153

120-
return {filterOp: rules}
154+
return self._parseQueryFeed(feed)
121155

122156

123157
class SplitMergeMixin:

tests/test_collection.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -252,30 +252,49 @@ def test_Collection_createSmart(plex, tvshows):
252252
collection.delete()
253253

254254

255-
def test_Collection_smartFilters(plex, movies):
255+
@pytest.mark.parametrize(
256+
"advancedFilters",
257+
[
258+
{
259+
"and": [
260+
{"or": [{"title": "elephant"}, {"title=": "Big Buck Bunny"}]},
261+
{"year>>": '1990'},
262+
{"unwatched": '1'},
263+
]
264+
},
265+
{
266+
"or": [
267+
{
268+
"and": [
269+
{"title": "elephant"},
270+
{"year>>": '1990'},
271+
{"unwatched": '1'},
272+
]
273+
},
274+
{
275+
"and": [
276+
{"title=": "Big Buck Bunny"},
277+
{"year>>": '1990'},
278+
{"unwatched": '1'},
279+
]
280+
},
281+
]
282+
},
283+
],
284+
)
285+
def test_Collection_smartFilters(advancedFilters, plex, movies):
256286
title = "test_Collection_smartFilters"
257-
advancedFilters = {
258-
'and': [
259-
{
260-
'or': [
261-
{'title': 'elephant'},
262-
{'title=': 'Big Buck Bunny'}
263-
]
264-
},
265-
{'year>>': 1990},
266-
{'unwatched': True}
267-
]
268-
}
269287
try:
270288
collection = plex.createCollection(
271289
title=title,
272290
section=movies,
273291
smart=True,
274292
limit=5,
275293
sort="year",
276-
filters=advancedFilters
294+
filters=advancedFilters,
277295
)
278296
filters = collection.filters()
297+
assert filters["filters"] == advancedFilters
279298
assert movies.search(**filters) == collection.items()
280299
finally:
281300
collection.delete()

tests/test_playlist.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,20 +184,57 @@ def test_Playlist_createSmart(plex, movies, movie):
184184
playlist.delete()
185185

186186

187-
def test_Playlist_smartFilters(plex, tvshows):
187+
@pytest.mark.parametrize(
188+
"smartFilter",
189+
[
190+
{"or": [{"show.title": "game"}, {"show.title": "100"}]},
191+
{
192+
"and": [
193+
{
194+
"or": [
195+
{
196+
"and": [
197+
{"show.title": "game"},
198+
{"show.title": "thrones"},
199+
{
200+
"or": [
201+
{"show.year>>": "1999"},
202+
{"show.viewCount<<": "3"},
203+
]
204+
},
205+
]
206+
},
207+
{"show.title": "100"},
208+
]
209+
},
210+
{"or": [{"show.contentRating": "TV-14"}, {"show.addedAt>>": "-10y"}]},
211+
{"episode.hdr!": "1"},
212+
]
213+
},
214+
],
215+
)
216+
def test_Playlist_smartFilters(smartFilter, plex, tvshows):
188217
try:
189218
playlist = plex.createPlaylist(
190219
title="smart_playlist_filters",
191220
smart=True,
192221
section=tvshows,
193222
limit=5,
194-
libtype='show',
195-
sort=["season.index:nullsLast", "episode.index:nullsLast", "show.titleSort"],
196-
filters={"or": [{"show.title": "game"}, {'show.title': "100"}]}
223+
libtype="show",
224+
sort=[
225+
"season.index:nullsLast",
226+
"episode.index:nullsLast",
227+
"show.titleSort",
228+
],
229+
filters=smartFilter,
197230
)
198231
filters = playlist.filters()
199-
filters['libtype'] = tvshows.METADATA_TYPE # Override libtype to check playlist items
232+
filters["libtype"] = (
233+
tvshows.METADATA_TYPE
234+
) # Override libtype to check playlist items
235+
assert filters["filters"] == smartFilter
200236
assert tvshows.search(**filters) == playlist.items()
237+
201238
finally:
202239
playlist.delete()
203240

0 commit comments

Comments
 (0)