diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index b377c50..2f1226d 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -2,6 +2,7 @@ minor_release_name: "Momo" 3.2.0: notes: - "[Architecture] Switched to linuxserver.io container base image" + - "[Feature] Added basic support for albums" - "[Performance] Improved image rendering" - "[Bugfix] Fixed configuration of time format" - "[Bugfix] Fixed search on manual scrobble page" diff --git a/dev/testing/Maloja.postman_collection.json b/dev/testing/Maloja.postman_collection.json index 7ca23a4..852bd92 100644 --- a/dev/testing/Maloja.postman_collection.json +++ b/dev/testing/Maloja.postman_collection.json @@ -189,7 +189,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\"\n}" + "raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\",\n \"album\": \"{{data.album}}\",\n \"albumartists\":[\n \"{{data.artist1}}\",\n \"{{data.artist3}}\"\n ]\n}" }, "url": { "raw": "{{url}}/apis/mlj_1/newscrobble", @@ -219,7 +219,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"key\": \"{{api_key}}\",\n \"artists\": [\"{{data.artist1}}\",\"{{data.artist2}}\"],\n \"title\": \"{{data.title1}}\"\n}" + "raw": "{\n \"key\": \"{{api_key}}\",\n \"artists\": [\"{{data.artist1}}\",\"{{data.artist2}}\"],\n \"title\": \"{{data.title1}}\",\n \"album\": \"{{data.album}}\",\n \"albumartists\":[\n \"{{data.artist1}}\",\n \"{{data.artist3}}\"\n ]\n}" }, "url": { "raw": "{{url}}/apis/mlj_1/newscrobble", @@ -867,6 +867,11 @@ "key": "data.title3", "value": "One in a Million" }, + { + "key": "data.album", + "value": "The Epic Collection", + "type": "default" + }, { "key": "data.timestamp1", "value": "" diff --git a/maloja/apis/listenbrainz.py b/maloja/apis/listenbrainz.py index 234048c..cb15b89 100644 --- a/maloja/apis/listenbrainz.py +++ b/maloja/apis/listenbrainz.py @@ -75,7 +75,7 @@ class Listenbrainz(APIHandler): self.scrobble({ 'track_artists':[artiststr], 'track_title':titlestr, - 'album_name':albumstr, + 'album_title':albumstr, 'scrobble_time':timestamp, 'track_length': additional.get("duration"), **extrafields diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 41fa636..f7e36a7 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -470,7 +470,7 @@ def post_scrobble( rawscrobble = { 'track_artists':(artist or []) + artists, 'track_title':title, - 'album_name':album, + 'album_title':album, 'album_artists':albumartists, 'scrobble_duration':duration, 'track_length':length, @@ -494,19 +494,23 @@ def post_scrobble( 'artists':result['track']['artists'], 'title':result['track']['title'] }, - 'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}" + 'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}", + 'warnings':[] } if extra_kwargs: - responsedict['warnings'] = [ + responsedict['warnings'] += [ {'type':'invalid_keyword_ignored','value':k, 'desc':"This key was not recognized by the server and has been discarded."} for k in extra_kwargs ] if artist and artists: - responsedict['warnings'] = [ + responsedict['warnings'] += [ {'type':'mixed_schema','value':['artist','artists'], 'desc':"These two fields are meant as alternative methods to submit information. Use of both is discouraged, but works at the moment."} ] + + if len(responsedict['warnings']) == 0: del responsedict['warnings'] + return responsedict @@ -515,7 +519,7 @@ def post_scrobble( @api.post("addpicture") @authenticated_function(alternate=api_key_correct,api=True) @catch_exceptions -def add_picture(b64,artist:Multi=[],title=None): +def add_picture(b64,artist:Multi=[],title=None,albumtitle=None): """Uploads a new image for an artist or track. param string b64: Base 64 representation of the image @@ -527,8 +531,10 @@ def add_picture(b64,artist:Multi=[],title=None): for a in artist: keys.append("artist",a) if title is not None: keys.append("title",title) + elif albumtitle is not None: keys.append("albumtitle",albumtitle) k_filter, _, _, _, _ = uri_to_internal(keys) if "track" in k_filter: k_filter = k_filter["track"] + elif "album" in k_filter: k_filter = k_filter["album"] url = images.set_image(b64,**k_filter) return { diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 473cb7b..7cb3c6b 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -93,8 +93,10 @@ def incoming_scrobble(rawscrobble,fix=True,client=None,api=None,dbconn=None): log(f"Incoming scrobble [Client: {client} | API: {api}]: {rawscrobble}") scrobbledict = rawscrobble_to_scrobbledict(rawscrobble, fix, client) + albumupdate = (malojaconfig["ALBUM_INFORMATION_TRUST"] == 'last') - sqldb.add_scrobble(scrobbledict,dbconn=dbconn) + + sqldb.add_scrobble(scrobbledict,update_album=albumupdate,dbconn=dbconn) proxy_scrobble_all(scrobbledict['track']['artists'],scrobbledict['track']['title'],scrobbledict['time']) dbcache.invalidate_caches(scrobbledict['time']) @@ -130,8 +132,11 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): scrobbleinfo = {**rawscrobble} if fix: scrobbleinfo['track_artists'],scrobbleinfo['track_title'] = cla.fullclean(scrobbleinfo['track_artists'],scrobbleinfo['track_title']) + if scrobbleinfo.get('album_artists'): + scrobbleinfo['album_artists'] = cla.parseArtists(scrobbleinfo['album_artists']) scrobbleinfo['scrobble_time'] = scrobbleinfo.get('scrobble_time') or int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) + # processed info to internal scrobble dict scrobbledict = { "time":scrobbleinfo.get('scrobble_time'), @@ -139,7 +144,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "artists":scrobbleinfo.get('track_artists'), "title":scrobbleinfo.get('track_title'), "album":{ - "name":scrobbleinfo.get('album_name'), + "albumtitle":scrobbleinfo.get('album_title'), "artists":scrobbleinfo.get('album_artists') }, "length":scrobbleinfo.get('track_length') @@ -148,7 +153,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "origin":f"client:{client}" if client else "generic", "extra":{ k:scrobbleinfo[k] for k in scrobbleinfo if k not in - ['scrobble_time','track_artists','track_title','track_length','scrobble_duration','album_name','album_artists'] + ['scrobble_time','track_artists','track_title','track_length','scrobble_duration','album_title','album_artists'] }, "rawscrobble":rawscrobble } @@ -216,6 +221,8 @@ def get_scrobbles(dbconn=None,**keys): result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,dbconn=dbconn) elif 'track' in keys: result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,dbconn=dbconn) + elif 'album' in keys: + result = sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,dbconn=dbconn) else: result = sqldb.get_scrobbles(since=since,to=to,dbconn=dbconn) #return result[keys['page']*keys['perpage']:(keys['page']+1)*keys['perpage']] @@ -229,6 +236,8 @@ def get_scrobbles_num(dbconn=None,**keys): result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,resolve_references=False,dbconn=dbconn)) elif 'track' in keys: result = len(sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,resolve_references=False,dbconn=dbconn)) + elif 'album' in keys: + result = len(sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,resolve_references=False,dbconn=dbconn)) else: result = sqldb.get_scrobbles_num(since=since,to=to,dbconn=dbconn) return result @@ -259,10 +268,21 @@ def get_charts_tracks(dbconn=None,**keys): (since,to) = keys.get('timerange').timestamps() if 'artist' in keys: result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],dbconn=dbconn) + elif 'album' in keys: + result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],dbconn=dbconn) else: result = sqldb.count_scrobbles_by_track(since=since,to=to,dbconn=dbconn) return result +@waitfordb +def get_charts_albums(dbconn=None,**keys): + (since,to) = keys.get('timerange').timestamps() + if 'artist' in keys: + result = sqldb.count_scrobbles_by_album_of_artist(since=since,to=to,artist=keys['artist'],dbconn=dbconn) + else: + result = sqldb.count_scrobbles_by_album(since=since,to=to,dbconn=dbconn) + return result + @waitfordb def get_pulse(dbconn=None,**keys): @@ -299,6 +319,14 @@ def get_performance(dbconn=None,**keys): if c["artist"] == artist: rank = c["rank"] break + elif "album" in keys: + album = sqldb.get_album(sqldb.get_album_id(keys['album'],dbconn=dbconn),dbconn=dbconn) + charts = get_charts_albums(timerange=rng,dbconn=dbconn) + rank = None + for c in charts: + if c["album"] == album: + rank = c["rank"] + break else: raise exceptions.MissingEntityParameter() results.append({"range":rng,"rank":rank}) @@ -336,6 +364,21 @@ def get_top_tracks(dbconn=None,**keys): return results +@waitfordb +def get_top_albums(dbconn=None,**keys): + + rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]}) + results = [] + + for rng in rngs: + try: + res = get_charts_albums(timerange=rng,dbconn=dbconn)[0] + results.append({"range":rng,"album":res["album"],"scrobbles":res["scrobbles"]}) + except Exception: + results.append({"range":rng,"album":None,"scrobbles":0}) + + return results + @waitfordb def artist_info(dbconn=None,**keys): @@ -417,6 +460,36 @@ def track_info(dbconn=None,**keys): } +@waitfordb +def album_info(dbconn=None,**keys): + + album = keys.get('album') + if album is None: raise exceptions.MissingEntityParameter() + + album_id = sqldb.get_album_id(album,dbconn=dbconn) + album = sqldb.get_album(album_id,dbconn=dbconn) + + alltimecharts = get_charts_albums(timerange=alltime(),dbconn=dbconn) + + #scrobbles = get_scrobbles_num(track=track,timerange=alltime()) + + c = [e for e in alltimecharts if e["album"] == album][0] + scrobbles = c["scrobbles"] + position = c["rank"] + + return { + "album":album, + "scrobbles":scrobbles, + "position":position, + "medals":{ + "gold": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['gold']], + "silver": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['silver']], + "bronze": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['bronze']], + }, + "topweeks":len([e for e in cached.weekly_topalbums if e == album_id]), + "id":album_id + } + def get_predefined_rulesets(dbconn=None): validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/maloja/database/cached.py b/maloja/database/cached.py index ea39a29..d1c0a26 100644 --- a/maloja/database/cached.py +++ b/maloja/database/cached.py @@ -14,16 +14,21 @@ medals_artists = { medals_tracks = { # year: {'gold':[],'silver':[],'bronze':[]} } +medals_albums = { + # year: {'gold':[],'silver':[],'bronze':[]} +} weekly_topartists = [] weekly_toptracks = [] +weekly_topalbums = [] @runyearly def update_medals(): - global medals_artists, medals_tracks + global medals_artists, medals_tracks, medals_albums medals_artists.clear() medals_tracks.clear() + medals_albums.clear() with sqldb.engine.begin() as conn: for year in mjt.ranges(step="year"): @@ -31,11 +36,14 @@ def update_medals(): charts_artists = sqldb.count_scrobbles_by_artist(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=conn) charts_tracks = sqldb.count_scrobbles_by_track(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=conn) + charts_albums = sqldb.count_scrobbles_by_album(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=conn) entry_artists = {'gold':[],'silver':[],'bronze':[]} entry_tracks = {'gold':[],'silver':[],'bronze':[]} + entry_albums = {'gold':[],'silver':[],'bronze':[]} medals_artists[year.desc()] = entry_artists medals_tracks[year.desc()] = entry_tracks + medals_albums[year.desc()] = entry_albums for entry in charts_artists: if entry['rank'] == 1: entry_artists['gold'].append(entry['artist_id']) @@ -47,6 +55,11 @@ def update_medals(): elif entry['rank'] == 2: entry_tracks['silver'].append(entry['track_id']) elif entry['rank'] == 3: entry_tracks['bronze'].append(entry['track_id']) else: break + for entry in charts_albums: + if entry['rank'] == 1: entry_albums['gold'].append(entry['album_id']) + elif entry['rank'] == 2: entry_albums['silver'].append(entry['album_id']) + elif entry['rank'] == 3: entry_albums['bronze'].append(entry['album_id']) + else: break @@ -54,9 +67,10 @@ def update_medals(): @rundaily def update_weekly(): - global weekly_topartists, weekly_toptracks + global weekly_topartists, weekly_toptracks, weekly_topalbums weekly_topartists.clear() weekly_toptracks.clear() + weekly_topalbums.clear() with sqldb.engine.begin() as conn: for week in mjt.ranges(step="week"): @@ -65,6 +79,7 @@ def update_weekly(): charts_artists = sqldb.count_scrobbles_by_artist(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=conn) charts_tracks = sqldb.count_scrobbles_by_track(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=conn) + charts_albums = sqldb.count_scrobbles_by_album(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=conn) for entry in charts_artists: if entry['rank'] == 1: weekly_topartists.append(entry['artist_id']) @@ -72,3 +87,6 @@ def update_weekly(): for entry in charts_tracks: if entry['rank'] == 1: weekly_toptracks.append(entry['track_id']) else: break + for entry in charts_albums: + if entry['rank'] == 1: weekly_topalbums.append(entry['album_id']) + else: break diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 61f0b38..1c0e0c5 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -21,44 +21,72 @@ DBTABLES = { # name - type - foreign key - kwargs 'scrobbles':{ 'columns':[ - ("timestamp", sql.Integer, {'primary_key':True}), - ("rawscrobble", sql.String, {}), - ("origin", sql.String, {}), - ("duration", sql.Integer, {}), - ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}), - ("extra", sql.String, {}) + ("timestamp", sql.Integer, {'primary_key':True}), + ("rawscrobble", sql.String, {}), + ("origin", sql.String, {}), + ("duration", sql.Integer, {}), + ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}), + ("extra", sql.String, {}) ], 'extraargs':(),'extrakwargs':{} }, 'tracks':{ 'columns':[ - ("id", sql.Integer, {'primary_key':True}), - ("title", sql.String, {}), - ("title_normalized",sql.String, {}), - ("length", sql.Integer, {}) + ("id", sql.Integer, {'primary_key':True}), + ("title", sql.String, {}), + ("title_normalized", sql.String, {}), + ("length", sql.Integer, {}), + ("album_id", sql.Integer, sql.ForeignKey('albums.id'), {}) ], 'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True} }, 'artists':{ 'columns':[ - ("id", sql.Integer, {'primary_key':True}), - ("name", sql.String, {}), - ("name_normalized", sql.String, {}) + ("id", sql.Integer, {'primary_key':True}), + ("name", sql.String, {}), + ("name_normalized", sql.String, {}) + ], + 'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True} + }, + 'albums':{ + 'columns':[ + ("id", sql.Integer, {'primary_key':True}), + ("albtitle", sql.String, {}), + ("albtitle_normalized", sql.String, {}) + #("albumartist", sql.String, {}) + # when an album has no artists, always use 'Various Artists' ], 'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True} }, 'trackartists':{ 'columns':[ - ("id", sql.Integer, {'primary_key':True}), - ("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}), - ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}) + ("id", sql.Integer, {'primary_key':True}), + ("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}), + ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}) ], 'extraargs':(sql.UniqueConstraint('artist_id', 'track_id'),),'extrakwargs':{} }, + 'albumartists':{ + 'columns':[ + ("id", sql.Integer, {'primary_key':True}), + ("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}), + ("album_id", sql.Integer, sql.ForeignKey('albums.id'), {}) + ], + 'extraargs':(sql.UniqueConstraint('artist_id', 'album_id'),),'extrakwargs':{} + }, +# 'albumtracks':{ +# # tracks can be in multiple albums +# 'columns':[ +# ("id", sql.Integer, {'primary_key':True}), +# ("album_id", sql.Integer, sql.ForeignKey('albums.id'), {}), +# ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}) +# ], +# 'extraargs':(sql.UniqueConstraint('album_id', 'track_id'),),'extrakwargs':{} +# }, 'associated_artists':{ 'columns':[ - ("source_artist", sql.Integer, sql.ForeignKey('artists.id'), {}), - ("target_artist", sql.Integer, sql.ForeignKey('artists.id'), {}) + ("source_artist", sql.Integer, sql.ForeignKey('artists.id'), {}), + ("target_artist", sql.Integer, sql.ForeignKey('artists.id'), {}) ], 'extraargs':(sql.UniqueConstraint('source_artist', 'target_artist'),),'extrakwargs':{} } @@ -138,7 +166,7 @@ def connection_provider(func): # "artists":list, # "title":string, # "album":{ -# "name":string, +# "albumtitle":string, # "artists":list # }, # "length":None @@ -185,11 +213,12 @@ def scrobble_db_to_dict(row,dbconn=None): def tracks_db_to_dict(rows,dbconn=None): artists = get_artists_of_tracks(set(row.id for row in rows),dbconn=dbconn) + albums = get_albums_map(set(row.album_id for row in rows),dbconn=dbconn) return [ { "artists":artists[row.id], "title":row.title, - #"album": + "album":albums.get(row.album_id), "length":row.length } for row in rows @@ -207,18 +236,31 @@ def artists_db_to_dict(rows,dbconn=None): def artist_db_to_dict(row,dbconn=None): return artists_db_to_dict([row],dbconn=dbconn)[0] +def albums_db_to_dict(rows,dbconn=None): + artists = get_artists_of_albums(set(row.id for row in rows),dbconn=dbconn) + return [ + { + "artists":artists.get(row.id), + "albumtitle":row.albtitle, + } + for row in rows + ] + +def album_db_to_dict(row,dbconn=None): + return albums_db_to_dict([row],dbconn=dbconn)[0] + ### DICT -> DB # These should return None when no data is in the dict so they can be used for update statements -def scrobble_dict_to_db(info,dbconn=None): +def scrobble_dict_to_db(info,update_album=False,dbconn=None): return { "timestamp":info.get('time'), "origin":info.get('origin'), "duration":info.get('duration'), - "track_id":get_track_id(info.get('track'),dbconn=dbconn), + "track_id":get_track_id(info.get('track'),update_album=update_album,dbconn=dbconn), "extra":json.dumps(info.get('extra')) if info.get('extra') else None, "rawscrobble":json.dumps(info.get('rawscrobble')) if info.get('rawscrobble') else None } @@ -236,6 +278,12 @@ def artist_dict_to_db(info,dbconn=None): "name_normalized":normalize_name(info) } +def album_dict_to_db(info,dbconn=None): + return { + "albtitle":info.get('albumtitle'), + "albtitle_normalized":normalize_name(info.get('albumtitle')) + } + @@ -244,17 +292,17 @@ def artist_dict_to_db(info,dbconn=None): @connection_provider -def add_scrobble(scrobbledict,dbconn=None): - add_scrobbles([scrobbledict],dbconn=dbconn) +def add_scrobble(scrobbledict,update_album=False,dbconn=None): + add_scrobbles([scrobbledict],update_album=update_album,dbconn=dbconn) @connection_provider -def add_scrobbles(scrobbleslist,dbconn=None): +def add_scrobbles(scrobbleslist,update_album=False,dbconn=None): with SCROBBLE_LOCK: ops = [ DB['scrobbles'].insert().values( - **scrobble_dict_to_db(s,dbconn=dbconn) + **scrobble_dict_to_db(s,update_album=update_album,dbconn=dbconn) ) for s in scrobbleslist ] @@ -285,11 +333,34 @@ def delete_scrobble(scrobble_id,dbconn=None): return True +@connection_provider +def add_track_to_album(track_id,album_id,replace=False,dbconn=None): + + conditions = [ + DB['tracks'].c.id == track_id + ] + if not replace: + # if we dont want replacement, just update if there is no album yet + conditions.append( + DB['tracks'].c.album_id == None + ) + + op = DB['tracks'].update().where( + *conditions + ).values( + album_id=album_id + ) + + result = dbconn.execute(op) + return True + + + ### these will 'get' the ID of an entity, creating it if necessary @cached_wrapper @connection_provider -def get_track_id(trackdict,create_new=True,dbconn=None): +def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): ntitle = normalize_name(trackdict['title']) artist_ids = [get_artist_id(a,dbconn=dbconn) for a in trackdict['artists']] artist_ids = list(set(artist_ids)) @@ -310,18 +381,19 @@ def get_track_id(trackdict,create_new=True,dbconn=None): op = DB['trackartists'].select( # DB['trackartists'].c.artist_id ).where( - DB['trackartists'].c.track_id==row[0] + DB['trackartists'].c.track_id==row.id ) result = dbconn.execute(op).all() match_artist_ids = [r.artist_id for r in result] #print("required artists",artist_ids,"this match",match_artist_ids) if set(artist_ids) == set(match_artist_ids): #print("ID for",trackdict['title'],"was",row[0]) + if trackdict.get('album'): + add_track_to_album(row.id,get_album_id(trackdict['album'],dbconn=dbconn),replace=update_album,dbconn=dbconn) return row.id if not create_new: return None - op = DB['tracks'].insert().values( **track_dict_to_db(trackdict,dbconn=dbconn) ) @@ -335,6 +407,9 @@ def get_track_id(trackdict,create_new=True,dbconn=None): ) result = dbconn.execute(op) #print("Created",trackdict['title'],track_id) + + if trackdict.get('album'): + add_track_to_album(track_id,get_album_id(trackdict['album'],dbconn=dbconn),dbconn=dbconn) return track_id @cached_wrapper @@ -364,6 +439,59 @@ def get_artist_id(artistname,create_new=True,dbconn=None): return result.inserted_primary_key[0] +@cached_wrapper +@connection_provider +def get_album_id(albumdict,create_new=True,dbconn=None): + ntitle = normalize_name(albumdict['albumtitle']) + artist_ids = [get_artist_id(a,dbconn=dbconn) for a in albumdict.get('artists') or []] + artist_ids = list(set(artist_ids)) + + + + + op = DB['albums'].select( +# DB['albums'].c.id + ).where( + DB['albums'].c.albtitle_normalized==ntitle + ) + result = dbconn.execute(op).all() + for row in result: + # check if the artists are the same + foundtrackartists = [] + + op = DB['albumartists'].select( +# DB['albumartists'].c.artist_id + ).where( + DB['albumartists'].c.album_id==row.id + ) + result = dbconn.execute(op).all() + match_artist_ids = [r.artist_id for r in result] + #print("required artists",artist_ids,"this match",match_artist_ids) + if set(artist_ids) == set(match_artist_ids): + #print("ID for",albumdict['title'],"was",row[0]) + return row.id + + if not create_new: return None + + + op = DB['albums'].insert().values( + **album_dict_to_db(albumdict,dbconn=dbconn) + ) + result = dbconn.execute(op) + album_id = result.inserted_primary_key[0] + + for artist_id in artist_ids: + op = DB['albumartists'].insert().values( + album_id=album_id, + artist_id=artist_id + ) + result = dbconn.execute(op) + #print("Created",trackdict['title'],track_id) + return album_id + + + + ### Edit existing @@ -544,6 +672,29 @@ def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbco #result = [scrobble_db_to_dict(row) for row in result] return result +@cached_wrapper +@connection_provider +def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,dbconn=None): + + if since is None: since=0 + if to is None: to=now() + + album_id = get_album_id(album,dbconn=dbconn) + + jointable = sql.join(DB['scrobbles'],DB['tracks'],DB['scrobbles'].c.track_id == DB['tracks'].c.id) + + op = jointable.select().where( + DB['scrobbles'].c.timestamp<=to, + DB['scrobbles'].c.timestamp>=since, + DB['tracks'].c.album_id==album_id + ).order_by(sql.asc('timestamp')) + result = dbconn.execute(op).all() + + if resolve_references: + result = scrobbles_db_to_dict(result) + #result = [scrobble_db_to_dict(row) for row in result] + return result + @cached_wrapper @connection_provider def get_scrobbles(since=None,to=None,resolve_references=True,dbconn=None): @@ -687,6 +838,112 @@ def count_scrobbles_by_track(since,to,resolve_ids=True,dbconn=None): result = rank(result,key='scrobbles') return result +@cached_wrapper +@connection_provider +def count_scrobbles_by_album(since,to,resolve_ids=True,dbconn=None): + + jointable = sql.join( + DB['scrobbles'], + DB['tracks'], + DB['scrobbles'].c.track_id == DB['tracks'].c.id + ) + + op = sql.select( + sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'), + DB['tracks'].c.album_id + ).select_from(jointable).where( + DB['scrobbles'].c.timestamp<=to, + DB['scrobbles'].c.timestamp>=since, + DB['tracks'].c.album_id != None + ).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count')) + result = dbconn.execute(op).all() + + if resolve_ids: + counts = [row.count for row in result] + albums = get_albums_map([row.album_id for row in result],dbconn=dbconn) + result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result] + else: + result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result] + result = rank(result,key='scrobbles') + return result + +@cached_wrapper +@connection_provider +# this ranks the albums of that artist, not albums the artist appears on - even scrobbles +# of tracks the artist is not part of! +def count_scrobbles_by_album_of_artist(since,to,artist,resolve_ids=True,dbconn=None): + + artist_id = get_artist_id(artist,dbconn=dbconn) + + jointable = sql.join( + DB['scrobbles'], + DB['tracks'], + DB['scrobbles'].c.track_id == DB['tracks'].c.id + ) + jointable2 = sql.join( + jointable, + DB['albumartists'], + DB['tracks'].c.album_id == DB['albumartists'].c.album_id + ) + + op = sql.select( + sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'), + DB['tracks'].c.album_id + ).select_from(jointable2).where( + DB['scrobbles'].c.timestamp<=to, + DB['scrobbles'].c.timestamp>=since, + DB['albumartists'].c.artist_id == artist_id + ).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count')) + result = dbconn.execute(op).all() + + if resolve_ids: + counts = [row.count for row in result] + albums = get_albums_map([row.album_id for row in result],dbconn=dbconn) + result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result] + else: + result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result] + result = rank(result,key='scrobbles') + return result + +@cached_wrapper +@connection_provider +# this ranks the tracks of that artist by the album they appear on - even when the album +# is not the artist's +def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=None): + + artist_id = get_artist_id(artist,dbconn=dbconn) + + jointable = sql.join( + DB['scrobbles'], + DB['trackartists'], + DB['scrobbles'].c.track_id == DB['trackartists'].c.track_id + ) + jointable2 = sql.join( + jointable, + DB['tracks'], + DB['scrobbles'].c.track_id == DB['tracks'].c.id + ) + + op = sql.select( + sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'), + DB['tracks'].c.album_id + ).select_from(jointable2).where( + DB['scrobbles'].c.timestamp<=to, + DB['scrobbles'].c.timestamp>=since, + DB['trackartists'].c.artist_id == artist_id + ).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count')) + result = dbconn.execute(op).all() + + if resolve_ids: + counts = [row.count for row in result] + albums = get_albums_map([row.album_id for row in result],dbconn=dbconn) + result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result] + else: + result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result] + result = rank(result,key='scrobbles') + return result + + @cached_wrapper @connection_provider def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None): @@ -717,6 +974,35 @@ def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None): return result +@cached_wrapper +@connection_provider +def count_scrobbles_by_track_of_album(since,to,album,dbconn=None): + + album_id = get_album_id(album,dbconn=dbconn) + + jointable = sql.join( + DB['scrobbles'], + DB['tracks'], + DB['scrobbles'].c.track_id == DB['tracks'].c.id + ) + + op = sql.select( + sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'), + DB['scrobbles'].c.track_id + ).select_from(jointable).filter( + DB['scrobbles'].c.timestamp<=to, + DB['scrobbles'].c.timestamp>=since, + DB['tracks'].c.album_id==album_id + ).group_by(DB['scrobbles'].c.track_id).order_by(sql.desc('count')) + result = dbconn.execute(op).all() + + + counts = [row.count for row in result] + tracks = get_tracks_map([row.track_id for row in result],dbconn=dbconn) + result = [{'scrobbles':row.count,'track':tracks[row.track_id]} for row in result] + result = rank(result,key='scrobbles') + return result + ### functions that get mappings for several entities -> rows @@ -734,6 +1020,19 @@ def get_artists_of_tracks(track_ids,dbconn=None): artists.setdefault(row.track_id,[]).append(artist_db_to_dict(row,dbconn=dbconn)) return artists +@cached_wrapper_individual +@connection_provider +def get_artists_of_albums(album_ids,dbconn=None): + op = sql.join(DB['albumartists'],DB['artists']).select().where( + DB['albumartists'].c.album_id.in_(album_ids) + ) + result = dbconn.execute(op).all() + + artists = {} + for row in result: + artists.setdefault(row.album_id,[]).append(artist_db_to_dict(row,dbconn=dbconn)) + return artists + @cached_wrapper_individual @connection_provider @@ -769,6 +1068,22 @@ def get_artists_map(artist_ids,dbconn=None): return artists +@cached_wrapper_individual +@connection_provider +def get_albums_map(album_ids,dbconn=None): + op = DB['albums'].select().where( + DB['albums'].c.id.in_(album_ids) + ) + result = dbconn.execute(op).all() + + albums = {} + result = list(result) + # this will get a list of albumdicts in the correct order of our rows + albumdicts = albums_db_to_dict(result,dbconn=dbconn) + for row,albumdict in zip(result,albumdicts): + albums[row.id] = albumdict + return albums + ### associations @cached_wrapper @@ -835,6 +1150,16 @@ def get_artist(id,dbconn=None): artistinfo = result[0] return artist_db_to_dict(artistinfo,dbconn=dbconn) +@cached_wrapper +@connection_provider +def get_album(id,dbconn=None): + op = DB['albums'].select().where( + DB['albums'].c.id==id + ) + result = dbconn.execute(op).all() + + albuminfo = result[0] + return album_db_to_dict(albuminfo,dbconn=dbconn) @cached_wrapper @connection_provider diff --git a/maloja/images.py b/maloja/images.py index f1c569a..4c2fa22 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -39,6 +39,13 @@ DB['tracks'] = sql.Table( sql.Column('expire',sql.Integer), sql.Column('raw',sql.String) ) +DB['albums'] = sql.Table( + 'albums', meta, + sql.Column('id',sql.Integer,primary_key=True), + sql.Column('url',sql.String), + sql.Column('expire',sql.Integer), + sql.Column('raw',sql.String) +) meta.create_all(engine) @@ -115,6 +122,11 @@ def get_artist_image(artist=None,artist_id=None): return f"/image?type=artist&id={artist_id}" +def get_album_image(album=None,album_id=None): + if album_id is None: + album_id = database.sqldb.get_album_id(album) + + return f"/image?type=album&id={album_id}" resolve_semaphore = BoundedSemaphore(8) @@ -132,7 +144,7 @@ def resolve_track_image(track_id): # local image if malojaconfig["USE_LOCAL_IMAGES"]: - images = local_files(artists=track['artists'],title=track['title']) + images = local_files(track=track) if len(images) != 0: result = random.choice(images) result = urllib.parse.quote(result) @@ -176,31 +188,56 @@ def resolve_artist_image(artist_id): return result +def resolve_album_image(album_id): + + with resolve_semaphore: + # check cache + result = get_image_from_cache(album_id,'albums') + if result is not None: + return result + + album = database.sqldb.get_album(album_id) + + # local image + if malojaconfig["USE_LOCAL_IMAGES"]: + images = local_files(album=album) + if len(images) != 0: + result = random.choice(images) + result = urllib.parse.quote(result) + result = {'type':'url','value':result} + set_image_in_cache(album_id,'tracks',result['value']) + return result + + # third party + result = thirdparty.get_image_album_all((album['artists'],album['albumtitle'])) + result = {'type':'url','value':result} + set_image_in_cache(album_id,'albums',result['value']) + + return result + + # removes emojis and weird shit from names def clean(name): return "".join(c for c in name if c.isalnum() or c in []).strip() -def get_all_possible_filenames(artist=None,artists=None,title=None): - # check if we're dealing with a track or artist, then clean up names - # (only remove non-alphanumeric, allow korean and stuff) - - if title is not None and artists is not None: - track = True - title, artists = clean(title), [clean(a) for a in artists] - elif artist is not None: - track = False +# new and improved +def get_all_possible_filenames(artist=None,track=None,album=None): + if track: + title, artists = clean(track['title']), [clean(a) for a in track['artists']] + superfolder = "tracks/" + elif album: + title, artists = clean(album['albumtitle']), [clean(a) for a in album.get('artists') or []] + superfolder = "albums/" + elif artist: artist = clean(artist) - else: return [] - - - superfolder = "tracks/" if track else "artists/" + superfolder = "artists/" + else: + return [] filenames = [] - if track: - #unsafeartists = [artist.translate(None,"-_./\\") for artist in artists] + if track or album: safeartists = [re.sub("[^a-zA-Z0-9]","",artist) for artist in artists] - #unsafetitle = title.translate(None,"-_./\\") safetitle = re.sub("[^a-zA-Z0-9]","",title) if len(artists) < 4: @@ -210,7 +247,6 @@ def get_all_possible_filenames(artist=None,artists=None,title=None): unsafeperms = [sorted(artists)] safeperms = [sorted(safeartists)] - for unsafeartistlist in unsafeperms: filename = "-".join(unsafeartistlist) + "_" + title if filename != "": @@ -241,10 +277,11 @@ def get_all_possible_filenames(artist=None,artists=None,title=None): return [superfolder + name for name in filenames] -def local_files(artist=None,artists=None,title=None): + +def local_files(artist=None,album=None,track=None): - filenames = get_all_possible_filenames(artist,artists,title) + filenames = get_all_possible_filenames(artist=artist,album=album,track=track) images = [] @@ -271,13 +308,18 @@ class MalformedB64(Exception): pass def set_image(b64,**keys): - track = "title" in keys - if track: - entity = {'artists':keys['artists'],'title':keys['title']} - id = database.sqldb.get_track_id(entity) - else: - entity = keys['artist'] - id = database.sqldb.get_artist_id(entity) + if "title" in keys: + entity = {"track":keys} + id = database.sqldb.get_track_id(entity['track']) + dbtable = "tracks" + elif "albumtitle" in keys: + entity = {"album":keys} + id = database.sqldb.get_album_id(entity['album']) + dbtable = "albums" + elif "artist" in keys: + entity = keys + id = database.sqldb.get_artist_id(entity['artist']) + dbtable = "artists" log("Trying to set image, b64 string: " + str(b64[:30] + "..."),module="debug") @@ -288,13 +330,13 @@ def set_image(b64,**keys): type,b64 = match.groups() b64 = base64.b64decode(b64) filename = "webupload" + str(int(datetime.datetime.now().timestamp())) + "." + type - for folder in get_all_possible_filenames(**keys): + for folder in get_all_possible_filenames(**entity): if os.path.exists(data_dir['images'](folder)): with open(data_dir['images'](folder,filename),"wb") as f: f.write(b64) break else: - folder = get_all_possible_filenames(**keys)[0] + folder = get_all_possible_filenames(**entity)[0] os.makedirs(data_dir['images'](folder)) with open(data_dir['images'](folder,filename),"wb") as f: f.write(b64) @@ -303,7 +345,6 @@ def set_image(b64,**keys): log("Saved image as " + data_dir['images'](folder,filename),module="debug") # set as current picture in rotation - if track: set_image_in_cache(id,'tracks',os.path.join("/images",folder,filename)) - else: set_image_in_cache(id,'artists',os.path.join("/images",folder,filename)) + set_image_in_cache(id,dbtable,os.path.join("/images",folder,filename)) return os.path.join("/images",folder,filename) diff --git a/maloja/malojauri.py b/maloja/malojauri.py index 6b1af6d..350a7e4 100644 --- a/maloja/malojauri.py +++ b/maloja/malojauri.py @@ -4,7 +4,7 @@ import urllib import math # this also sets defaults! -def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False): +def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api=False): # output: # 1 keys that define the filtered object like artist or track @@ -12,12 +12,23 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False): # 3 keys that define interal time ranges # 4 keys that define amount limits + type = None + if forceTrack: type = "track" + if forceArtist: type = "artist" + if forceAlbum: type = "album" + + if not type and "title" in keys: type = "track" + if not type and "albumtitle" in keys: type = "album" + if not type and "artist" in keys: type = "artist" + # 1 - if "title" in keys and not forceArtist: + if type == "track": filterkeys = {"track":{"artists":keys.getall("artist"),"title":keys.get("title")}} - elif "artist" in keys and not forceTrack: + elif type == "artist": filterkeys = {"artist":keys.get("artist")} if "associated" in keys: filterkeys["associated"] = True + elif type == "album": + filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("albumtitle") or keys.get("title")}} else: filterkeys = {} @@ -84,6 +95,10 @@ def internal_to_uri(keys): for a in keys["track"]["artists"]: urikeys.append("artist",a) urikeys.append("title",keys["track"]["title"]) + elif "album" in keys: + for a in keys["album"].get("artists") or []: + urikeys.append("artist",a) + urikeys.append("albumtitle",keys["album"]["albumtitle"]) #time if "timerange" in keys: diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index ea6a8c0..7224970 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -177,6 +177,7 @@ malojaconfig = Configuration( }, "Database":{ + "album_information_trust":(tp.Choice({'first':"First",'last':"Last",'majority':"Majority"}), "Album Information Authority","first", "Whether to trust the first album information that is sent with a track or update every time a different album is sent"), "invalid_artists":(tp.Set(tp.String()), "Invalid Artists", ["[Unknown Artist]","Unknown Artist","Spotify"], "Artists that should be discarded immediately"), "remove_from_title":(tp.Set(tp.String()), "Remove from Title", ["(Original Mix)","(Radio Edit)","(Album Version)","(Explicit Version)","(Bonus Track)"], "Phrases that should be removed from song titles"), "delimiters_feat":(tp.Set(tp.String()), "Featuring Delimiters", ["ft.","ft","feat.","feat","featuring"], "Delimiters used for extra artists, even when in the title field"), @@ -191,6 +192,7 @@ malojaconfig = Configuration( "default_step_pulse":(tp.Choice({'year':'Year','month':"Month",'week':'Week','day':'Day'}), "Default Pulse Step", "month"), "charts_display_tiles":(tp.Boolean(), "Display Chart Tiles", False), "display_art_icons":(tp.Boolean(), "Display Album/Artist Icons", True), + "default_album_artist":(tp.String(), "Default Albumartist", "Various Artists"), "discourage_cpu_heavy_stats":(tp.Boolean(), "Discourage CPU-heavy stats", False, "Prevent visitors from mindlessly clicking on CPU-heavy options. Does not actually disable them for malicious actors!"), "use_local_images":(tp.Boolean(), "Use Local Images", True), #"local_image_rotate":(tp.Integer(), "Local Image Rotate", 3600), diff --git a/maloja/server.py b/maloja/server.py index ce3595c..7e3815e 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -19,7 +19,7 @@ from doreah import auth # rest of the project from . import database from .database.jinjaview import JinjaDBConnection -from .images import resolve_track_image, resolve_artist_image +from .images import resolve_track_image, resolve_artist_image, resolve_album_image from .malojauri import uri_to_internal, remove_identical from .pkg_global.conf import malojaconfig, data_dir from .jinjaenv.context import jinja_environment @@ -124,6 +124,8 @@ def dynamic_image(): result = resolve_track_image(keys['id']) elif keys['type'] == 'artist': result = resolve_artist_image(keys['id']) + elif keys['type'] == 'album': + result = resolve_album_image(keys['id']) if result is None or result['value'] in [None,'']: return "" diff --git a/maloja/thirdparty/__init__.py b/maloja/thirdparty/__init__.py index 135d792..25cd657 100644 --- a/maloja/thirdparty/__init__.py +++ b/maloja/thirdparty/__init__.py @@ -63,7 +63,18 @@ def get_image_artist_all(artist): log("Could not get artist image for " + str(artist) + " from " + service.name) except Exception as e: log("Error getting artist image from " + service.name + ": " + repr(e)) - +def get_image_album_all(album): + with thirdpartylock: + for service in services["metadata"]: + try: + res = service.get_image_album(album) + if res is not None: + log("Got album image for " + str(album) + " from " + service.name) + return res + else: + log("Could not get album image for " + str(album) + " from " + service.name) + except Exception as e: + log("Error getting album image from " + service.name + ": " + repr(e)) class GenericInterface: @@ -217,6 +228,23 @@ class MetadataInterface(GenericInterface,abstract=True): if imgurl is not None: imgurl = self.postprocess_url(imgurl) return imgurl + def get_image_album(self,album): + artists, title = album + artiststring = urllib.parse.quote(", ".join(artists)) + titlestring = urllib.parse.quote(title) + response = urllib.request.urlopen( + self.metadata["albumurl"].format(artist=artiststring,title=titlestring,**self.settings) + ) + + responsedata = response.read() + if self.metadata["response_type"] == "json": + data = json.loads(responsedata) + imgurl = self.metadata_parse_response_album(data) + else: + imgurl = None + if imgurl is not None: imgurl = self.postprocess_url(imgurl) + return imgurl + # default function to parse response by descending down nodes # override if more complicated def metadata_parse_response_artist(self,data): @@ -225,6 +253,9 @@ class MetadataInterface(GenericInterface,abstract=True): def metadata_parse_response_track(self,data): return self._parse_response("response_parse_tree_track", data) + def metadata_parse_response_album(self,data): + return self._parse_response("response_parse_tree_album", data) + def _parse_response(self, resp, data): res = data for node in self.metadata[resp]: diff --git a/maloja/thirdparty/audiodb.py b/maloja/thirdparty/audiodb.py index 66d2b84..9a4d84a 100644 --- a/maloja/thirdparty/audiodb.py +++ b/maloja/thirdparty/audiodb.py @@ -9,13 +9,17 @@ class AudioDB(MetadataInterface): } metadata = { - #"trackurl": "https://theaudiodb.com/api/v1/json/{api_key}/searchtrack.php?s={artist}&t={title}", + #"trackurl": "https://theaudiodb.com/api/v1/json/{api_key}/searchtrack.php?s={artist}&t={title}", #patreon "artisturl": "https://www.theaudiodb.com/api/v1/json/{api_key}/search.php?s={artist}", + #"albumurl": "https://www.theaudiodb.com/api/v1/json/{api_key}/searchalbum.php?s={artist}&a={title}", #patreon "response_type":"json", #"response_parse_tree_track": ["tracks",0,"astrArtistThumb"], "response_parse_tree_artist": ["artists",0,"strArtistThumb"], "required_settings": ["api_key"], } - def get_image_track(self,artist): + def get_image_track(self,track): + return None + + def get_image_album(self,album): return None diff --git a/maloja/thirdparty/deezer.py b/maloja/thirdparty/deezer.py index 1347c6f..c691899 100644 --- a/maloja/thirdparty/deezer.py +++ b/maloja/thirdparty/deezer.py @@ -8,10 +8,17 @@ class Deezer(MetadataInterface): } metadata = { - "trackurl": "https://api.deezer.com/search?q={artist}%20{title}", + #"trackurl": "https://api.deezer.com/search?q={artist}%20{title}", "artisturl": "https://api.deezer.com/search?q={artist}", + "albumurl": "https://api.deezer.com/search?q={artist}%20{title}", "response_type":"json", - "response_parse_tree_track": ["data",0,"album","cover_medium"], + #"response_parse_tree_track": ["data",0,"album","cover_medium"], "response_parse_tree_artist": ["data",0,"artist","picture_medium"], + "response_parse_tree_album": ["data",0,"album","cover_medium"], "required_settings": [], } + + def get_image_track(self,track): + return None + # we can use the album pic from the track search, + # but should do so via maloja logic diff --git a/maloja/thirdparty/lastfm.py b/maloja/thirdparty/lastfm.py index 80f3c75..e565e5f 100644 --- a/maloja/thirdparty/lastfm.py +++ b/maloja/thirdparty/lastfm.py @@ -22,15 +22,22 @@ class LastFM(MetadataInterface, ProxyScrobbleInterface): "activated_setting": "SCROBBLE_LASTFM" } metadata = { + #"artisturl": "https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist={artist}&api_key={apikey}&format=json" "trackurl": "https://ws.audioscrobbler.com/2.0/?method=track.getinfo&track={title}&artist={artist}&api_key={apikey}&format=json", + "albumurl": "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={apikey}&artist={artist}&album={title}&format=json", "response_type":"json", "response_parse_tree_track": ["track","album","image",-1,"#text"], + # technically just the album artwork, but we use it for now + #"response_parse_tree_artist": ["artist","image",-1,"#text"], + "response_parse_tree_album": ["album","image",-1,"#text"], "required_settings": ["apikey"], } def get_image_artist(self,artist): return None - # lastfm doesn't provide artist images + # lastfm still provides that endpoint with data, + # but doesn't provide actual images + def proxyscrobble_parse_response(self,data): return data.attrib.get("status") == "ok" and data.find("scrobbles").attrib.get("ignored") == "0" diff --git a/maloja/thirdparty/musicbrainz.py b/maloja/thirdparty/musicbrainz.py index 78e033a..f16229b 100644 --- a/maloja/thirdparty/musicbrainz.py +++ b/maloja/thirdparty/musicbrainz.py @@ -26,6 +26,8 @@ class MusicBrainz(MetadataInterface): return None # not supported + def get_image_album(self,album): + return None def get_image_track(self,track): self.lock.acquire() diff --git a/maloja/thirdparty/spotify.py b/maloja/thirdparty/spotify.py index 8d50284..2665865 100644 --- a/maloja/thirdparty/spotify.py +++ b/maloja/thirdparty/spotify.py @@ -15,9 +15,11 @@ class Spotify(MetadataInterface): metadata = { "trackurl": "https://api.spotify.com/v1/search?q=artist:{artist}%20track:{title}&type=track&access_token={token}", + "albumurl": "https://api.spotify.com/v1/search?q=artist:{artist}%album:{title}&type=album&access_token={token}", "artisturl": "https://api.spotify.com/v1/search?q=artist:{artist}&type=artist&access_token={token}", "response_type":"json", - "response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"], + "response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"], # use album art + "response_parse_tree_album": ["albums","items",0,"images",0,"url"], "response_parse_tree_artist": ["artists","items",0,"images",0,"url"], "required_settings": ["apiid","secret"], } diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja new file mode 100644 index 0000000..6a16da3 --- /dev/null +++ b/maloja/web/jinja/album.jinja @@ -0,0 +1,163 @@ +{% extends "abstracts/base.jinja" %} +{% block title %}Maloja - {{ info.album.albumtitle }}{% endblock %} + +{% import 'snippets/links.jinja' as links %} + +{% block scripts %} + + +{% endblock %} + +{% set album = filterkeys.album %} +{% set info = dbc.album_info({'album':album}) %} + +{% set initialrange ='month' %} + + +{% set encodedalbum = mlj_uri.uriencode({'album':album}) %} + + +{% block icon_bar %} + {% if adminmode %} + {% include 'icons/edit.jinja' %} + {% include 'icons/merge.jinja' %} + {% include 'icons/merge_mark.jinja' %} + {% include 'icons/merge_cancel.jinja' %} + + {% endif %} +{% endblock %} + +{% block content %} + + + + +{% import 'partials/awards_album.jinja' as awards %} + + + + + + + +
+ {% if adminmode %} +
+ {% else %} +
+
+ {% endif %} +
+ {{ links.links(album.artists) }}
+

{{ info.album.albumtitle | e }}

+ {# awards.certs(album) #} + #{{ info.position }} +
+ +

+ {{ info['scrobbles'] }} Scrobbles +

+ + + + + + {{ awards.medals(info) }} + {{ awards.topweeks(info) }} + + +
+ +

Top Tracks

+ + +{% with amountkeys={"perpage":15,"page":0} %} +{% include 'partials/charts_tracks.jinja' %} +{% endwith %} + +
+ + + + + + + + +
+

Pulse

+
+ {% for r in xranges %} + + {{ r.localisation }} + + {% if not loop.last %}|{% endif %} + {% endfor %} + +

+ + {% for r in xranges %} + + + + {% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %} + {% include 'partials/pulse.jinja' %} + {% endwith %} + + + {% endfor %} +
+ +

Performance

+
+ {% for r in xranges %} + + {{ r.localisation }} + + {% if not loop.last %}|{% endif %} + {% endfor %} + +

+ + {% for r in xranges %} + + + + {% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %} + {% include 'partials/performance.jinja' %} + {% endwith %} + + + {% endfor %} + +
+ + +

Last Scrobbles

+ +{% with amountkeys = {"perpage":15,"page":0} %} +{% include 'partials/scrobbles.jinja' %} +{% endwith %} + + +{% endblock %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index acac7b1..6f3b635 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -90,8 +90,14 @@ -

Top Tracks

+

Top Albums

+{% with amountkeys={"perpage":15,"page":0} %} +{% include 'partials/charts_albums.jinja' %} +{% endwith %} + + +

Top Tracks

{% with amountkeys={"perpage":15,"page":0} %} {% include 'partials/charts_tracks.jinja' %} diff --git a/maloja/web/jinja/charts_albums.jinja b/maloja/web/jinja/charts_albums.jinja new file mode 100644 index 0000000..c442df2 --- /dev/null +++ b/maloja/web/jinja/charts_albums.jinja @@ -0,0 +1,52 @@ +{% extends "abstracts/base.jinja" %} +{% block title %}Maloja - Album Charts{% endblock %} + +{% import 'snippets/links.jinja' as links %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} + +{% block scripts %} + +{% endblock %} + +{% set charts = dbc.get_charts_albums(filterkeys,limitkeys) %} +{% set pages = math.ceil(charts.__len__() / amountkeys.perpage) %} +{% if charts[0] is defined %} + {% set topalbum = charts[0].album %} + {% set img = images.get_album_image(topalbum) %} +{% else %} + {% set img = "/favicon.png" %} +{% endif %} + + +{% block content %} + + + + + + +
+
+
+

Album Charts

View #1 Albums
+ {{ filterdesc.desc(filterkeys,limitkeys) }} +

+ {% with delimitkeys = {} %} + {% include 'snippets/timeselection.jinja' %} + {% endwith %} + +
+ +{% if settings['CHARTS_DISPLAY_TILES'] %} + {% include 'partials/charts_albums_tiles.jinja' %} +

+{% endif %} + +{% with compare=true %} +{% include 'partials/charts_albums.jinja' %} +{% endwith %} + +{% import 'snippets/pagination.jinja' as pagination %} +{{ pagination.pagination(filterkeys,limitkeys,delimitkeys,amountkeys,pages) }} + +{% endblock %} diff --git a/maloja/web/jinja/charts_artists.jinja b/maloja/web/jinja/charts_artists.jinja index 87f51ff..627f38c 100644 --- a/maloja/web/jinja/charts_artists.jinja +++ b/maloja/web/jinja/charts_artists.jinja @@ -1,6 +1,8 @@ {% extends "abstracts/base.jinja" %} {% block title %}Maloja - Artist Charts{% endblock %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} + {% block scripts %} {% endblock %} @@ -25,7 +27,7 @@

Artist Charts

View #1 Artists
- {{ limitkeys.timerange.desc(prefix=True) }} + {{ filterdesc.desc(filterkeys,limitkeys) }}

{% with delimitkeys = {} %} {% include 'snippets/timeselection.jinja' %} diff --git a/maloja/web/jinja/charts_tracks.jinja b/maloja/web/jinja/charts_tracks.jinja index 17b2f20..d265c3a 100644 --- a/maloja/web/jinja/charts_tracks.jinja +++ b/maloja/web/jinja/charts_tracks.jinja @@ -2,6 +2,7 @@ {% block title %}Maloja - Track Charts{% endblock %} {% import 'snippets/links.jinja' as links %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} {% block scripts %} @@ -26,8 +27,7 @@

Track Charts

View #1 Tracks
- {% if filterkeys.get('artist') is not none %}by {{ links.link(filterkeys.get('artist')) }}{% endif %} - {{ limitkeys.timerange.desc(prefix=True) }} + {{ filterdesc.desc(filterkeys,limitkeys) }}

{% with delimitkeys = {} %} {% include 'snippets/timeselection.jinja' %} diff --git a/maloja/web/jinja/partials/awards_album.jinja b/maloja/web/jinja/partials/awards_album.jinja new file mode 100644 index 0000000..5ded047 --- /dev/null +++ b/maloja/web/jinja/partials/awards_album.jinja @@ -0,0 +1,42 @@ +{% macro medals(info) %} + + +{% for year in info.medals.gold -%} + + {{ year }} + +{%- endfor %} +{% for year in info.medals.silver -%} + + {{ year }} + +{%- endfor %} +{% for year in info.medals.bronze -%} + + {{ year }} + +{%- endfor %} + +{%- endmacro %} + + + + + + + + +{% macro topweeks(info) %} + +{% set encodedtrack = mlj_uri.uriencode({'album':info.album}) %} + + + + {% if info.topweeks > 0 %} + + {{ info.topweeks }} + + {% endif %} + + +{%- endmacro %} diff --git a/maloja/web/jinja/partials/charts_albums.jinja b/maloja/web/jinja/partials/charts_albums.jinja new file mode 100644 index 0000000..a779e62 --- /dev/null +++ b/maloja/web/jinja/partials/charts_albums.jinja @@ -0,0 +1,56 @@ +{% import 'snippets/links.jinja' as links %} +{% import 'snippets/entityrow.jinja' as entityrow %} + +{% if charts is undefined %} + {% set charts = dbc.get_charts_albums(filterkeys,limitkeys) %} +{% endif %} +{% if compare %} + {% if compare is true %} + {% set compare = limitkeys.timerange.next(step=-1) %} + {% if compare is none %}{% set compare = False %}{% endif %} + {% endif %} + {% if compare %} + {% set prevalbums = dbc.get_charts_albums(filterkeys,{'timerange':compare}) %} + + {% set lastranks = {} %} + {% for t in prevalbums %} + {% if lastranks.update({"|".join(t.album.artists or [])+"||"+t.album.albumtitle:t.rank}) %}{% endif %} + {% endfor %} + + {% for t in charts %} + {% if "|".join(t.album.artists or [])+"||"+t.album.albumtitle in lastranks %} + {% if t.update({'last_rank':lastranks["|".join(t.album.artists or [])+"||"+t.album.albumtitle]}) %}{% endif %} + {% endif %} + {% endfor %} + {% endif %} +{% endif %} + +{% set firstindex = amountkeys.page * amountkeys.perpage %} +{% set lastindex = firstindex + amountkeys.perpage %} + +{% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %} + + {% for e in charts %} + {% if loop.index0 >= firstindex and loop.index0 < lastindex %} + + + + + {% if compare %} + {% if e.last_rank is undefined %} + {% elif e.last_rank < e.rank %} + {% elif e.last_rank > e.rank %} + {% elif e.last_rank == e.rank %} + {% endif %} + {% endif %} + + + {{ entityrow.row(e['album']) }} + + + + + + {% endif %} + {% endfor %} +
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}🆕↘↗➡{{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}{{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],percent=e['scrobbles']*100/maxbar) }}
diff --git a/maloja/web/jinja/partials/charts_albums_tiles.jinja b/maloja/web/jinja/partials/charts_albums_tiles.jinja new file mode 100644 index 0000000..56e3c4e --- /dev/null +++ b/maloja/web/jinja/partials/charts_albums_tiles.jinja @@ -0,0 +1,45 @@ +{% import 'snippets/links.jinja' as links %} + + +{% if charts is undefined %} + {% set charts = dbc.get_charts_albums(limitkeys) %} +{% endif %} + +{% set charts_14 = charts | fixlength(14) %} +{% set charts_cycler = cycler(*charts_14) %} + + + + +{% for segment in range(3) %} + {% if charts_14[0] is none and loop.first %} + {% include 'icons/nodata.jinja' %} + {% else %} + + {% endif %} +{% endfor %} +
+ {% set segmentsize = segment+1 %} + + {% for row in range(segmentsize) -%} + + {% for col in range(segmentsize) %} + {% set entry = charts_cycler.next() %} + {% if entry is not none %} + {% set album = entry.album %} + {% set rank = entry.rank %} + + {% else -%} + + {%- endif -%} + {%- endfor -%} + + {%- endfor -%} +
+ +
+ #{{ rank }} {{ album.title }} +
+
+
+
diff --git a/maloja/web/jinja/partials/top_albums.jinja b/maloja/web/jinja/partials/top_albums.jinja new file mode 100644 index 0000000..cdedd6a --- /dev/null +++ b/maloja/web/jinja/partials/top_albums.jinja @@ -0,0 +1,30 @@ +{% import 'snippets/links.jinja' as links %} +{% import 'snippets/entityrow.jinja' as entityrow %} + +{% set ranges = dbc.get_top_albums(filterkeys,limitkeys,delimitkeys) %} + +{% set maxbar = ranges|map(attribute="scrobbles")|max|default(1) %} +{% if maxbar < 1 %}{% set maxbar = 1 %}{% endif %} + + + {% for e in ranges %} + + {% set thisrange = e.range %} + {% set album = e.album %} + + + + {% if album is none %} + + + + + {% else %} + {{ entityrow.row(album) }} + + + {% endif %} + + + {% endfor %} +
{{ thisrange.desc() }}
n/a0{{ links.link_scrobbles([{'album':album,'timerange':thisrange}],amount=e.scrobbles) }} {{ links.link_scrobbles([{'album':album,'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}
diff --git a/maloja/web/jinja/snippets/entityrow.jinja b/maloja/web/jinja/snippets/entityrow.jinja index 7601fea..2721f0c 100644 --- a/maloja/web/jinja/snippets/entityrow.jinja +++ b/maloja/web/jinja/snippets/entityrow.jinja @@ -2,8 +2,10 @@ {% import 'snippets/links.jinja' as links %} -{% if entity is mapping and 'artists' in entity %} +{% if entity is mapping and 'title' in entity %} {% set img = images.get_track_image(entity) %} +{% elif entity is mapping and 'albumtitle' in entity %} + {% set img = images.get_album_image(entity) %} {% else %} {% set img = images.get_artist_image(entity) %} {% endif %} @@ -20,6 +22,10 @@ {{ links.links(entity.artists) }} – {{ links.link(entity) }} +{% elif entity is mapping and 'albumtitle' in entity %} + + {{ links.links(entity.artists) }} – {{ links.link(entity) }} + {% else %} {{ links.link(entity) }} {% if counting != [] %} diff --git a/maloja/web/jinja/snippets/filterdescription.jinja b/maloja/web/jinja/snippets/filterdescription.jinja index 820d501..4a5135f 100644 --- a/maloja/web/jinja/snippets/filterdescription.jinja +++ b/maloja/web/jinja/snippets/filterdescription.jinja @@ -7,6 +7,9 @@ {% elif filterkeys.get('track') is not none %} of {{ links.link(filterkeys.get('track')) }} by {{ links.links(filterkeys["track"]["artists"]) }} + {% elif filterkeys.get('album') is not none %} + of {{ links.link(filterkeys.get('album')) }} + by {{ links.links(filterkeys["album"]["artists"]) }} {% endif %} {{ limitkeys.timerange.desc(prefix=True) }} diff --git a/maloja/web/jinja/snippets/links.jinja b/maloja/web/jinja/snippets/links.jinja index e53a39f..3b5c29d 100644 --- a/maloja/web/jinja/snippets/links.jinja +++ b/maloja/web/jinja/snippets/links.jinja @@ -1,6 +1,6 @@ {% macro link(entity) -%} - {% if entity is mapping and 'artists' in entity %} - {% set name = entity.title %} + {% if entity is mapping and 'title' in entity or 'albumtitle' in entity %} + {% set name = entity.title or entity.albumtitle %} {% else %} {% set name = entity %} {% endif %} @@ -9,15 +9,21 @@ {%- endmacro %} {% macro links(entities) -%} - {% for entity in entities -%} - {{ link(entity) }}{{ ", " if not loop.last }} - {%- endfor %} + {% if entities is none or entities == [] %} + {{ settings["DEFAULT_ALBUM_ARTIST"] }} + {% else %} + {% for entity in entities -%} + {{ link(entity) }}{{ ", " if not loop.last }} + {%- endfor %} + {% endif %} {%- endmacro %} {% macro url(entity) %} - {% if entity is mapping and 'artists' in entity -%} + {% if entity is mapping and 'albumtitle' in entity -%} + {{ mlj_uri.create_uri("/album",{'album':entity}) }} + {% elif entity is mapping and 'artists' in entity -%} {{ mlj_uri.create_uri("/track",{'track':entity}) }} {%- else -%} {{ mlj_uri.create_uri("/artist",{'artist':entity}) }} @@ -43,6 +49,8 @@ {% if 'track' in filterkeys %} {% set url = mlj_uri.create_uri("/charts_tracks",{'timerange':timerange}) %} + {% elif 'album' in filterkeys %} + {% set url = mlj_uri.create_uri("/charts_albums",{'timerange':timerange}) %} {% elif 'artist' in filterkeys %} {% set url = mlj_uri.create_uri("/charts_artists",{'timerange':timerange}) %} {% endif %} diff --git a/maloja/web/jinja/top_albums.jinja b/maloja/web/jinja/top_albums.jinja new file mode 100644 index 0000000..df1a416 --- /dev/null +++ b/maloja/web/jinja/top_albums.jinja @@ -0,0 +1,31 @@ +{% extends "abstracts/base.jinja" %} +{% block title %}Maloja - #1 Albums{% endblock %} + +{% import 'snippets/filterdescription.jinja' as filterdesc %} + + + +{% set entries = dbc.get_top_albums(filterkeys,limitkeys,delimitkeys) %} +{% set repr = entries | find_representative('album','scrobbles') %} +{% set img = "/favicon.png" if repr is none else images.get_album_image(repr.album) %} + + +{% block content %} + + + + + +
+
+
+

#1 Albums


+ {{ filterdesc.desc(filterkeys,limitkeys) }} + +

+ {% include 'snippets/timeselection.jinja' %} +
+ + {% include 'partials/top_albums.jinja' %} + +{% endblock %} diff --git a/maloja/web/jinja/top_artists.jinja b/maloja/web/jinja/top_artists.jinja index 175d213..ce4bf24 100644 --- a/maloja/web/jinja/top_artists.jinja +++ b/maloja/web/jinja/top_artists.jinja @@ -1,6 +1,7 @@ {% extends "abstracts/base.jinja" %} {% block title %}Maloja - #1 Artists{% endblock %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} @@ -17,7 +18,7 @@

#1 Artists


- {{ limitkeys.timerange.desc(prefix=True) }} + {{ filterdesc.desc(filterkeys,limitkeys) }}

{% include 'snippets/timeselection.jinja' %} diff --git a/maloja/web/jinja/top_tracks.jinja b/maloja/web/jinja/top_tracks.jinja index 42ae78d..0f98dd3 100644 --- a/maloja/web/jinja/top_tracks.jinja +++ b/maloja/web/jinja/top_tracks.jinja @@ -1,6 +1,7 @@ {% extends "abstracts/base.jinja" %} {% block title %}Maloja - #1 Tracks{% endblock %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} @@ -17,7 +18,7 @@

#1 Tracks


- {{ limitkeys.timerange.desc(prefix=True) }} + {{ filterdesc.desc(filterkeys,limitkeys) }}

{% include 'snippets/timeselection.jinja' %} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 29cb404..3fbc909 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -64,6 +64,9 @@ {{ awards.certs(track) }} #{{ info.position }}
+ {% if info.track.album %} + from {{ links.link(info.track.album) }}
+ {% endif %}

{% if adminmode %}{% endif %}