diff --git a/docs/lb_radio.rst b/docs/lb_radio.rst index 7e0d19a7..f828d13a 100644 --- a/docs/lb_radio.rst +++ b/docs/lb_radio.rst @@ -85,25 +85,33 @@ is likely going to create a playlist with familiar music, and a hard playlist ma Syntax Notes ------------ -The syntax attempts to be intuitive and simple, but it does have some limitations. The artist: entity has the most tricky restrictions -because it should accept the full name of an artist, so it must be wrapped in (): +Artist and tag names are the tricky bits to specify in a prompt, so they must be enclosed with (): :: + artist:(Blümchen) + tag:(deep house) artist:(Мумий Тролль) -Furthermore, artist names must be spelled exactly as their appear in MusicBrainz. +Furthermore, artist names must be spelled exactly as their appear in MusicBrainz. If you have difficulty specifying the +correct artist, you can use an artist MBID to be very precise. -Tags and comma seperated lists of tags have similar restrictions and must be enclosed by (): +Tags have similar restrictions. If a tag you'd like to specify has no spaces or non-latin unicode characters you may use: :: tag:(punk) + #punk + +But with spaces or non-latin unicode characters, wrap it in () and use the full tag element name: -A shorthand with # is allowed, as long as the tag does not contain spaces: :: - #punk + tag:(hip hop) + +:: + + tag:(あなたを決して裏切りません) Simple examples @@ -111,51 +119,49 @@ Simple examples :: - artist:(Rick Astley) + Rick Astley Create a single stream, from artist Rick Astley and similar artists. Artist names must be spelled here exactly as they are spelled in MusicBrainz. If for some reason the artist name is not recognized, specify an MBID instead. See below. + :: - tag:(rock):3 tag:(pop):2 + #punk -Create two streams, one from tag "rock" contributing 3 parts of the recordings and one from tag "pop" contibuting 2 parts of the recordings. +The # shorthand notation allows user to quickly specify a tag radio, but it only works for one tag and the tag cannot contain spaces. For +more advanced prompts, use the full notation described above. :: - artist:8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11 - + tag:(rock,pop)::or -Specify an exact artist, using an artist MBID. +This prompt generates a playlist with recordings that have been tagged with either the "rock" OR "pop" tags. The weight can be omitted and will +be assumed to be 1. :: - #rock #pop - + tag:(rock) tag:(pop) -The # shorthand notation allows user to quickly specify a tag radio. This prompt generates two equal streams from the tags "rock" and "pop". +Create two streams, one from tag "rock" contributing 3 parts of the recordings and one from tag "pop" contibuting 2 parts of the recordings. :: - #(rock,pop) - tag:(rock,pop) + tag:(trip hop) -These two prompts are equal, the # notation is simply a shortcut for tag. This prompt generates a playlist with recordings that have been tagged -with both the "rock" AND "pop" tags. +Tags that have a space in them must be enclosed in (). Specifying multiple tags requires the tags to be enclosed in () as well as comma separated. :: - tag:(rock,pop)::or + tag:(trip hop, downtempo) -This prompt generates a playlist with recordings that have been tagged with either the "rock" OR "pop" tags. The weight can be omitted and will -be assumed to be 1. +If LB-radio does not find your artist, you can specify an artist using an Artist MBID: :: - tag:(trip hop) + artist:8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11 -Tags that have a space in them must be enclosed in (). Specifying multiple tags requires the tags to be enclosed in () as well as comma separated. +LB-radio also supports MusicBrainz collections as sources: :: diff --git a/pyproject.toml b/pyproject.toml index bb30576a..55c1b77a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,7 @@ dependencies = [ 'liblistenbrainz>=v0.5.5', 'python-dateutil>=2.8.2', 'spotipy>=2.22.1', - 'more_itertools', - 'pyparsing' + 'more_itertools' ] [project.scripts] diff --git a/tests/test_parser.py b/tests/test_parser.py index 113c9076..acb1f307 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,130 +1,154 @@ from uuid import UUID import unittest -from troi.parse_prompt import parse, ParseError +from troi.parse_prompt import PromptParser, ParseError class TestParser(unittest.TestCase): def test_basic_entities(self): - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + pp = PromptParser() + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - self.assertRaises(ParseError, parse, "wrong:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + self.assertRaises(ParseError, pp.parse, "wrong:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") - r = parse("artist:(the knife)") + r = pp.parse("artist:(the knife)") assert r[0] == {"entity": "artist", "values": ["the knife"], "weight": 1, "opts": []} - self.assertRaises(ParseError, parse, "artist:u2:nosim") + self.assertRaises(ParseError, pp.parse, "artist:u2:nosim") + self.assertRaises(ParseError, pp.parse, "artists:u2:nosim") def test_tags(self): - r = parse("t:(abstract,rock,blues)") + pp = PromptParser() + r = pp.parse("tag:abstract tag:rock tag:blues") + assert r[0] == {"entity": "tag", "values": ["abstract"], "weight": 1, "opts": []} + assert r[1] == {"entity": "tag", "values": ["rock"], "weight": 1, "opts": []} + assert r[2] == {"entity": "tag", "values": ["blues"], "weight": 1, "opts": []} + + r = pp.parse("tag:(abstract,rock,blues)") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": []} - r = parse("t:(abstract rock blues)") + r = pp.parse("tag:(abstract rock blues)") assert r[0] == {"entity": "tag", "values": ["abstract rock blues"], "weight": 1, "opts": []} - r = parse("tag:(abstract,rock,blues):1:or") + r = pp.parse("tag:(abstract,rock,blues):1:or") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": ["or"]} - r = parse("t:(abstract,rock,blues)") + r = pp.parse("tag:(abstract,rock,blues)") assert r[0] == {"entity": "tag", "values": ["abstract", "rock", "blues"], "weight": 1, "opts": []} - r = parse('t:(trip hop, hip hop)') + r = pp.parse('tag:(trip hop, hip hop)') assert r[0] == {"entity": "tag", "values": ["trip hop", "hip hop"], "weight": 1, "opts": []} - r = parse("t:(r&b)") + r = pp.parse("tag:(r&b)") assert r[0] == {"entity": "tag", "values": ["r&b"], "weight": 1, "opts": []} - r = parse("t:(blümchen)") + r = pp.parse("tag:(blümchen)") assert r[0] == {"entity": "tag", "values": ["blümchen"], "weight": 1, "opts": []} - r = parse("t:(モーニング娘。)") + r = pp.parse("tag:(モーニング娘。)") assert r[0] == {"entity": "tag", "values": ["モーニング娘。"], "weight": 1, "opts": []} def test_tag_errors(self): - self.assertRaises(ParseError, parse, "t:(abstract rock blues):bork") - self.assertRaises(ParseError, parse, "tag:(foo") - self.assertRaises(ParseError, parse, "tag:foo)") - self.assertRaises(ParseError, parse, 'tag:foo"') - self.assertRaises(ParseError, parse, 'tag:"foo') + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, "t:(abstract rock blues):bork") + self.assertRaises(ParseError, pp.parse, "tag:(foo") + self.assertRaises(ParseError, pp.parse, "tag:foo)") def test_shortcuts(self): - r = parse("#abstract #rock #blues") + pp = PromptParser() + r = pp.parse("#abstract") assert r[0] == {"entity": "tag", "values": ["abstract"], "weight": 1, "opts": []} - assert r[1] == {"entity": "tag", "values": ["rock"], "weight": 1, "opts": []} - assert r[2] == {"entity": "tag", "values": ["blues"], "weight": 1, "opts": []} + + pp = PromptParser() + r = pp.parse("u2") + assert r[0] == {"entity": "artist", "values": ["u2"], "weight": 1, "opts": []} def test_compound(self): - r = parse('artist:05319f96-e409-4199-b94f-3cabe7cc188a:2 #downtempo:1 tag:(trip hop, abstract):2') + pp = PromptParser() + r = pp.parse('artist:05319f96-e409-4199-b94f-3cabe7cc188a:2 tag:(downtempo):1 tag:(trip hop, abstract):2') assert r[0] == {"entity": "artist", "values": [UUID("05319f96-e409-4199-b94f-3cabe7cc188a")], "weight": 2, "opts": []} assert r[1] == {"entity": "tag", "values": ["downtempo"], "weight": 1, "opts": []} assert r[2] == {"entity": "tag", "values": ["trip hop", "abstract"], "weight": 2, "opts": []} def test_weights(self): - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:2") + pp = PromptParser() + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:2") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} assert r[1] == {"entity": "artist", "values": [UUID("f54ba4c6-12dd-4358-9136-c64ad89420c5")], "weight": 2, "opts": []} - self.assertRaises(ParseError, parse, - "a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:fussy") - self.assertRaises(ParseError, parse, "a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 a:f54ba4c6-12dd-4358-9136-c64ad89420c5:.5") + self.assertRaises(ParseError, pp.parse, + "artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:fussy") + self.assertRaises(ParseError, pp.parse, "artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:.5") - r = parse("a:(portishead)::easy") + r = pp.parse("artist:portishead::easy") assert r[0] == {"entity": "artist", "values": ["portishead"], "weight": 1, "opts": ["easy"]} - r = parse("a:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4::easy") + r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4::easy") assert r[0] == {"entity": "artist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": ["easy"]} - def test_collection_playlist(self): + def test_opts(self): + pp = PromptParser() + r = pp.parse("stats:(mr_monkey)::month") + print(r[0]) + assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} + r = pp.parse("artist:(monkey)::nosim,easy") + assert r[0] == {"entity": "artist", "values": ["monkey"], "weight": 1, "opts": ["nosim", "easy"]} + + self.assertRaises(ParseError, pp.parse, 'artist:(meh)::nosim,') - r = parse("collection:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + def test_parens(self): + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, 'artist:adfadf(meh)') + self.assertRaises(ParseError, pp.parse, 'artist:adfadf(meh') + self.assertRaises(ParseError, pp.parse, 'artist:adfadf)meh') + + def test_collection_playlist(self): + pp = PromptParser() + r = pp.parse("collection:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "collection", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "playlist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} - r = parse("p:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") + r = pp.parse("playlist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4") assert r[0] == {"entity": "playlist", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []} def test_stats(self): - - r = parse("stats:") - assert r[0] == {"entity": "stats", "values": [], "weight": 1, "opts": []} - - r = parse("stats:mr_monkey:1:year") + pp = PromptParser() + r = pp.parse("stats:mr_monkey:1:year") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["year"]} - r = parse("s:rob:1:week") + r = pp.parse("stats:rob:1:week") assert r[0] == {"entity": "stats", "values": ["rob"], "weight": 1, "opts": ["week"]} - r = parse("stats:(mr_monkey)::month") + r = pp.parse("stats:(mr_monkey)::month") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 1, "opts": ["month"]} - r = parse("stats:(mr_monkey):2:month") + r = pp.parse("stats:(mr_monkey):2:month") assert r[0] == {"entity": "stats", "values": ["mr_monkey"], "weight": 2, "opts": ["month"]} - r = parse("stats:(rob zombie)") + r = pp.parse("stats:(rob zombie)") assert r[0] == {"entity": "stats", "values": ["rob zombie"], "weight": 1, "opts": []} def test_recs(self): + pp = PromptParser() + self.assertRaises(ParseError, pp.parse, 'recs:') - r = parse("recs:") - assert r[0] == {"entity": "recs", "values": [], "weight": 1, "opts": []} - - r = parse("recs:mr_monkey::listened") + r = pp.parse("recs:mr_monkey::listened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} - r = parse("r:rob:1:unlistened") + r = pp.parse("recs:rob:1:unlistened") assert r[0] == {"entity": "recs", "values": ["rob"], "weight": 1, "opts": ["unlistened"]} - r = parse("recs:(mr_monkey):1:listened") + r = pp.parse("recs:(mr_monkey):1:listened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 1, "opts": ["listened"]} - r = parse("recs:(mr_monkey):2:unlistened") + r = pp.parse("recs:(mr_monkey):2:unlistened") assert r[0] == {"entity": "recs", "values": ["mr_monkey"], "weight": 2, "opts": ["unlistened"]} - r = parse("recs:(rob zombie)") + r = pp.parse("recs:(rob zombie)") assert r[0] == {"entity": "recs", "values": ["rob zombie"], "weight": 1, "opts": []} diff --git a/troi/cli.py b/troi/cli.py index ef8301bc..66a3a4d7 100755 --- a/troi/cli.py +++ b/troi/cli.py @@ -119,7 +119,7 @@ def info(patch): def test(args): """Run unit tests""" import pytest - pytest.main(list(args)) + raise SystemExit(pytest.main(list(args))) if __name__ == "__main__": diff --git a/troi/parse_prompt.py b/troi/parse_prompt.py index 712d86bd..a7c54a70 100755 --- a/troi/parse_prompt.py +++ b/troi/parse_prompt.py @@ -1,188 +1,172 @@ -import sys from uuid import UUID - -import pyparsing as pp -import pyparsing.exceptions +import re TIME_RANGES = ["week", "month", "quarter", "half_yearly", "year", "all_time", "this_week", "this_month", "this_year"] +ELEMENTS = ["artist", "tag", "collection", "playlist", "stats", "recs"] + +ELEMENT_OPTIONS = { + "artist": ["nosim", "easy", "medium", "hard"], + "tag": ["nosim", "and", "or", "easy", "medium", "hard"], + "collection": ["easy", "medium", "hard"], + "playlist": ["easy", "medium", "hard"], + "stats": TIME_RANGES, + "recs": ["easy", "medium", "hard", "listened", "unlistened"] +} -OPTIONS = ["easy", "hard", "medium", "and", "or", "nosim", "listened", "unlistened"] + TIME_RANGES +OPTIONS = set() +for eo in ELEMENT_OPTIONS: + OPTIONS.update(ELEMENT_OPTIONS[eo]) + +OPTIONS = list(OPTIONS) class ParseError(Exception): pass -def build_parser(): - """ Build a parser using pyparsing, which is bloody brilliant! """ - - # Define the entities and their keywords - artist_element = pp.MatchFirst((pp.Keyword("artist"), pp.Keyword("a"))) - tag_element = pp.MatchFirst((pp.Keyword("tag"), pp.Keyword("t"))) - collection_element = pp.MatchFirst((pp.Keyword("collection"))) - playlist_element = pp.MatchFirst((pp.Keyword("playlist"), pp.Keyword("p"))) - stats_element = pp.MatchFirst((pp.Keyword("stats"), pp.Keyword("s"))) - recs_element = pp.MatchFirst((pp.Keyword("recs"), pp.Keyword("r"))) - - # Define the various text fragments/identifiers that we plan to use - text = pp.Word(pp.identbodychars + " ") - uuid = pp.pyparsing_common.uuid() - paren_text = pp.QuotedString("(", end_quote_char=")") - tag_chars = pp.identbodychars + "&!-@$%^*=+;'" - ws_tag = pp.OneOrMore(pp.Word(tag_chars + " ")) - tag = pp.Word(tag_chars) - - # Define supporting fragments that will be used multiple times - paren_tag = pp.Suppress(pp.Literal("(")) \ - + pp.delimitedList(pp.Group(ws_tag, aslist=True), delim=",") \ - + pp.Suppress(pp.Literal(")")) - weight = pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.pyparsing_common.integer(), 1) - opt_keywords = pp.MatchFirst([pp.Keyword(k) for k in OPTIONS]) - options = pp.Suppress(pp.Literal(':')) \ - + opt_keywords - paren_options = pp.Suppress(pp.Literal(':')) \ - + pp.Suppress(pp.Literal("(")) \ - + pp.delimitedList(pp.Group(opt_keywords, aslist=True), delim=",") \ - + pp.Suppress(pp.Literal(")")) - optional = pp.Opt(weight + pp.Opt(pp.Group(options | paren_options), ""), 1) - - # Define artist element - element_uuid = artist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_paren_text = artist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - - # Define tag element - element_paren_tag = tag_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - element_tag_shortcut = pp.Literal('#') \ - + pp.Group(tag, aslist=True) \ - + optional - element_tag_paren_shortcut = pp.Literal('#') \ - + pp.Group(paren_tag, aslist=True) \ - + optional - - # Collection, playlist and stats, rec elements - element_collection = collection_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_playlist = playlist_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(uuid, aslist=True) \ - + optional - element_stats = stats_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.Group(text, aslist=True), "") \ - + optional - element_paren_stats = stats_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - element_recs = recs_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Opt(pp.Group(text, aslist=True), "") \ - + optional - element_paren_recs = recs_element \ - + pp.Suppress(pp.Literal(':')) \ - + pp.Group(paren_text, aslist=True) \ - + optional - - # Finally combine all elements into one, starting with the shortest/simplest elements and getting more - # complex - elements = element_tag_shortcut | element_uuid | element_paren_recs | element_collection | element_playlist | \ - element_paren_stats | element_paren_recs | element_recs | element_stats | \ - element_paren_text | element_paren_tag | element_tag_paren_shortcut - - # All of the above was to parse one single term, now allow the stats to define more than one if they want - return pp.OneOrMore(pp.Group(elements, aslist=True)) - - -def common_error_check(prompt: str): - """ Pyparsing is amazing, but the error messages leave a lot to be desired. This function attempts - to scan for common problems and give better error messages.""" - - parts = prompt.split(":") - try: - if parts[2] in OPTIONS: - sugg = f"{parts[0]}:{parts[1]}::{parts[2]}" - raise ParseError("Syntax error: options specified in the weight field, since a : is missing. Did you mean '%s'?" % sugg) - except IndexError: - pass - - -def parse(prompt: str): - """ Parse the given prompt. Return an array of dicts that contain the following keys: - entity: str e.g. "artist" - value: list e.g. "57baa3c6-ee43-4db3-9e6a-50bbc9792ee4" - weight: int e.g. 1 (positive integer) - options: list e.g ["and", "easy"] - - raises ParseError if, well, a parse error is encountered. +class PromptParser: + """ + Parse the LB radio prompt and return a list of elements and all of their data/options """ - common_error_check(prompt) - - parser = build_parser() - try: - elements = parser.parseString(prompt.lower(), parseAll=True) - except pp.exceptions.ParseException as err: - raise ParseError(err) - - results = [] - for element in elements: - if element[0] == "a": - entity = "artist" - elif element[0] == "s": - entity = "stats" - elif element[0] == "r": - entity = "recs" - elif element[0] == "t": - entity = "tag" - elif element[0] == "p": - entity = "playlist" - elif element[0] == "#": - entity = "tag" - else: - entity = element[0] - - try: - if entity == "tag" and element[1][0].find(",") > 0: - element[1] = [s.strip() for s in element[1][0].split(",")] - except IndexError: - pass - - try: - values = [UUID(element[1][0])] - except (ValueError, AttributeError, IndexError): - values = [] - for value in element[1]: - if isinstance(value, list): - values.append(value[0]) - else: - values.append(value) - try: - weight = element[2] - except IndexError: - weight = 1 - - opts = [] - try: - for opt in element[3]: - if isinstance(opt, list): - opts.append(opt[0]) - else: - opts.append(opt) - except IndexError: - pass + def __init__(self): + self.clean_spaces = re.compile(r"\s+") + self.element_check = re.compile(r"([a-zA-Z]+):") + + def identify_block(self, block): + """Given a prompt string, identify the block that is at the beginning of the string""" + + for element in ELEMENTS: + if block.startswith(element + ":"): + return element - results.append({"entity": entity, "values": values, "weight": weight, "opts": opts}) + if block.startswith("#"): + return "hashtag" - return results + # check for malformed element names + m = self.element_check.match(block) + if m is not None: + raise ParseError("Unrecognized element name '%s'. Must be one of: %s" % (m.group(0), ",".join(ELEMENTS))) + + return "artistname" + + def parse_special_cases(self, prompt): + """Detect the artist and tag special cases and re-write the query in long hand form.""" + + block_type = self.identify_block(prompt) + if block_type == "hashtag": + return "tag:(%s)" % prompt[1:] + if block_type == "artistname": + return "artist:(%s)" % prompt + + return prompt + + def set_block_values(self, name, values, weight, opts, text, block): + """Parse, process and sanity check data for an element""" + + if values is None: + try: + values = [UUID(text)] + except ValueError: + if name == "tag": + values = text.split(",") + values = [v.strip() for v in values] + else: + values = [text] + elif weight is None: + if not text: + weight = 1 + else: + try: + weight = int(text) + except ValueError: + raise ParseError("Weight must be a positive integer, not '%s'" % text) + elif not opts: + opts = text.split(",") + if opts and opts[-1] == "": + raise ParseError("Trailing comma in options.") + + return values, weight, opts + + def parse(self, prompt): + """Parse an actual LB-radio prompt and return a list of [name, [values], weight, [opts]]""" + + prompt = self.clean_spaces.sub(" ", prompt).strip() + block = self.parse_special_cases(prompt) + blocks = [] + while True: + name = "" + for element in ELEMENTS: + if block.startswith(element + ":"): + name = element + break + if not name: + raise ParseError("Unknown element '%s'" % block) + + block = block[len(name):] + if block[0] == ':': + block = block[1:] + + opts = [] + weight = None + values = None + text = "" + parens = 0 + escaped = False + for i in range(len(block)): + if not escaped and block[i] == '\\': + escaped = True + continue + + if escaped: + if block[i] in ('(', ')', '\\'): + escaped = False + text += block[i] + continue + escaped = False + + if block[i] == '(': + if i > 0: + raise ParseError("() must start at the beginning of the value field.") + parens += 1 + continue + + if block[i] == ')': + parens -= 1 + if parens < 0: + raise ParseError("closing ) without matching opening ( near: '%s'." % block[i:]) + continue + + if block[i] == ':' and parens == 0: + values, weight, opts = self.set_block_values(name, values, weight, opts, text, block) + text = "" + continue + + if block[i] == ' ' and parens == 0: + break + + text += block[i] + + # Now that we've parsed a block, do some sanity checking + values, weight, opts = self.set_block_values(name, values, weight, opts, text, block) + try: + block = block[i + 1:] + except UnboundLocalError: + raise ParseError("incomplete prompt") + + if parens > 0: + raise ParseError("Missing closing ).") + + if parens < 0: + raise ParseError("Missing opening (.") + + for opt in opts: + if opt not in ELEMENT_OPTIONS[name]: + raise ParseError("Option '%s' is not allowed for element %s" % (opt, name)) + + blocks.append({"entity": name, "values": values, "weight": weight or 1, "opts": opts}) + + if len(block) == 0: + break + + return blocks diff --git a/troi/patches/lb_radio.py b/troi/patches/lb_radio.py index c0843480..52b4dedb 100755 --- a/troi/patches/lb_radio.py +++ b/troi/patches/lb_radio.py @@ -11,7 +11,7 @@ import troi.listenbrainz.recs import troi.musicbrainz.recording_lookup from troi.playlist import PlaylistMakerElement -from troi.parse_prompt import parse, ParseError, TIME_RANGES +from troi.parse_prompt import PromptParser, ParseError, TIME_RANGES from troi.patches.lb_radio_classes.artist import LBRadioArtistRecordingElement from troi.patches.lb_radio_classes.blend import InterleaveRecordingsElement, WeighAndBlendRecordingsElement from troi.patches.lb_radio_classes.collection import LBRadioCollectionRecordingElement @@ -108,8 +108,9 @@ def create(self, inputs): self.mode = inputs["mode"] # First parse the prompt + pp = PromptParser() try: - prompt_elements = parse(self.prompt) + prompt_elements = pp.parse(self.prompt) except ParseError as err: raise RuntimeError(f"cannot parse prompt: '{err}'")