Skip to content

Commit

Permalink
Merge pull request #135 from metabrainz/integration-fixes
Browse files Browse the repository at this point in the history
Integration fixes
  • Loading branch information
mayhem authored Apr 26, 2024
2 parents 29dd4b2 + 5b27377 commit ced8785
Show file tree
Hide file tree
Showing 23 changed files with 552 additions and 685 deletions.
15 changes: 12 additions & 3 deletions docs/lb_radio.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The LB Radio supports the following entities:
#. **playlist**: Use a ListenBrainz playlist as a source of recordings. (mode also does not apply to playlists)
#. **stats**: Use a ListenBrainz user's statistics as a source of recordings.
#. **recs**: Use a ListenBrainz user's recommended recordings as a source of recordings.
#. **country**: Select recordings from artists who are from the given country.

Options
-------
Expand Down Expand Up @@ -165,21 +166,21 @@ If LB-radio does not find your artist, you can specify an artist using an Artist

::

artist:8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11
artist:(8f6bd1e4-fbe1-4f50-aa9b-94c450ec0f11)

LB-radio also supports MusicBrainz collections as sources:

::

collection:8be1a919-a386-45f3-8cc2-0d9249b02aa4
collection:(8be1a919-a386-45f3-8cc2-0d9249b02aa4)

Will select random recordings from a MusicBrainz recording collection -- the modes wont have any affect on collections, since
collections have no inherent ranking that could be used to select recordings according to mode. :(


::

playlist:8be1a919-a386-45f3-8cc2-0d9249b02aa4
playlist:(8be1a919-a386-45f3-8cc2-0d9249b02aa4)

Will select random recordings from a ListenBrainz playlist -- the modes wont have any affect on collections, since
plylists have no inherent ranking that could be used to select recordings according to mode. :(
Expand All @@ -198,6 +199,14 @@ Will select random recordings from the ListenBrainz user lucifer recordings stat

Will select random recordings from the ListenBrainz user mr_monkey's recommended recordings that mr_monkey hasn't listened to.

::

country:(Mali)

Will select random recordings from artists who are from the given country. While this features generally represents music from
that selected country, some artists leave their home country and don't perform music representative of their country, so
this element may not always be 100% on point. But it can still create some very interesting playlists!


More complex examples
---------------------
Expand Down
31 changes: 22 additions & 9 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ class TestParser(unittest.TestCase):

def test_basic_entities(self):
pp = PromptParser()
r = pp.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": []}

r = pp.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, pp.parse, "wrong:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")
self.assertRaises(ParseError, pp.parse, "artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")

r = pp.parse("artist:(the knife)")
assert r[0] == {"entity": "artist", "values": ["the knife"], "weight": 1, "opts": []}

self.assertRaises(ParseError, pp.parse, "artist:u2:nosim")
self.assertRaises(ParseError, pp.parse, "artists:u2:nosim")
self.assertRaises(ParseError, pp.parse, "country:andorra")

def test_tags(self):
pp = PromptParser()
Expand Down Expand Up @@ -52,6 +54,9 @@ def test_tags(self):
r = pp.parse("tag:(モーニング娘。)")
assert r[0] == {"entity": "tag", "values": ["モーニング娘。"], "weight": 1, "opts": []}

r = pp.parse("tag:(57baa3c6-ee43-4db3-9e6a-50bbc9792ee4)")
assert r[0] == {"entity": "tag", "values": ["57baa3c6-ee43-4db3-9e6a-50bbc9792ee4"], "weight": 1, "opts": []}

def test_tag_errors(self):
pp = PromptParser()
self.assertRaises(ParseError, pp.parse, "t:(abstract rock blues):bork")
Expand All @@ -73,25 +78,25 @@ def test_shortcuts(self):

def test_compound(self):
pp = PromptParser()
r = pp.parse('artist:05319f96-e409-4199-b94f-3cabe7cc188a:2 tag:(downtempo):1 tag:(trip hop, abstract):2')
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):
pp = PromptParser()
r = pp.parse("artist:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4:1 artist:f54ba4c6-12dd-4358-9136-c64ad89420c5:2")
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, 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 = pp.parse("artist:portishead::easy")
r = pp.parse("artist:(portishead)::easy")
assert r[0] == {"entity": "artist", "values": ["portishead"], "weight": 1, "opts": ["easy"]}

r = pp.parse("artist: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_opts(self):
Expand All @@ -112,13 +117,13 @@ def test_parens(self):

def test_collection_playlist(self):
pp = PromptParser()
r = pp.parse("collection:57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")
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 = pp.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 = pp.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": []}

def test_stats(self):
Expand Down Expand Up @@ -156,3 +161,11 @@ def test_recs(self):

r = pp.parse("recs:(rob zombie)")
assert r[0] == {"entity": "recs", "values": ["rob zombie"], "weight": 1, "opts": []}

def test_country(self):
pp = PromptParser()
r = pp.parse("country:(57baa3c6-ee43-4db3-9e6a-50bbc9792ee4)")
assert r[0] == {"entity": "country", "values": [UUID("57baa3c6-ee43-4db3-9e6a-50bbc9792ee4")], "weight": 1, "opts": []}

r = pp.parse("country:(mali)")
assert r[0] == {"entity": "country", "values": ["mali"], "weight": 1, "opts": []}
25 changes: 25 additions & 0 deletions tests/test_plist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import unittest

from troi.plist import plist


class TestSplitter(unittest.TestCase):

def test_plist(self):
pl = plist([0,1,2,3,4,5,6,7,8,9])

assert pl[0:10] == [0]
assert pl[0:20] == [0,1]
assert pl[50:100] == [5,6,7,8,9]
assert pl.uslice(0, 10) == [0]
assert pl.uslice(0, 20) == [0,1]
assert pl.uslice(50, 100) == [5,6,7,8,9]

assert pl.dslice(0, 2) == [0,1]

assert pl.random_item(50, 100) in [5,6,7,8,9]

def test_plist_unique(self):
pl = plist([0,1,2,3,4,5,6,7,8,9])
rlist = pl.random_item(count=9)
assert len(rlist) == len(set(rlist))
76 changes: 0 additions & 76 deletions tests/test_splitter.py

This file was deleted.

6 changes: 5 additions & 1 deletion troi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ def playlist(patch, quiet, save, token, upload, args, created_for, name, desc, m
"This is a local patch and should be invoked via the specific troi function, rather than the playlist function.")
return None

ret = patch.generate_playlist()
try:
ret = patch.generate_playlist()
except RuntimeError as err:
logger.error(err)
ret = 0

user_feedback = patch.user_feedback()
if len(user_feedback) > 0:
Expand Down
72 changes: 56 additions & 16 deletions troi/content_resolver/artist_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@
from troi.content_resolver.model.recording import Recording, RecordingMetadata
from troi.content_resolver.utils import select_recordings_on_popularity
from troi.recording_search_service import RecordingSearchByArtistService
from troi.splitter import plist
from troi.plist import plist

OVERHYPED_SIMILAR_ARTISTS = [
"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d", # The Beatles
"83d91898-7763-47d7-b03b-b92132375c47", # Pink Floyd
"a74b1b7f-71a5-4011-9441-d0b5e4122711", # Radiohead
"8bfac288-ccc5-448d-9573-c33ea2aa5c30", # Red Hot Chili Peppers
"9c9f1380-2516-4fc9-a3e6-f9f61941d090", # Muse
"cc197bad-dc9c-440d-a5b5-d52ba2e14234", # Coldplay
"65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab", # Metallica
"5b11f4ce-a62d-471e-81fc-a69a8278c7da", # Nirvana
"f59c5520-5f46-4d2c-b2c4-822eabf53419", # Linkin Park
"cc0b7089-c08d-4c10-b6b0-873582c17fd6", # System of a Down
"ebfc1398-8d96-47e3-82c3-f782abcdb13d", # Beach boys
]

class LocalRecordingSearchByArtistService(RecordingSearchByArtistService):
'''
Expand All @@ -21,23 +34,50 @@ class LocalRecordingSearchByArtistService(RecordingSearchByArtistService):
def __init__(self):
RecordingSearchByArtistService.__init__(self)

def search(self, artist_mbids, begin_percent, end_percent, num_recordings):
def get_similar_artists(self, artist_mbid):
""" Fetch similar artists, given an artist_mbid. Returns a sored plist of artists. """

r = requests.post("https://labs.api.listenbrainz.org/similar-artists/json",
json=[{
'artist_mbid':
artist_mbid,
'algorithm':
"session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30"
}])
if r.status_code != 200:
raise RuntimeError(f"Cannot fetch similar artists: {r.status_code} ({r.text})")

try:
artists = r.json()[3]["data"]
except IndexError:
return []

# Knock down super hyped artists
for artist in artists:
if artist["artist_mbid"] in OVERHYPED_SIMILAR_ARTISTS:
artist["score"] /= 3 # Chop!

return plist(sorted(artists, key=lambda a: a["score"], reverse=True))

def search(self, mode, artist_mbid, pop_begin, pop_end, max_recordings_per_artist, max_similar_artists):

"""
Perform an artist search. Parameters:
tags - a list of artist_mbids for which to search recordings
begin_percent - if many recordings match the above parameters, return only
recordings that have a minimum popularity percent score
of begin_percent.
end_percent - if many recordings match the above parameters, return only
recordings that have a maximum popularity percent score
of end_percent.
num_recordings - ideally return these many recordings
If only few recordings match, the begin_percent and end_percent are
ignored.
mode: the mode used for this artist search
pop_begin: if many recordings match the above parameters, return only
recordings that have a minimum popularity percent score
of pop_begin.
pop_end: if many recordings match the above parameters, return only
recordings that have a maximum popularity percent score
of pop_end.
max_recordings_per_artist: The number of recordings to collect for each artist.
max_similar_artists: The maximum number of similar artists to select.
If only few recordings match, the pop_begin and pop_end are ignored.
"""

similar_artists = self.get_similar_artists(artist_mbid)
query = """SELECT popularity
, recording_mbid
, artist_mbid
Expand All @@ -50,8 +90,8 @@ def search(self, artist_mbids, begin_percent, end_percent, num_recordings):
ORDER BY artist_mbid
, popularity"""

placeholders = ",".join(("?", ) * len(artist_mbids))
cursor = db.execute_sql(query % placeholders, params=tuple(artist_mbids))
placeholders = ",".join(("?", ) * len(similar_artists))
cursor = db.execute_sql(query % placeholders, params=tuple(similar_artists))

artists = defaultdict(list)
for rec in cursor.fetchall():
Expand All @@ -64,6 +104,6 @@ def search(self, artist_mbids, begin_percent, end_percent, num_recordings):
})

for artist in artists:
artists[artist] = select_recordings_on_popularity(artists[artist], begin_percent, end_percent, num_recordings)
artists[artist] = select_recordings_on_popularity(artists[artist], pop_begin, pop_end, num_recordings)

return artists
Loading

0 comments on commit ced8785

Please sign in to comment.