From 8f3df9881c36109c26a6120db07568c8594be8d4 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 14:52:54 +0200 Subject: [PATCH 001/176] Added album support to database --- maloja/database/sqldb.py | 152 +++++++++++++++++++++++++++++++++------ 1 file changed, 132 insertions(+), 20 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 61f0b38..f22c27c 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -21,44 +21,71 @@ 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, {}) ], '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 +165,7 @@ def connection_provider(func): # "artists":list, # "title":string, # "album":{ -# "name":string, +# "title":string, # "artists":list # }, # "length":None @@ -207,6 +234,19 @@ 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[row.id], + "title":row.albtitle, + } + for row in rows + ] + +def album_db_to_dict(row,dbconn=None): + return albums_db_to_dict([row],dbconn=dbconn) + @@ -236,6 +276,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('title'), + "albtitle_normalized":normalize_name(info.get('title')) + } + @@ -310,7 +356,7 @@ 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] @@ -364,6 +410,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['title']) + artist_ids = [get_artist_id(a,dbconn=dbconn) for a in albumdict['artists']] + artist_ids = list(set(artist_ids)) + + + + + op = DB['albums'].select( +# DB['albums'].c.id + ).where( + DB['albums'].c.title_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.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",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 track_id + + + + ### Edit existing @@ -734,6 +833,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 From 7f62021d57755cd24ddf13debee40d2a5056bd9e Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 15:37:01 +0200 Subject: [PATCH 002/176] Added functions for albums --- maloja/database/__init__.py | 4 ++-- maloja/database/sqldb.py | 45 ++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 473cb7b..c6178f0 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -139,7 +139,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'), + "title":scrobbleinfo.get('album_title') or scrobbleinfo.get('album_name'), "artists":scrobbleinfo.get('album_artists') }, "length":scrobbleinfo.get('track_length') @@ -148,7 +148,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_name','album_artists'] }, "rawscrobble":rawscrobble } diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index f22c27c..9a5b17f 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -35,7 +35,8 @@ DBTABLES = { ("id", sql.Integer, {'primary_key':True}), ("title", sql.String, {}), ("title_normalized", sql.String, {}), - ("length", sql.Integer, {}) + ("length", sql.Integer, {}), + ("album_id", sql.Integer, sql.ForeignKey('albums.id'), {}) ], 'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True} }, @@ -73,15 +74,15 @@ DBTABLES = { ], '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':{} - }, +# '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'), {}), @@ -331,11 +332,27 @@ def delete_scrobble(scrobble_id,dbconn=None): return True +@connection_provider +def add_track_to_album(track_id,album_id,replace=False,dbconn=None): + + op = DB['tracks'].update().where( + DB['tracks'].c.id == track_id, + (DB['tracks'].c.album_id == None) or replace + # update if we want replacement or if there is no album yet + ).values( + DB['tracks'].c.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)) @@ -363,11 +380,11 @@ def get_track_id(trackdict,create_new=True,dbconn=None): #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]) + add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album) return row.id if not create_new: return None - op = DB['tracks'].insert().values( **track_dict_to_db(trackdict,dbconn=dbconn) ) @@ -381,6 +398,8 @@ def get_track_id(trackdict,create_new=True,dbconn=None): ) result = dbconn.execute(op) #print("Created",trackdict['title'],track_id) + + add_track_to_album(track_id,get_album_id(trackdict['album'])) return track_id @cached_wrapper @@ -458,7 +477,7 @@ def get_album_id(albumdict,create_new=True,dbconn=None): ) result = dbconn.execute(op) #print("Created",trackdict['title'],track_id) - return track_id + return album_id From 3a4f145f41cd1c00a694f2d8b83c8e0b33b25d3d Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 16:04:50 +0200 Subject: [PATCH 003/176] Added album support for scrobbling --- maloja/apis/listenbrainz.py | 2 +- maloja/apis/native_v1.py | 2 +- maloja/database/__init__.py | 2 ++ maloja/database/sqldb.py | 25 +++++++++++++++++-------- 4 files changed, 21 insertions(+), 10 deletions(-) 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..4e9c49f 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, diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index c6178f0..54b76c9 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -130,8 +130,10 @@ 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']) + 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'), diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 9a5b17f..3ec27f2 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -335,12 +335,19 @@ def delete_scrobble(scrobble_id,dbconn=None): @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( - DB['tracks'].c.id == track_id, - (DB['tracks'].c.album_id == None) or replace - # update if we want replacement or if there is no album yet + *conditions ).values( - DB['tracks'].c.album_id = album_id + album_id=album_id ) result = dbconn.execute(op) @@ -380,7 +387,8 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): #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]) - add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album) + if 'album' in trackdict: + add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album) return row.id if not create_new: return None @@ -399,7 +407,8 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): result = dbconn.execute(op) #print("Created",trackdict['title'],track_id) - add_track_to_album(track_id,get_album_id(trackdict['album'])) + if 'album' in trackdict: + add_track_to_album(track_id,get_album_id(trackdict['album'])) return track_id @cached_wrapper @@ -442,7 +451,7 @@ def get_album_id(albumdict,create_new=True,dbconn=None): op = DB['albums'].select( # DB['albums'].c.id ).where( - DB['albums'].c.title_normalized==ntitle + DB['albums'].c.albtitle_normalized==ntitle ) result = dbconn.execute(op).all() for row in result: @@ -452,7 +461,7 @@ def get_album_id(albumdict,create_new=True,dbconn=None): op = DB['albumartists'].select( # DB['albumartists'].c.artist_id ).where( - DB['albumartists'].c.track_id==row.id + DB['albumartists'].c.album_id==row.id ) result = dbconn.execute(op).all() match_artist_ids = [r.artist_id for r in result] From 657bb7e6d7d41cd825aacb7ec9b9941e5966f62d Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 16:14:29 +0200 Subject: [PATCH 004/176] Added setting for album information update --- maloja/database/__init__.py | 4 +++- maloja/database/sqldb.py | 12 ++++++------ maloja/pkg_global/conf.py | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 54b76c9..e8acca5 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 = (malojaconf["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']) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 3ec27f2..4b33dbe 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -254,12 +254,12 @@ def album_db_to_dict(row,dbconn=None): ### 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 } @@ -291,17 +291,17 @@ def album_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 ] diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index ea6a8c0..f11d569 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"}),"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"), From 4620ed1407450beac876adce8d384bb835aecfba Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 16:37:01 +0200 Subject: [PATCH 005/176] Added album support to URI handler --- maloja/malojauri.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/maloja/malojauri.py b/maloja/malojauri.py index 6b1af6d..b18ca9f 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"),"title":keys.get("title") or keys.get("albumtitle")}} 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"]["artists"]: + urikeys.append("artist",a) + urikeys.append("albumtitle",keys["album"]["title"]) #time if "timerange" in keys: From e7b1cb469d615cedcae766cfa9122c3b0e86cddd Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 17:22:16 +0200 Subject: [PATCH 006/176] Implemented several functions for albums --- maloja/database/__init__.py | 41 +++++++++++++++++++++++++- maloja/database/cached.py | 22 ++++++++++++-- maloja/database/sqldb.py | 57 ++++++++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index e8acca5..6a73247 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -93,7 +93,7 @@ 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 = (malojaconf["ALBUM_INFORMATION_TRUST"] == 'last') + albumupdate = (malojaconfig["ALBUM_INFORMATION_TRUST"] == 'last') sqldb.add_scrobble(scrobbledict,update_album=albumupdate,dbconn=dbconn) @@ -267,6 +267,15 @@ def get_charts_tracks(dbconn=None,**keys): 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): @@ -421,6 +430,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 4b33dbe..a86bc32 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -246,7 +246,7 @@ def albums_db_to_dict(rows,dbconn=None): ] def album_db_to_dict(row,dbconn=None): - return albums_db_to_dict([row],dbconn=dbconn) + return albums_db_to_dict([row],dbconn=dbconn)[0] @@ -814,6 +814,35 @@ 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 def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None): @@ -909,6 +938,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 @@ -975,6 +1020,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 From 6d55d605353548db95de03f3c158b269fbab4fd6 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 17:36:13 +0200 Subject: [PATCH 007/176] Implemented more album functions --- maloja/database/__init__.py | 10 ++++++++++ maloja/database/sqldb.py | 27 +++++++++++++++++++++++++-- maloja/images.py | 5 +++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 6a73247..45211ed 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -220,6 +220,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']] @@ -312,6 +314,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}) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index a86bc32..36ccc1e 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -388,7 +388,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): if set(artist_ids) == set(match_artist_ids): #print("ID for",trackdict['title'],"was",row[0]) if 'album' in trackdict: - add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album) + add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album,dbconn=dbconn) return row.id if not create_new: return None @@ -408,7 +408,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): #print("Created",trackdict['title'],track_id) if 'album' in trackdict: - add_track_to_album(track_id,get_album_id(trackdict['album'])) + add_track_to_album(track_id,get_album_id(trackdict['album']),dbconn=dbconn) return track_id @cached_wrapper @@ -671,6 +671,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): diff --git a/maloja/images.py b/maloja/images.py index f1c569a..103b0f1 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -115,6 +115,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) From 1086dfee25b02e0d09e8e3116d728530ff932d1b Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 18:16:15 +0200 Subject: [PATCH 008/176] Implemented and changed more album stuff --- maloja/database/__init__.py | 17 +++++++++++++++++ maloja/database/sqldb.py | 10 +++++----- maloja/malojauri.py | 4 ++-- maloja/pkg_global/conf.py | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 45211ed..fb9f48b 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -235,6 +235,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 @@ -359,6 +361,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): diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 36ccc1e..01e2975 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -166,7 +166,7 @@ def connection_provider(func): # "artists":list, # "title":string, # "album":{ -# "title":string, +# "albumtitle":string, # "artists":list # }, # "length":None @@ -240,7 +240,7 @@ def albums_db_to_dict(rows,dbconn=None): return [ { "artists":artists[row.id], - "title":row.albtitle, + "albumtitle":row.albtitle, } for row in rows ] @@ -279,8 +279,8 @@ def artist_dict_to_db(info,dbconn=None): def album_dict_to_db(info,dbconn=None): return { - "albtitle":info.get('title'), - "albtitle_normalized":normalize_name(info.get('title')) + "albtitle":info.get('albumtitle'), + "albtitle_normalized":normalize_name(info.get('albumtitle')) } @@ -441,7 +441,7 @@ def get_artist_id(artistname,create_new=True,dbconn=None): @cached_wrapper @connection_provider def get_album_id(albumdict,create_new=True,dbconn=None): - ntitle = normalize_name(albumdict['title']) + ntitle = normalize_name(albumdict['albumtitle']) artist_ids = [get_artist_id(a,dbconn=dbconn) for a in albumdict['artists']] artist_ids = list(set(artist_ids)) diff --git a/maloja/malojauri.py b/maloja/malojauri.py index b18ca9f..e2675cc 100644 --- a/maloja/malojauri.py +++ b/maloja/malojauri.py @@ -28,7 +28,7 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api filterkeys = {"artist":keys.get("artist")} if "associated" in keys: filterkeys["associated"] = True elif type == "album": - filterkeys = {"album":{"artists":keys.getall("artist"),"title":keys.get("title") or keys.get("albumtitle")}} + filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("title") or keys.get("albumtitle")}} else: filterkeys = {} @@ -98,7 +98,7 @@ def internal_to_uri(keys): elif "album" in keys: for a in keys["album"]["artists"]: urikeys.append("artist",a) - urikeys.append("albumtitle",keys["album"]["title"]) + 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 f11d569..164cfa3 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -177,7 +177,7 @@ malojaconfig = Configuration( }, "Database":{ - "album_information_trust":(tp.Choice({'first':"First",'last':"Last"}),"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"), + "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"), From 1a43aa302d63ec3b158bbfbae3cfae924363a5d8 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 18:32:45 +0200 Subject: [PATCH 009/176] Updated API tests --- dev/testing/Maloja.postman_collection.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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": "" From 69b456dc73e1108e374091cddd07337a7390463e Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 18:41:49 +0200 Subject: [PATCH 010/176] Scrobbling fixes --- maloja/apis/native_v1.py | 10 +++++++--- maloja/database/__init__.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 4e9c49f..cd031a2 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -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 diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index fb9f48b..9204367 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -143,7 +143,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "artists":scrobbleinfo.get('track_artists'), "title":scrobbleinfo.get('track_title'), "album":{ - "title":scrobbleinfo.get('album_title') or scrobbleinfo.get('album_name'), + "albumtitle":scrobbleinfo.get('album_title'), "artists":scrobbleinfo.get('album_artists') }, "length":scrobbleinfo.get('track_length') @@ -152,7 +152,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_title','album_name','album_artists'] + ['scrobble_time','track_artists','track_title','track_length','scrobble_duration','album_title','album_artists'] }, "rawscrobble":rawscrobble } From fd9987ec35ce033b8aa3b1f54a01d6f687234dd1 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 19:58:12 +0200 Subject: [PATCH 011/176] Implemented images for albums --- maloja/apis/native_v1.py | 4 +- maloja/images.py | 98 ++++++++++++++++++++++---------- maloja/server.py | 4 +- maloja/thirdparty/__init__.py | 33 ++++++++++- maloja/thirdparty/audiodb.py | 8 ++- maloja/thirdparty/deezer.py | 11 +++- maloja/thirdparty/lastfm.py | 9 ++- maloja/thirdparty/musicbrainz.py | 2 + maloja/thirdparty/spotify.py | 4 +- 9 files changed, 133 insertions(+), 40 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index cd031a2..f7e36a7 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -519,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 @@ -531,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/images.py b/maloja/images.py index 103b0f1..f92e91f 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) @@ -137,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) @@ -181,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['artists']] + 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: @@ -215,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 != "": @@ -246,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 = [] @@ -276,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") @@ -293,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) @@ -308,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/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"], } From 99cb8f4c647018d0a27ce80438bd140b0280e9c3 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 19:58:25 +0200 Subject: [PATCH 012/176] Fixed DB locking --- maloja/database/sqldb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 01e2975..918fa43 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -388,7 +388,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): if set(artist_ids) == set(match_artist_ids): #print("ID for",trackdict['title'],"was",row[0]) if 'album' in trackdict: - add_track_to_album(row.id,get_album_id(trackdict['album']),replace=update_album,dbconn=dbconn) + 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 @@ -408,7 +408,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): #print("Created",trackdict['title'],track_id) if 'album' in trackdict: - add_track_to_album(track_id,get_album_id(trackdict['album']),dbconn=dbconn) + add_track_to_album(track_id,get_album_id(trackdict['album'],dbconn=dbconn),dbconn=dbconn) return track_id @cached_wrapper From 27a2bc705a6fa78dbd52b5f1e2258074c48710b4 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 20:26:46 +0200 Subject: [PATCH 013/176] Added web pages for albums --- maloja/web/jinja/album.jinja | 154 ++++++++++++++++++ maloja/web/jinja/charts_albums.jinja | 52 ++++++ maloja/web/jinja/partials/awards_album.jinja | 42 +++++ maloja/web/jinja/partials/charts_albums.jinja | 56 +++++++ .../jinja/partials/charts_albums_tiles.jinja | 45 +++++ maloja/web/jinja/partials/top_albums.jinja | 30 ++++ maloja/web/jinja/snippets/entityrow.jinja | 8 +- .../jinja/snippets/filterdescription.jinja | 3 + maloja/web/jinja/snippets/links.jinja | 8 +- maloja/web/jinja/top_albums.jinja | 30 ++++ 10 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 maloja/web/jinja/album.jinja create mode 100644 maloja/web/jinja/charts_albums.jinja create mode 100644 maloja/web/jinja/partials/awards_album.jinja create mode 100644 maloja/web/jinja/partials/charts_albums.jinja create mode 100644 maloja/web/jinja/partials/charts_albums_tiles.jinja create mode 100644 maloja/web/jinja/partials/top_albums.jinja create mode 100644 maloja/web/jinja/top_albums.jinja diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja new file mode 100644 index 0000000..4a6f855 --- /dev/null +++ b/maloja/web/jinja/album.jinja @@ -0,0 +1,154 @@ +{% 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) }} + + +
+ + + + + + + + +
+

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/charts_albums.jinja b/maloja/web/jinja/charts_albums.jinja new file mode 100644 index 0000000..7d2667a --- /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 %} + +{% 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
+ {% if filterkeys.get('artist') is not none %}by {{ links.link(filterkeys.get('artist')) }}{% endif %} + {{ limitkeys.timerange.desc(prefix=True) }} +

+ {% 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/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..b6bcc0f --- /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)+"||"+t.album.albumtitle:t.rank}) %}{% endif %} + {% endfor %} + + {% for t in charts %} + {% if "|".join(t.album.artists)+"||"+t.album.albumtitle in lastranks %} + {% if t.update({'last_rank':lastranks["|".join(t.album.artists)+"||"+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..e49b2c3 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 %} + {% set name = entity.title or entity.albumtitle %} {% else %} {% set name = entity %} {% endif %} @@ -17,7 +17,9 @@ {% 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 +45,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..c7a0782 --- /dev/null +++ b/maloja/web/jinja/top_albums.jinja @@ -0,0 +1,30 @@ +{% extends "abstracts/base.jinja" %} +{% block title %}Maloja - #1 Albums{% endblock %} + + + + +{% 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


+ {{ limitkeys.timerange.desc(prefix=True) }} + +

+ {% include 'snippets/timeselection.jinja' %} +
+ + {% include 'partials/top_albums.jinja' %} + +{% endblock %} From 4a0bd4b97ebf6a4d76f484b83ed603f3345ee930 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 20:58:50 +0200 Subject: [PATCH 014/176] More album functionality --- maloja/database/__init__.py | 2 ++ maloja/database/sqldb.py | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 9204367..5fbd5ed 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -267,6 +267,8 @@ 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 diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 918fa43..633fc52 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -213,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 @@ -387,7 +388,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): #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 'album' in trackdict: + 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 @@ -407,7 +408,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): result = dbconn.execute(op) #print("Created",trackdict['title'],track_id) - if 'album' in trackdict: + if trackdict.get('album'): add_track_to_album(track_id,get_album_id(trackdict['album'],dbconn=dbconn),dbconn=dbconn) return track_id @@ -896,6 +897,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 From add79916042c963309d76b6909b146a61b4376f8 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 21:00:39 +0200 Subject: [PATCH 015/176] Unified filter desciptions --- maloja/web/jinja/charts_albums.jinja | 4 ++-- maloja/web/jinja/charts_artists.jinja | 4 +++- maloja/web/jinja/charts_tracks.jinja | 4 ++-- maloja/web/jinja/snippets/links.jinja | 2 +- maloja/web/jinja/top_albums.jinja | 3 ++- maloja/web/jinja/top_artists.jinja | 3 ++- maloja/web/jinja/top_tracks.jinja | 3 ++- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/maloja/web/jinja/charts_albums.jinja b/maloja/web/jinja/charts_albums.jinja index 7d2667a..c442df2 100644 --- a/maloja/web/jinja/charts_albums.jinja +++ b/maloja/web/jinja/charts_albums.jinja @@ -2,6 +2,7 @@ {% block title %}Maloja - Album Charts{% endblock %} {% import 'snippets/links.jinja' as links %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} {% block scripts %} @@ -26,8 +27,7 @@

Album Charts

View #1 Albums
- {% 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/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/snippets/links.jinja b/maloja/web/jinja/snippets/links.jinja index e49b2c3..280ed75 100644 --- a/maloja/web/jinja/snippets/links.jinja +++ b/maloja/web/jinja/snippets/links.jinja @@ -1,5 +1,5 @@ {% macro link(entity) -%} - {% if entity is mapping and 'artists' in entity %} + {% if entity is mapping and 'title' in entity or 'albumtitle' in entity %} {% set name = entity.title or entity.albumtitle %} {% else %} {% set name = entity %} diff --git a/maloja/web/jinja/top_albums.jinja b/maloja/web/jinja/top_albums.jinja index c7a0782..df1a416 100644 --- a/maloja/web/jinja/top_albums.jinja +++ b/maloja/web/jinja/top_albums.jinja @@ -1,6 +1,7 @@ {% extends "abstracts/base.jinja" %} {% block title %}Maloja - #1 Albums{% endblock %} +{% import 'snippets/filterdescription.jinja' as filterdesc %} @@ -17,7 +18,7 @@

#1 Albums


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

{% include 'snippets/timeselection.jinja' %} 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' %} From 4d1f810e9213fde98da035d10588a9ecbfa2a9de Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 21:48:44 +0200 Subject: [PATCH 016/176] Improved support for artistless albums --- maloja/database/__init__.py | 3 ++- maloja/database/sqldb.py | 4 ++-- maloja/images.py | 2 +- maloja/malojauri.py | 4 ++-- maloja/pkg_global/conf.py | 1 + maloja/web/jinja/partials/charts_albums.jinja | 6 +++--- maloja/web/jinja/snippets/links.jinja | 10 +++++++--- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 5fbd5ed..7cb3c6b 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -132,7 +132,8 @@ 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']) - scrobbleinfo['album_artists'] = cla.parseArtists(scrobbleinfo['album_artists']) + 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()) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 633fc52..b4fd2c2 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -240,7 +240,7 @@ 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[row.id], + "artists":artists.get(row.id), "albumtitle":row.albtitle, } for row in rows @@ -443,7 +443,7 @@ def get_artist_id(artistname,create_new=True,dbconn=None): @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['artists']] + artist_ids = [get_artist_id(a,dbconn=dbconn) for a in albumdict.get('artists') or []] artist_ids = list(set(artist_ids)) diff --git a/maloja/images.py b/maloja/images.py index f92e91f..4c2fa22 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -226,7 +226,7 @@ def get_all_possible_filenames(artist=None,track=None,album=None): 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['artists']] + title, artists = clean(album['albumtitle']), [clean(a) for a in album.get('artists') or []] superfolder = "albums/" elif artist: artist = clean(artist) diff --git a/maloja/malojauri.py b/maloja/malojauri.py index e2675cc..350a7e4 100644 --- a/maloja/malojauri.py +++ b/maloja/malojauri.py @@ -28,7 +28,7 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api filterkeys = {"artist":keys.get("artist")} if "associated" in keys: filterkeys["associated"] = True elif type == "album": - filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("title") or keys.get("albumtitle")}} + filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("albumtitle") or keys.get("title")}} else: filterkeys = {} @@ -96,7 +96,7 @@ def internal_to_uri(keys): urikeys.append("artist",a) urikeys.append("title",keys["track"]["title"]) elif "album" in keys: - for a in keys["album"]["artists"]: + for a in keys["album"].get("artists") or []: urikeys.append("artist",a) urikeys.append("albumtitle",keys["album"]["albumtitle"]) diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 164cfa3..7224970 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -192,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/web/jinja/partials/charts_albums.jinja b/maloja/web/jinja/partials/charts_albums.jinja index b6bcc0f..a779e62 100644 --- a/maloja/web/jinja/partials/charts_albums.jinja +++ b/maloja/web/jinja/partials/charts_albums.jinja @@ -14,12 +14,12 @@ {% set lastranks = {} %} {% for t in prevalbums %} - {% if lastranks.update({"|".join(t.album.artists)+"||"+t.album.albumtitle:t.rank}) %}{% endif %} + {% if lastranks.update({"|".join(t.album.artists or [])+"||"+t.album.albumtitle:t.rank}) %}{% endif %} {% endfor %} {% for t in charts %} - {% if "|".join(t.album.artists)+"||"+t.album.albumtitle in lastranks %} - {% if t.update({'last_rank':lastranks["|".join(t.album.artists)+"||"+t.album.albumtitle]}) %}{% endif %} + {% 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 %} diff --git a/maloja/web/jinja/snippets/links.jinja b/maloja/web/jinja/snippets/links.jinja index 280ed75..3b5c29d 100644 --- a/maloja/web/jinja/snippets/links.jinja +++ b/maloja/web/jinja/snippets/links.jinja @@ -9,9 +9,13 @@ {%- 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 %} From dc2a8a54f99134d2530fb4494b4251537e782efc Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 21:49:21 +0200 Subject: [PATCH 017/176] Implemented additional album functions --- maloja/database/sqldb.py | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index b4fd2c2..1c0e0c5 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -867,6 +867,83 @@ def count_scrobbles_by_album(since,to,resolve_ids=True,dbconn=None): 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): From c7f392a74f42651a402545b71ad21b085300120b Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 21:50:24 +0200 Subject: [PATCH 018/176] Created some interlinking with the new album pages --- maloja/web/jinja/album.jinja | 9 +++++++++ maloja/web/jinja/artist.jinja | 8 +++++++- maloja/web/jinja/track.jinja | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 4a6f855..6a16da3 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -76,6 +76,15 @@ +

Top Tracks

+ + +{% with amountkeys={"perpage":15,"page":0} %} +{% include 'partials/charts_tracks.jinja' %} +{% endwith %} + +
+ 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/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 %} From e23a1863fcabd88d2a749ca98d55b7d5b46e9e07 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 28 Mar 2023 22:40:34 +0200 Subject: [PATCH 019/176] Added patch notes --- dev/releases/3.1.yml | 3 --- dev/releases/3.2.yml | 7 +++++++ 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 dev/releases/3.2.yml diff --git a/dev/releases/3.1.yml b/dev/releases/3.1.yml index 7187b5b..a417691 100644 --- a/dev/releases/3.1.yml +++ b/dev/releases/3.1.yml @@ -41,6 +41,3 @@ minor_release_name: "Soyeon" - "[Bugfix] Fixed image display on Safari" - "[Bugfix] Fixed entity editing on Firefox" - "[Bugfix] Made compatibile with SQLAlchemy 2.0" -upcoming: - notes: - - "[Bugfix] Fixed configuration of time format" diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml new file mode 100644 index 0000000..b377c50 --- /dev/null +++ b/dev/releases/3.2.yml @@ -0,0 +1,7 @@ +minor_release_name: "Momo" +3.2.0: + notes: + - "[Architecture] Switched to linuxserver.io container base image" + - "[Performance] Improved image rendering" + - "[Bugfix] Fixed configuration of time format" + - "[Bugfix] Fixed search on manual scrobble page" From 688cac81eef41d5bf71b84caab0163bfef948349 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 00:19:40 +0200 Subject: [PATCH 020/176] Implemented web editing for albums --- maloja/apis/native_v1.py | 10 +++++++++ maloja/database/__init__.py | 10 +++++++++ maloja/database/sqldb.py | 22 +++++++++++++++++++ .../jinja/partials/charts_albums_tiles.jinja | 2 +- maloja/web/static/js/edit.js | 5 +++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index f7e36a7..585fa11 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -714,6 +714,16 @@ def edit_track(id,title): "status":"success" } +@api.post("edit_album") +@authenticated_function(api=True) +@catch_exceptions +def edit_album(id,albumtitle): + """Internal Use Only""" + result = database.edit_album(id,{'albumtitle':albumtitle}) + return { + "status":"success" + } + @api.post("merge_tracks") @authenticated_function(api=True) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 7cb3c6b..978b569 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -189,6 +189,16 @@ def edit_track(id,trackinfo): return result +@waitfordb +def edit_album(id,albuminfo): + album = sqldb.get_album(id) + log(f"Renaming {album['albumtitle']} to {albuminfo['albumtitle']}") + result = sqldb.edit_album(id,albuminfo) + dbcache.invalidate_entity_cache() + dbcache.invalidate_caches() + + return result + @waitfordb def merge_artists(target_id,source_ids): sources = [sqldb.get_artist(id) for id in source_ids] diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 1c0e0c5..b28e89a 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -558,6 +558,28 @@ def edit_track(id,trackupdatedict,dbconn=None): return True +@connection_provider +def edit_album(id,albumupdatedict,dbconn=None): + + album = get_album(id,dbconn=dbconn) + changedalbum = {**album,**albumupdatedict} + + dbentry = album_dict_to_db(albumupdatedict,dbconn=dbconn) + dbentry = {k:v for k,v in dbentry.items() if v} + + existing_album_id = get_album_id(changedalbum,create_new=False,dbconn=dbconn) + if existing_album_id not in (None,id): + raise exc.TrackExists(changedalbum) + + op = DB['albums'].update().where( + DB['albums'].c.id==id + ).values( + **dbentry + ) + result = dbconn.execute(op) + + return True + ### Merge diff --git a/maloja/web/jinja/partials/charts_albums_tiles.jinja b/maloja/web/jinja/partials/charts_albums_tiles.jinja index 56e3c4e..400d68b 100644 --- a/maloja/web/jinja/partials/charts_albums_tiles.jinja +++ b/maloja/web/jinja/partials/charts_albums_tiles.jinja @@ -28,7 +28,7 @@

diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index d240d07..e25f9c9 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -161,6 +161,11 @@ function doneEditing() { searchParams.set("title", newname); var payload = {'id':entity_id,'title':newname} } + else if (entity_type == 'album') { + var endpoint = "/apis/mlj_1/edit_album"; + searchParams.set("albumtitle", newname); + var payload = {'id':entity_id,'albumtitle':newname} + } callback_func = function(req){ if (req.status == 200) { From d860e19b544cd3852e4decce3dd37fe2134dc63c Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 15:31:04 +0200 Subject: [PATCH 021/176] Added albums to search --- maloja/apis/native_v1.py | 14 +++++++++++++- maloja/database/__init__.py | 3 +++ maloja/database/sqldb.py | 9 +++++++++ maloja/web/jinja/abstracts/base.jinja | 4 ++++ maloja/web/static/js/search.js | 25 +++++++++++++++++++++++-- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 585fa11..d969115 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -592,6 +592,7 @@ def search(**keys): artists = database.db_search(query,type="ARTIST") tracks = database.db_search(query,type="TRACK") + albums = database.db_search(query,type="ALBUM") @@ -599,6 +600,7 @@ def search(**keys): # also, shorter is better (because longer titles would be easier to further specify) artists.sort(key=lambda x: ((0 if x.lower().startswith(query) else 1 if " " + query in x.lower() else 2),len(x))) tracks.sort(key=lambda x: ((0 if x["title"].lower().startswith(query) else 1 if " " + query in x["title"].lower() else 2),len(x["title"]))) + albums.sort(key=lambda x: ((0 if x["albumtitle"].lower().startswith(query) else 1 if " " + query in x["albumtitle"].lower() else 2),len(x["albumtitle"]))) # add links artists_result = [] @@ -619,7 +621,17 @@ def search(**keys): } tracks_result.append(result) - return {"artists":artists_result[:max_],"tracks":tracks_result[:max_]} + albums_result = [] + for al in albums: + result = { + 'album': al, + 'link': "/album?" + compose_querystring(internal_to_uri({"album":al})), + 'image': images.get_album_image(al) + } + if not result['album']['artists']: result['album']['displayArtist'] = malojaconfig["DEFAULT_ALBUM_ARTIST"] + albums_result.append(result) + + return {"artists":artists_result[:max_],"tracks":tracks_result[:max_],"albums":albums_result[:max_]} @api.post("newrule") diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 978b569..37285db 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -580,4 +580,7 @@ def db_search(query,type=None): results = sqldb.search_artist(query) if type=="TRACK": results = sqldb.search_track(query) + if type=="ALBUM": + results = sqldb.search_album(query) + return results diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index b28e89a..26f2f62 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1214,6 +1214,15 @@ def search_track(searchterm,dbconn=None): return [get_track(row.id,dbconn=dbconn) for row in result] +@cached_wrapper +@connection_provider +def search_album(searchterm,dbconn=None): + op = DB['albums'].select().where( + DB['albums'].c.albtitle_normalized.ilike(normalize_name(f"%{searchterm}%")) + ) + result = dbconn.execute(op).all() + + return [get_album(row.id,dbconn=dbconn) for row in result] ##### MAINTENANCE diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index 3ca1619..6d0b542 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -82,6 +82,10 @@ Tracks
+

+ Albums + +
diff --git a/maloja/web/static/js/search.js b/maloja/web/static/js/search.js index b5244dc..1dd60d5 100644 --- a/maloja/web/static/js/search.js +++ b/maloja/web/static/js/search.js @@ -23,11 +23,13 @@ function html_to_fragment(html) { var results_artists; var results_tracks; +var results_albums; var searchresultwrap; window.addEventListener("DOMContentLoaded",function(){ results_artists = document.getElementById("searchresults_artists"); results_tracks = document.getElementById("searchresults_tracks"); + results_albums = document.getElementById("searchresults_albums"); searchresultwrap = document.getElementById("resultwrap"); }); @@ -50,8 +52,9 @@ function searchresult() { // any older searches are now rendered irrelevant while (searches[0] != this) { searches.splice(0,1) } var result = JSON.parse(this.responseText); - var artists = result["artists"].slice(0,5) - var tracks = result["tracks"].slice(0,5) + var artists = result["artists"].slice(0,4) + var tracks = result["tracks"].slice(0,4) + var albums = result["albums"].slice(0,4) while (results_artists.firstChild) { results_artists.removeChild(results_artists.firstChild); @@ -59,6 +62,9 @@ function searchresult() { while (results_tracks.firstChild) { results_tracks.removeChild(results_tracks.firstChild); } + while (results_albums.firstChild) { + results_albums.removeChild(results_albums.firstChild); + } for (var i=0;i Date: Wed, 29 Mar 2023 17:06:07 +0200 Subject: [PATCH 022/176] Adjusted DB cleanup to account for albums --- maloja/database/sqldb.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 26f2f62..f8d89aa 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1236,11 +1236,16 @@ def clean_db(dbconn=None): # tracks with no scrobbles (trackartist entries first) "from trackartists where track_id in (select id from tracks where id not in (select track_id from scrobbles))", "from tracks where id not in (select track_id from scrobbles)", - # artists with no tracks - "from artists where id not in (select artist_id from trackartists) and id not in (select target_artist from associated_artists)", + # artists with no tracks AND no albums + "from artists where id not in (select artist_id from trackartists) \ + and id not in (select target_artist from associated_artists) \ + and id not in (select artist_id from albumartists)", # tracks with no artists (scrobbles first) "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", - "from tracks where id not in (select track_id from trackartists)" + "from tracks where id not in (select track_id from trackartists)", + # albums with no tracks (albumartist entries first) + "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks))", + "from albums where id not in (select album_id from tracks)" ] for d in to_delete: From 5eec25963bd58b550af55dfe52f5fabf4a7356b3 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 17:26:36 +0200 Subject: [PATCH 023/176] Fixed things for albumartists with no tracks --- maloja/database/__init__.py | 42 +++++++++++++++++++---------------- maloja/web/jinja/artist.jinja | 2 +- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 37285db..80bbf84 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -400,13 +400,21 @@ def artist_info(dbconn=None,**keys): alltimecharts = get_charts_artists(timerange=alltime(),dbconn=dbconn) scrobbles = get_scrobbles_num(artist=artist,timerange=alltime(),dbconn=dbconn) #we cant take the scrobble number from the charts because that includes all countas scrobbles - try: - c = [e for e in alltimecharts if e["artist"] == artist][0] + + # base info for everyone + result = { + "artist":artist, + "scrobbles":scrobbles, + "id":artist_id + } + + # check if credited to someone else + parent_artists = sqldb.get_credited_artists(artist) + if len(parent_artists) == 0: + c = [e for e in alltimecharts if e["artist"] == artist] + position = c[0]["rank"] if len(c) > 0 else None others = sqldb.get_associated_artists(artist,dbconn=dbconn) - position = c["rank"] - return { - "artist":artist, - "scrobbles":scrobbles, + result.update({ "position":position, "associated":others, "medals":{ @@ -414,23 +422,19 @@ def artist_info(dbconn=None,**keys): "silver": [year for year in cached.medals_artists if artist_id in cached.medals_artists[year]['silver']], "bronze": [year for year in cached.medals_artists if artist_id in cached.medals_artists[year]['bronze']], }, - "topweeks":len([e for e in cached.weekly_topartists if e == artist_id]), - "id":artist_id - } - except Exception: - # if the artist isnt in the charts, they are not being credited and we - # need to show information about the credited one - replaceartist = sqldb.get_credited_artists(artist)[0] + "topweeks":len([e for e in cached.weekly_topartists if e == artist_id]) + }) + + else: + replaceartist = parent_artists[0] c = [e for e in alltimecharts if e["artist"] == replaceartist][0] position = c["rank"] - return { - "artist":artist, + result.update({ "replace":replaceartist, - "scrobbles":scrobbles, - "position":position, - "id":artist_id - } + "position":position + }) + return result diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 6f3b635..d551315 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -63,7 +63,7 @@

{{ info.artist | e }}

- {% if competes %}#{{ info.position }}{% endif %} + {% if competes and info['scrobbles']>0 %}#{{ info.position }}{% endif %}
{% if competes and included %} associated: {{ links.links(included) }} From deb96c9ce7211117022f70cfd3fa6757535ff7c7 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 17:56:07 +0200 Subject: [PATCH 024/176] Adjusted artist page for album artists --- maloja/database/__init__.py | 8 ++++++-- maloja/database/sqldb.py | 13 +++++++++++++ maloja/web/jinja/artist.jinja | 6 +++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 80bbf84..1d8dccd 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -398,14 +398,18 @@ def artist_info(dbconn=None,**keys): artist_id = sqldb.get_artist_id(artist,dbconn=dbconn) artist = sqldb.get_artist(artist_id,dbconn=dbconn) alltimecharts = get_charts_artists(timerange=alltime(),dbconn=dbconn) - scrobbles = get_scrobbles_num(artist=artist,timerange=alltime(),dbconn=dbconn) #we cant take the scrobble number from the charts because that includes all countas scrobbles + scrobbles = get_scrobbles_num(artist=artist,timerange=alltime(),dbconn=dbconn) + albums = sqldb.get_albums_of_artists(set([artist_id]),dbconn=dbconn) + isalbumartist = len(albums.get(artist_id,[]))>0 + # base info for everyone result = { "artist":artist, "scrobbles":scrobbles, - "id":artist_id + "id":artist_id, + "isalbumartist":isalbumartist } # check if credited to someone else diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index f8d89aa..3bc35b5 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1055,6 +1055,19 @@ def get_artists_of_albums(album_ids,dbconn=None): artists.setdefault(row.album_id,[]).append(artist_db_to_dict(row,dbconn=dbconn)) return artists +@cached_wrapper_individual +@connection_provider +def get_albums_of_artists(artist_ids,dbconn=None): + op = sql.join(DB['albumartists'],DB['albums']).select().where( + DB['albumartists'].c.artist_id.in_(artist_ids) + ) + result = dbconn.execute(op).all() + + albums = {} + for row in result: + albums.setdefault(row.artist_id,[]).append(album_db_to_dict(row,dbconn=dbconn)) + return albums + @cached_wrapper_individual @connection_provider diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index d551315..ba4b99f 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -90,19 +90,22 @@ +{% if info["isalbumartist"] %}

Top Albums

{% with amountkeys={"perpage":15,"page":0} %} {% include 'partials/charts_albums.jinja' %} {% endwith %} +{% endif %} - +{% if info['scrobbles']>0 %}

Top Tracks

{% with amountkeys={"perpage":15,"page":0} %} {% include 'partials/charts_tracks.jinja' %} {% endwith %} +
@@ -180,5 +183,6 @@ {% with amountkeys = {"perpage":15,"page":0} %} {% include 'partials/scrobbles.jinja' %} {% endwith %} +{% endif %} {% endblock %} From f0bfe8dfa75bb307117d9f502bbbcece61774ad9 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 18:20:23 +0200 Subject: [PATCH 025/176] Added merging for albums --- maloja/apis/native_v1.py | 10 ++++++++++ maloja/database/__init__.py | 11 +++++++++++ maloja/database/sqldb.py | 23 +++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index d969115..e7d2ff4 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -757,6 +757,16 @@ def merge_artists(target_id,source_ids): "status":"success" } +@api.post("merge_albums") +@authenticated_function(api=True) +@catch_exceptions +def merge_artists(target_id,source_ids): + """Internal Use Only""" + result = database.merge_albums(target_id,source_ids) + return { + "status":"success" + } + @api.post("reparse_scrobble") @authenticated_function(api=True) @catch_exceptions diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 1d8dccd..628c576 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -221,6 +221,17 @@ def merge_tracks(target_id,source_ids): return result +@waitfordb +def merge_albums(target_id,source_ids): + sources = [sqldb.get_album(id) for id in source_ids] + target = sqldb.get_album(target_id) + log(f"Merging {sources} into {target}") + result = sqldb.merge_albums(target_id,source_ids) + dbcache.invalidate_entity_cache() + dbcache.invalidate_caches() + + return result + diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 3bc35b5..df897e1 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -647,6 +647,19 @@ def merge_artists(target_id,source_ids,dbconn=None): return True +@connection_provider +def merge_albums(target_id,source_ids,dbconn=None): + + op = DB['tracks'].update().where( + DB['tracks'].c.album_id.in_(source_ids) + ).values( + album_id=target_id + ) + result = dbconn.execute(op) + clean_db(dbconn=dbconn) + + return True + ### Functions that get rows according to parameters @@ -1257,8 +1270,14 @@ def clean_db(dbconn=None): "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", "from tracks where id not in (select track_id from trackartists)", # albums with no tracks (albumartist entries first) - "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks))", - "from albums where id not in (select album_id from tracks)" + "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks where album_id is not null))", + "from albums where id not in (select album_id from tracks where album_id is not null)", + # albumartist entries that are missing a reference + "from albumartists where album_id not in (select album_id from tracks where album_id is not null)", + "from albumartists where artist_id not in (select id from artists)", + # trackartist entries that mare missing a reference + "from trackartists where track_id not in (select id from tracks)", + "from trackartists where artist_id not in (select id from artists)" ] for d in to_delete: From 1e70d529fbd60917fb942178dac15765e47b447d Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 20:29:44 +0200 Subject: [PATCH 026/176] Fixed potential bug for some sql functions --- maloja/database/sqldb.py | 52 +++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index df897e1..cd9e31d 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1045,7 +1045,18 @@ def count_scrobbles_by_track_of_album(since,to,album,dbconn=None): @cached_wrapper_individual @connection_provider def get_artists_of_tracks(track_ids,dbconn=None): - op = sql.join(DB['trackartists'],DB['artists']).select().where( + + jointable = sql.join( + DB['trackartists'], + DB['artists'] + ) + + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB['artists'], + DB['trackartists'].c.track_id + ).select_from(jointable).where( DB['trackartists'].c.track_id.in_(track_ids) ) result = dbconn.execute(op).all() @@ -1058,7 +1069,18 @@ def get_artists_of_tracks(track_ids,dbconn=None): @cached_wrapper_individual @connection_provider def get_artists_of_albums(album_ids,dbconn=None): - op = sql.join(DB['albumartists'],DB['artists']).select().where( + + jointable = sql.join( + DB['albumartists'], + DB['artists'] + ) + + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB['artists'], + DB['albumartists'].c.album_id + ).select_from(jointable).where( DB['albumartists'].c.album_id.in_(album_ids) ) result = dbconn.execute(op).all() @@ -1071,7 +1093,18 @@ def get_artists_of_albums(album_ids,dbconn=None): @cached_wrapper_individual @connection_provider def get_albums_of_artists(artist_ids,dbconn=None): - op = sql.join(DB['albumartists'],DB['albums']).select().where( + + jointable = sql.join( + DB['albumartists'], + DB['albums'] + ) + + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB["albums"], + DB['albumartists'].c.artist_id + ).select_from(jointable).where( DB['albumartists'].c.artist_id.in_(artist_ids) ) result = dbconn.execute(op).all() @@ -1145,7 +1178,11 @@ def get_associated_artists(*artists,dbconn=None): DB['associated_artists'].c.source_artist == DB['artists'].c.id ) - op = jointable.select().where( + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB['artists'] + ).select_from(jointable).where( DB['associated_artists'].c.target_artist.in_(artist_ids) ) result = dbconn.execute(op).all() @@ -1164,8 +1201,11 @@ def get_credited_artists(*artists,dbconn=None): DB['associated_artists'].c.target_artist == DB['artists'].c.id ) - - op = jointable.select().where( + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB['artists'] + ).select_from(jointable).where( DB['associated_artists'].c.source_artist.in_(artist_ids) ) result = dbconn.execute(op).all() From db2b4760a0d0be778ced7b449d47fbd496524e6a Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 29 Mar 2023 23:35:12 +0200 Subject: [PATCH 027/176] Added album showcase module for artist page --- maloja/database/__init__.py | 15 +++++++ maloja/database/sqldb.py | 43 ++++++++++++++++--- maloja/pkg_global/conf.py | 1 + maloja/web/jinja/artist.jinja | 15 +++++-- .../web/jinja/partials/album_showcase.jinja | 43 +++++++++++++++++++ maloja/web/static/css/maloja.css | 33 ++++++++++++++ 6 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 maloja/web/jinja/partials/album_showcase.jinja diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 628c576..4711cf8 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -278,6 +278,21 @@ def get_artists(dbconn=None): return sqldb.get_artists(dbconn=dbconn) +def get_albums_artist_appears_on(dbconn=None,**keys): + + artist_id = sqldb.get_artist_id(keys['artist'],dbconn=dbconn) + + albums = sqldb.get_albums_artists_appear_on([artist_id],dbconn=dbconn).get(artist_id) or [] + ownalbums = sqldb.get_albums_of_artists([artist_id],dbconn=dbconn).get(artist_id) or [] + + result = { + "own_albums":ownalbums, + "appears_on":[a for a in albums if a not in ownalbums] + } + + return result + + @waitfordb def get_charts_artists(dbconn=None,**keys): (since,to) = keys.get('timerange').timestamps() diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index cd9e31d..aeb9e45 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -368,9 +368,7 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): - op = DB['tracks'].select( -# DB['tracks'].c.id - ).where( + op = DB['tracks'].select().where( DB['tracks'].c.title_normalized==ntitle ) result = dbconn.execute(op).all() @@ -418,9 +416,7 @@ def get_artist_id(artistname,create_new=True,dbconn=None): nname = normalize_name(artistname) #print("looking for",nname) - op = DB['artists'].select( -# DB['artists'].c.id - ).where( + op = DB['artists'].select().where( DB['artists'].c.name_normalized==nname ) result = dbconn.execute(op).all() @@ -1114,6 +1110,41 @@ def get_albums_of_artists(artist_ids,dbconn=None): albums.setdefault(row.artist_id,[]).append(album_db_to_dict(row,dbconn=dbconn)) return albums +@cached_wrapper_individual +@connection_provider +# this includes the artists' own albums! +def get_albums_artists_appear_on(artist_ids,dbconn=None): + + jointable1 = sql.join( + DB["trackartists"], + DB["tracks"] + ) + jointable2 = sql.join( + jointable1, + DB["albums"] + ) + + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB["albums"], + DB["trackartists"].c.artist_id + ).select_from(jointable2).where( + DB['trackartists'].c.artist_id.in_(artist_ids) + ) + result = dbconn.execute(op).all() + + albums = {} + # avoid duplicates from multiple tracks in album by same artist + already_done = {} + for row in result: + if row.id in already_done.setdefault(row.artist_id,[]): + pass + else: + albums.setdefault(row.artist_id,[]).append(album_db_to_dict(row,dbconn=dbconn)) + already_done[row.artist_id].append(row.id) + return albums + @cached_wrapper_individual @connection_provider diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 7224970..f519dc5 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -191,6 +191,7 @@ malojaconfig = Configuration( "default_range_charts_tracks":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range Track Charts", "year"), "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), + "album_showcase":(tp.Boolean(), "Display Album Showcase", True, "Display a graphical album showcase for artist overview pages instead of a chart list"), "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!"), diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index ba4b99f..f9b5c42 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -91,11 +91,18 @@ {% if info["isalbumartist"] %} -

Top Albums

-{% with amountkeys={"perpage":15,"page":0} %} -{% include 'partials/charts_albums.jinja' %} -{% endwith %} +{% if settings['ALBUM_SHOWCASE'] %} +

Albums

+ {% include 'partials/album_showcase.jinja' %} +{% else %} +

Top Albums

+ + {% with amountkeys={"perpage":15,"page":0} %} + {% include 'partials/charts_albums.jinja' %} + {% endwith %} +{% endif %} + {% endif %} {% if info['scrobbles']>0 %} diff --git a/maloja/web/jinja/partials/album_showcase.jinja b/maloja/web/jinja/partials/album_showcase.jinja new file mode 100644 index 0000000..798ad65 --- /dev/null +++ b/maloja/web/jinja/partials/album_showcase.jinja @@ -0,0 +1,43 @@ +{% import 'snippets/links.jinja' as links %} + +{% set info = dbc.get_albums_artist_appears_on(filterkeys,limitkeys) %} +{% set ownalbums = info.own_albums %} +{% set otheralbums = info.appears_on %} + +
+
+ +{% for album in ownalbums %}{% endfor %} +{% if ownalbums and otheralbums%}{% endif %} +{% for album in otheralbums %}{% endfor %} + + + +{% for album in ownalbums %} + +{% endfor %} +{% if ownalbums and otheralbums%}{% endif %} +{% for album in otheralbums %} + +{% endfor %} + + + +{% for album in ownalbums %} + +{% endfor %} +{% if ownalbums and otheralbums%}{% endif %} +{% for album in otheralbums %} + +{% endfor %} + +
Appears on
+ +
+
+
+ +
+
+
{{ album.albumtitle }}{{ album.albumtitle }}
+ diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index a6c3827..d1b7524 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -898,6 +898,39 @@ table.tiles_sub a span { } +div#showcase_scroll_container { + overflow-x: scroll; + overflow-y: show; + margin-top: -15px; +} + +table.album_showcase { + text-align: center; + +} +table.album_showcase tr:nth-child(1) { + opacity: 0.3; +} +table.album_showcase td { + padding: 20px; + padding-bottom:3px; + padding-top:0px; + width: 150px; +} +table.album_showcase td.album_separator_column { + width: 40px; +} + +table.album_showcase td div { + height: 150px; + width: 150px; + + background-size: cover; + background-position: top; + + box-shadow: 0px 0px 10px 10px rgba(3,3,3,0.5); +} + .summary_rank { background-size:cover; From 12b5eb0b74f67c5daa28b47e93b62d5e34fc9713 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 01:26:37 +0200 Subject: [PATCH 028/176] Made the album showcase prettier --- .../web/jinja/partials/album_showcase.jinja | 37 +++++++++++++- maloja/web/static/css/maloja.css | 51 ++++++++++++------- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/maloja/web/jinja/partials/album_showcase.jinja b/maloja/web/jinja/partials/album_showcase.jinja index 798ad65..ab53070 100644 --- a/maloja/web/jinja/partials/album_showcase.jinja +++ b/maloja/web/jinja/partials/album_showcase.jinja @@ -4,7 +4,41 @@ {% set ownalbums = info.own_albums %} {% set otheralbums = info.appears_on %} -
+
+ +{% for album in ownalbums %} + + + + + +
 
+ +
+
+
+ {{ links.links(album.artists) }}
+ {{ links.link(album) }} +
+{% endfor %} + +{% for album in otheralbums %} + + + + + +
Appears on
+ +
+
+
+ {{ links.links(album.artists) }}
+ {{ links.link(album) }} +
+{% endfor %} + +
diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index d1b7524..f8a3d8b 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -898,37 +898,54 @@ table.tiles_sub a span { } -div#showcase_scroll_container { - overflow-x: scroll; - overflow-y: show; +div#showcase_container { + display: flex; margin-top: -15px; + padding-bottom: 20px; + align-items: flex-start; + flex-wrap: wrap; } -table.album_showcase { - text-align: center; - +div#showcase_container table.album { + width: 180px; } -table.album_showcase tr:nth-child(1) { + +div#showcase_container table.album tr td { + padding-left: 15px; + padding-right: 15px; +} +div#showcase_container table.album tr:nth-child(1) td { + height:8px; opacity: 0.3; + text-align: center; } -table.album_showcase td { - padding: 20px; - padding-bottom:3px; - padding-top:0px; - width: 150px; +div#showcase_container table.album tr:nth-child(2) td { + height:150px; + padding-top:2px; + padding-bottom:2px; } -table.album_showcase td.album_separator_column { - width: 40px; +div#showcase_container table.album tr:nth-child(3) td { + height:15px; } - -table.album_showcase td div { +div#showcase_container div { height: 150px; width: 150px; background-size: cover; background-position: top; - box-shadow: 0px 0px 10px 10px rgba(3,3,3,0.5); + box-shadow: 0px 0px 10px 10px rgba(0,0,0,0.5); +} + +div#showcase_container table:hover div { + box-shadow: 0px 0px 10px 10px var(--ctrl-element-color-main); +} + +div#showcase_container span.album_artists { + font-size: 80%; +} +div#showcase_container span.album_title { + font-weight: bold; } From fd5d01b7286573c386dac2c49c0b6ffb61fc9646 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 14:39:44 +0200 Subject: [PATCH 029/176] Spaghetti Code --- maloja/database/__init__.py | 10 ++++ maloja/database/dbcache.py | 6 +- maloja/database/sqldb.py | 61 +++++++++++--------- maloja/proccontrol/tasks/import_scrobbles.py | 5 +- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 4711cf8..7e3f3ac 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -45,6 +45,16 @@ dbstatus = { } +# we're running an auxiliary task that doesn't require all the random background +# nonsense to be fired up +# this is temporary +# FIX YO DAMN ARCHITECTURE ALREADY +AUX_MODE = False +def set_aux_mode(): + global AUX_MODE + AUX_MODE = True + + def waitfordb(func): def newfunc(*args,**kwargs): diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index 9b092a9..582a9e7 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -22,8 +22,10 @@ if malojaconfig['USE_GLOBAL_CACHE']: @runhourly def maintenance(): - print_stats() - trim_cache() + from . import AUX_MODE + if not AUX_MODE: + print_stats() + trim_cache() def print_stats(): for name,c in (('Cache',cache),('Entity Cache',entitycache)): diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index aeb9e45..f397713 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1327,37 +1327,42 @@ def search_album(searchterm,dbconn=None): @connection_provider def clean_db(dbconn=None): - log(f"Database Cleanup...") + from . import AUX_MODE - to_delete = [ - # tracks with no scrobbles (trackartist entries first) - "from trackartists where track_id in (select id from tracks where id not in (select track_id from scrobbles))", - "from tracks where id not in (select track_id from scrobbles)", - # artists with no tracks AND no albums - "from artists where id not in (select artist_id from trackartists) \ - and id not in (select target_artist from associated_artists) \ - and id not in (select artist_id from albumartists)", - # tracks with no artists (scrobbles first) - "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", - "from tracks where id not in (select track_id from trackartists)", - # albums with no tracks (albumartist entries first) - "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks where album_id is not null))", - "from albums where id not in (select album_id from tracks where album_id is not null)", - # albumartist entries that are missing a reference - "from albumartists where album_id not in (select album_id from tracks where album_id is not null)", - "from albumartists where artist_id not in (select id from artists)", - # trackartist entries that mare missing a reference - "from trackartists where track_id not in (select id from tracks)", - "from trackartists where artist_id not in (select id from artists)" - ] + if not AUX_MODE: + with SCROBBLE_LOCK: + log(f"Database Cleanup...") - for d in to_delete: - selection = dbconn.execute(sql.text(f"select * {d}")) - for row in selection.all(): - log(f"Deleting {row}") - deletion = dbconn.execute(sql.text(f"delete {d}")) + to_delete = [ + # tracks with no scrobbles (trackartist entries first) + "from trackartists where track_id in (select id from tracks where id not in (select track_id from scrobbles))", + "from tracks where id not in (select track_id from scrobbles)", + # artists with no tracks AND no albums + "from artists where id not in (select artist_id from trackartists) \ + and id not in (select target_artist from associated_artists) \ + and id not in (select artist_id from albumartists)", + # tracks with no artists (scrobbles first) + "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", + "from tracks where id not in (select track_id from trackartists)", + # albums with no tracks (albumartist entries first) + "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks where album_id is not null))", + "from albums where id not in (select album_id from tracks where album_id is not null)", + # albumartist entries that are missing a reference + "from albumartists where album_id not in (select album_id from tracks where album_id is not null)", + "from albumartists where artist_id not in (select id from artists)", + # trackartist entries that mare missing a reference + "from trackartists where track_id not in (select id from tracks)", + "from trackartists where artist_id not in (select id from artists)" + ] - log("Database Cleanup complete!") + for d in to_delete: + print(d) + selection = dbconn.execute(sql.text(f"select * {d}")) + for row in selection.all(): + log(f"Deleting {row}") + deletion = dbconn.execute(sql.text(f"delete {d}")) + + log("Database Cleanup complete!") diff --git a/maloja/proccontrol/tasks/import_scrobbles.py b/maloja/proccontrol/tasks/import_scrobbles.py index b5bf620..376591d 100644 --- a/maloja/proccontrol/tasks/import_scrobbles.py +++ b/maloja/proccontrol/tasks/import_scrobbles.py @@ -21,6 +21,9 @@ outputs = { def import_scrobbles(inputf): + from ...database import set_aux_mode + set_aux_mode() + from ...database.sqldb import add_scrobbles result = { @@ -180,7 +183,7 @@ def parse_spotify_full(inputf): if len(inputfiles) == 0: print("No files found!") return - + if inputfiles != [inputf]: print("Spotify files should all be imported together to identify duplicates across the whole dataset.") if not ask("Import " + ", ".join(col['yellow'](i) for i in inputfiles) + "?",default=True): From d0d76166fc039c7bf607bf379e02a3fa8703ff17 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 16:08:03 +0200 Subject: [PATCH 030/176] Added functionality to parse old album information --- maloja/__main__.py | 1 + maloja/database/__init__.py | 3 +- maloja/database/sqldb.py | 95 +++++++++++++++++++++++- maloja/proccontrol/tasks/__init__.py | 1 + maloja/proccontrol/tasks/parse_albums.py | 20 +++++ 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 maloja/proccontrol/tasks/parse_albums.py diff --git a/maloja/__main__.py b/maloja/__main__.py index 5e6d4de..4694a40 100644 --- a/maloja/__main__.py +++ b/maloja/__main__.py @@ -166,6 +166,7 @@ def main(*args,**kwargs): "generate":generate.generate_scrobbles, # maloja generate 400 "export":tasks.export, # maloja export "apidebug":apidebug.run, # maloja apidebug + "parsealbums":tasks.parse_albums, # maloja parsealbums # aux "info":print_info } diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 7e3f3ac..9d437a1 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -163,7 +163,8 @@ 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_title','album_artists'] + ['scrobble_time','track_artists','track_title','track_length','scrobble_duration']#,'album_title','album_artists'] + # we still save album info in extra because the user might select majority album authority }, "rawscrobble":rawscrobble } diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index f397713..9782912 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -354,6 +354,11 @@ def add_track_to_album(track_id,album_id,replace=False,dbconn=None): result = dbconn.execute(op) return True +@connection_provider +def add_tracks_to_albums(track_to_album_id_dict,replace=False,dbconn=None): + + for track_id in track_to_album_id_dict: + add_track_to_album(track_id,track_to_album_id_dict[track_id],dbconn=dbconn) ### these will 'get' the ID of an entity, creating it if necessary @@ -1356,7 +1361,6 @@ def clean_db(dbconn=None): ] for d in to_delete: - print(d) selection = dbconn.execute(sql.text(f"select * {d}")) for row in selection.all(): log(f"Deleting {row}") @@ -1427,9 +1431,98 @@ def merge_duplicate_tracks(artist_id,dbconn=None): +@connection_provider +def guess_albums(track_ids=None,replace=False,dbconn=None): + + MIN_NUM_TO_ASSIGN = 1 + + jointable = sql.join( + DB['scrobbles'], + DB['tracks'] + ) + + # get all scrobbles of the respective tracks that have some info + conditions = [ + DB['scrobbles'].c.extra.isnot(None) + ] + if track_ids is not None: + # only do these tracks + conditions.append( + DB['scrobbles'].c.track_id.in_(track_ids) + ) + if not replace: + # only tracks that have no album yet + conditions.append( + DB['tracks'].c.album_id.is_(None) + ) + + op = sql.select( + DB['scrobbles'] + ).select_from(jointable).where( + *conditions + ) + + result = dbconn.execute(op).all() + + # for each track, count what album info appears how often + possible_albums = {} + for row in result: + extrainfo = json.loads(row.extra) + albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") + albumartists = extrainfo.get("album_artists",[]) + if albumtitle: + hashable_albuminfo = tuple([*albumartists,albumtitle]) + possible_albums.setdefault(row.track_id,{}).setdefault(hashable_albuminfo,0) + possible_albums[row.track_id][hashable_albuminfo] += 1 + + res = {} + for track_id in possible_albums: + options = possible_albums[track_id] + if len(options)>0: + # pick the one with most occurences + mostnum = max(options[albuminfo] for albuminfo in options) + if mostnum >= MIN_NUM_TO_ASSIGN: + bestpick = [albuminfo for albuminfo in options if options[albuminfo] == mostnum][0] + #print("best pick",track_id,bestpick) + *artists,title = bestpick + res[track_id] = {"assigned":{ + "artists":artists, + "albumtitle": title + }} + if len(artists) == 0: + # for albums without artist, assume track artist + res[track_id]["guess_artists"] = True + else: + res[track_id] = {"assigned":False,"reason":"Not enough data"} + + else: + res[track_id] = {"assigned":False,"reason":"No scrobbles with album information found"} + missing_artists = [track_id for track_id in res if res[track_id].get("guess_artists")] + + #we're pointlessly getting the albumartist names here even though the IDs would be enough + #but it's better for function separation I guess + jointable = sql.join( + DB['trackartists'], + DB['artists'] + ) + op = sql.select( + DB['trackartists'].c.track_id, + DB['artists'] + ).select_from(jointable).where( + DB['trackartists'].c.track_id.in_(missing_artists) + ) + result = dbconn.execute(op).all() + + for row in result: + res[row.track_id]["assigned"]["artists"].append(row.name) + for track_id in res: + if res[track_id].get("guess_artists"): + del res[track_id]["guess_artists"] + + return res diff --git a/maloja/proccontrol/tasks/__init__.py b/maloja/proccontrol/tasks/__init__.py index cf2cd85..90ce051 100644 --- a/maloja/proccontrol/tasks/__init__.py +++ b/maloja/proccontrol/tasks/__init__.py @@ -1,3 +1,4 @@ from .import_scrobbles import import_scrobbles from .backup import backup from .export import export # read that line out loud +from .parse_albums import parse_albums diff --git a/maloja/proccontrol/tasks/parse_albums.py b/maloja/proccontrol/tasks/parse_albums.py new file mode 100644 index 0000000..618eabf --- /dev/null +++ b/maloja/proccontrol/tasks/parse_albums.py @@ -0,0 +1,20 @@ + + + +def parse_albums(replace=False): + + from ...database.sqldb import guess_albums, get_album_id, add_track_to_album + + print("Parsing album information...") + result = guess_albums(replace=replace) + + result = {track_id:result[track_id] for track_id in result if result[track_id]["assigned"]} + print("Adding",len(result),"tracks to albums...") + i = 0 + for track_id in result: + album_id = get_album_id(result[track_id]["assigned"]) + add_track_to_album(track_id,album_id) + i += 1 + if (i % 100) == 0: + print(i,"of",len(result)) + print("Done!") From 3877401a051c1a673a7afc539671374fc62dc696 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 16:43:03 +0200 Subject: [PATCH 031/176] Added handling for albums when merging artists --- maloja/database/sqldb.py | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 9782912..882884b 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -626,6 +626,28 @@ def merge_artists(target_id,source_ids,dbconn=None): result = dbconn.execute(op) + + # same for albums + op = DB['albumartists'].select().where( + DB['albumartists'].c.artist_id.in_(source_ids + [target_id]) + ) + result = dbconn.execute(op) + + album_ids = set(row.album_id for row in result) + + op = DB['albumartists'].delete().where( + DB['albumartists'].c.artist_id.in_(source_ids + [target_id]), + ) + result = dbconn.execute(op) + + op = DB['albumartists'].insert().values([ + {'album_id':album_id,'artist_id':target_id} + for album_id in album_ids + ]) + + result = dbconn.execute(op) + + # tracks_artists = {} # for row in result: # tracks_artists.setdefault(row.track_id,[]).append(row.artist_id) @@ -641,8 +663,9 @@ def merge_artists(target_id,source_ids,dbconn=None): # ) # result = dbconn.execute(op) - # this could have created duplicate tracks + # this could have created duplicate tracks and albums merge_duplicate_tracks(artist_id=target_id,dbconn=dbconn) + merge_duplicate_albums(artist_id=target_id,dbconn=dbconn) clean_db(dbconn=dbconn) return True @@ -1431,6 +1454,45 @@ def merge_duplicate_tracks(artist_id,dbconn=None): +@connection_provider +def merge_duplicate_albums(artist_id,dbconn=None): + rows = dbconn.execute( + DB['albumartists'].select().where( + DB['albumartists'].c.artist_id == artist_id + ) + ) + affected_albums = [r.album_id for r in rows] + + album_artists = {} + rows = dbconn.execute( + DB['albumartists'].select().where( + DB['albumartists'].c.album_id.in_(affected_albums) + ) + ) + + + for row in rows: + album_artists.setdefault(row.album_id,[]).append(row.artist_id) + + artist_combos = {} + for album_id in album_artists: + artist_combos.setdefault(tuple(sorted(album_artists[album_id])),[]).append(album_id) + + for c in artist_combos: + if len(artist_combos[c]) > 1: + album_identifiers = {} + for album_id in artist_combos[c]: + album_identifiers.setdefault(normalize_name(get_album(album_id)['albumtitle']),[]).append(album_id) + for album in album_identifiers: + if len(album_identifiers[album]) > 1: + target,*src = album_identifiers[album] + merge_albums(target,src,dbconn=dbconn) + + + + + + @connection_provider def guess_albums(track_ids=None,replace=False,dbconn=None): From e52d57e4139a0a37311c46a0b981e3ff944f7064 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 17:05:35 +0200 Subject: [PATCH 032/176] Fixed design of album search results --- maloja/web/jinja/abstracts/base.jinja | 4 ++-- maloja/web/static/css/maloja.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index 6d0b542..8b3e8a6 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -80,11 +80,11 @@

Tracks - +


Albums - +
diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index f8a3d8b..239b44a 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -189,7 +189,7 @@ div.searchresults tr td:nth-child(2) { padding-left:10px; } -div.searchresults table.searchresults_tracks td span:nth-child(1) { +div.searchresults table.searchresults_extrainfo td span:nth-child(1) { font-size:12px; color:grey; From 0bdcb94f5b997ff6eb574e72a8db17e887766e3d Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 17:18:08 +0200 Subject: [PATCH 033/176] Scrobble guessing can now use rawscrobble --- maloja/database/sqldb.py | 5 +++++ maloja/proccontrol/tasks/parse_albums.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 882884b..0497456 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1532,6 +1532,11 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): extrainfo = json.loads(row.extra) albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") albumartists = extrainfo.get("album_artists",[]) + if not albumtitle: + # try the raw scrobble + extrainfo = json.loads(row.rawscrobble) + albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") + albumartists = albumartists or extrainfo.get("album_artists",[]) if albumtitle: hashable_albuminfo = tuple([*albumartists,albumtitle]) possible_albums.setdefault(row.track_id,{}).setdefault(hashable_albuminfo,0) diff --git a/maloja/proccontrol/tasks/parse_albums.py b/maloja/proccontrol/tasks/parse_albums.py index 618eabf..125df0c 100644 --- a/maloja/proccontrol/tasks/parse_albums.py +++ b/maloja/proccontrol/tasks/parse_albums.py @@ -3,6 +3,9 @@ def parse_albums(replace=False): + from ...database import set_aux_mode + set_aux_mode() + from ...database.sqldb import guess_albums, get_album_id, add_track_to_album print("Parsing album information...") From feaccf1259f103b6c77796ef256b5b113d8a5bcd Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 19:57:56 +0200 Subject: [PATCH 034/176] Added album title rule --- maloja/cleanup.py | 8 +++++++- maloja/database/__init__.py | 2 ++ maloja/proccontrol/tasks/import_scrobbles.py | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/maloja/cleanup.py b/maloja/cleanup.py index 9095114..62120b9 100644 --- a/maloja/cleanup.py +++ b/maloja/cleanup.py @@ -26,6 +26,7 @@ class CleanerAgent: self.rules_belongtogether = [r[1] for r in rawrules if r[0]=="belongtogether"] self.rules_notanartist = [r[1] for r in rawrules if r[0]=="notanartist"] self.rules_replacetitle = {r[1].lower():r[2] for r in rawrules if r[0]=="replacetitle"} + self.rules_replacealbumtitle = {r[1].lower():r[2] for r in rawrules if r[0]=="replacealbumtitle"} self.rules_replaceartist = {r[1].lower():r[2] for r in rawrules if r[0]=="replaceartist"} self.rules_ignoreartist = [r[1].lower() for r in rawrules if r[0]=="ignoreartist"] self.rules_addartists = {r[2].lower():(r[1].lower(),r[3]) for r in rawrules if r[0]=="addartists"} @@ -188,9 +189,14 @@ class CleanerAgent: if st in title.lower(): artists += self.rules_artistintitle[st].split("␟") return (title,artists) + def parseAlbumtitle(self,t): + if t.strip().lower() in self.rules_replacealbumtitle: + return self.rules_replacealbumtitle[t.strip().lower()] + t = t.replace("[","(").replace("]",")") - + t = t.strip() + return t def flatten(lis): diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 9d437a1..e64cca4 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -144,6 +144,8 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): 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']) + if scrobbleinfo.get("album_title"): + scrobbleinfo['album_title'] = cla.parseAlbumtitle(scrobbleinfo['album_title']) scrobbleinfo['scrobble_time'] = scrobbleinfo.get('scrobble_time') or int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) diff --git a/maloja/proccontrol/tasks/import_scrobbles.py b/maloja/proccontrol/tasks/import_scrobbles.py index 376591d..b34ccc0 100644 --- a/maloja/proccontrol/tasks/import_scrobbles.py +++ b/maloja/proccontrol/tasks/import_scrobbles.py @@ -309,6 +309,8 @@ def parse_lastfm(inputf): 'scrobble_time': int(datetime.datetime.strptime( time + '+0000', "%d %b %Y %H:%M%z" + # lastfm exports have time in UTC + # some old imports might have the wrong time here! ).timestamp()), 'scrobble_duration':None },'') From 8cb446f1fb2cef70f6e89b574bce9dbf9426a89c Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 20:39:27 +0200 Subject: [PATCH 035/176] Fixed scrobbling with incomplete album information --- maloja/database/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index e64cca4..0869696 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -148,6 +148,10 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): scrobbleinfo['album_title'] = cla.parseAlbumtitle(scrobbleinfo['album_title']) scrobbleinfo['scrobble_time'] = scrobbleinfo.get('scrobble_time') or int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) + # if we send [] as albumartists, it means various + # if we send nothing, the scrobbler just doesnt support it and we assume track artists + if 'album_artists' not in scrobbleinfo: + scrobbleinfo['album_artists'] = scrobbleinfo.get('track_artists') # processed info to internal scrobble dict scrobbledict = { From eb7268985ce88440e95ba3380180d29c0cc663dc Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 20:54:24 +0200 Subject: [PATCH 036/176] Added dummy file for album image folder creation --- maloja/data_files/state/images/albums/dummy | 0 maloja/database/__init__.py | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 maloja/data_files/state/images/albums/dummy diff --git a/maloja/data_files/state/images/albums/dummy b/maloja/data_files/state/images/albums/dummy new file mode 100644 index 0000000..e69de29 diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 0869696..29fa956 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -153,6 +153,11 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): if 'album_artists' not in scrobbleinfo: scrobbleinfo['album_artists'] = scrobbleinfo.get('track_artists') + # New plan, do this further down + # NONE always means there is simply no info, so make a guess or whatever the options say + # various artists always needs to be specified via [] + # TODO + # processed info to internal scrobble dict scrobbledict = { "time":scrobbleinfo.get('scrobble_time'), From 015b779ca91ceeb898280608e49586d2092947ea Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 30 Mar 2023 21:22:29 +0200 Subject: [PATCH 037/176] Fixed caching issue when changing album info of track --- maloja/database/__init__.py | 1 + maloja/server.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 29fa956..48fb708 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -110,6 +110,7 @@ def incoming_scrobble(rawscrobble,fix=True,client=None,api=None,dbconn=None): proxy_scrobble_all(scrobbledict['track']['artists'],scrobbledict['track']['title'],scrobbledict['time']) dbcache.invalidate_caches(scrobbledict['time']) + dbcache.invalidate_entity_cache() # because album info might have changed #return {"status":"success","scrobble":scrobbledict} return scrobbledict diff --git a/maloja/server.py b/maloja/server.py index 7e3815e..6f76524 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -220,8 +220,8 @@ def jinja_page(name): res = template.render(**loc_context) except TemplateNotFound: abort(404,f"Not found: '{name}'") - except (ValueError, IndexError): - abort(404,"This Artist or Track does not exist") + #except (ValueError, IndexError): + # abort(404,"This Artist or Track does not exist") if malojaconfig["DEV_MODE"]: jinja_environment.cache.clear() From f9ce0e6ba9d4b709a5012d8524219d8cdaf6dbd1 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 02:49:26 +0200 Subject: [PATCH 038/176] Fixed broken link for album top weeks --- maloja/web/jinja/partials/awards_album.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/web/jinja/partials/awards_album.jinja b/maloja/web/jinja/partials/awards_album.jinja index 5ded047..b9337a2 100644 --- a/maloja/web/jinja/partials/awards_album.jinja +++ b/maloja/web/jinja/partials/awards_album.jinja @@ -28,7 +28,7 @@ {% macro topweeks(info) %} -{% set encodedtrack = mlj_uri.uriencode({'album':info.album}) %} +{% set encodedalbum = mlj_uri.uriencode({'album':info.album}) %} From 451014f6e7674ff2f377cd13f91413d5ac45e8c3 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 04:22:33 +0200 Subject: [PATCH 039/176] Temporary fix for scrobbling with no album info --- maloja/database/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 48fb708..f40a85e 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -151,7 +151,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): # if we send [] as albumartists, it means various # if we send nothing, the scrobbler just doesnt support it and we assume track artists - if 'album_artists' not in scrobbleinfo: + if ('album_title' in scrobbleinfo) and ('album_artists' not in scrobbleinfo): scrobbleinfo['album_artists'] = scrobbleinfo.get('track_artists') # New plan, do this further down @@ -181,6 +181,9 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "rawscrobble":rawscrobble } + if scrobbledict["track"]["album"]["albumtitle"] is None and scrobbledict["track"]["album"]["artists"] is None: + del scrobbledict["track"]["album"] + return scrobbledict From d7d2f676a75c884e3a85b9016aba56ab8b1da7fd Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 05:18:43 +0200 Subject: [PATCH 040/176] Removed some superfluous id resolving of entities --- maloja/database/__init__.py | 45 ++++++++++++++++++++----------------- maloja/database/sqldb.py | 25 ++++++++++----------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index f40a85e..eae6634 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -181,7 +181,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "rawscrobble":rawscrobble } - if scrobbledict["track"]["album"]["albumtitle"] is None and scrobbledict["track"]["album"]["artists"] is None: + if scrobbledict["track"]["album"]["albumtitle"] is None: del scrobbledict["track"]["album"] return scrobbledict @@ -320,29 +320,29 @@ def get_albums_artist_appears_on(dbconn=None,**keys): @waitfordb -def get_charts_artists(dbconn=None,**keys): +def get_charts_artists(dbconn=None,resolve_ids=True,**keys): (since,to) = keys.get('timerange').timestamps() - result = sqldb.count_scrobbles_by_artist(since=since,to=to,dbconn=dbconn) + result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn) return result @waitfordb -def get_charts_tracks(dbconn=None,**keys): +def get_charts_tracks(dbconn=None,resolve_ids=True,**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) + result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],resolve_ids=resolve_ids,dbconn=dbconn) elif 'album' in keys: - result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],dbconn=dbconn) + result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],resolve_ids=resolve_ids,dbconn=dbconn) else: - result = sqldb.count_scrobbles_by_track(since=since,to=to,dbconn=dbconn) + result = sqldb.count_scrobbles_by_track(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn) return result @waitfordb -def get_charts_albums(dbconn=None,**keys): +def get_charts_albums(dbconn=None,resolve_ids=True,**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) + result = sqldb.count_scrobbles_by_album_of_artist(since=since,to=to,artist=keys['artist'],resolve_ids=resolve_ids,dbconn=dbconn) else: - result = sqldb.count_scrobbles_by_album(since=since,to=to,dbconn=dbconn) + result = sqldb.count_scrobbles_by_album(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn) return result @waitfordb @@ -364,29 +364,32 @@ def get_performance(dbconn=None,**keys): for rng in rngs: if "track" in keys: - track = sqldb.get_track(sqldb.get_track_id(keys['track'],dbconn=dbconn),dbconn=dbconn) - charts = get_charts_tracks(timerange=rng,dbconn=dbconn) + track_id = sqldb.get_track_id(keys['track'],dbconn=dbconn) + #track = sqldb.get_track(track_id,dbconn=dbconn) + charts = get_charts_tracks(timerange=rng,resolve_ids=False,dbconn=dbconn) rank = None for c in charts: - if c["track"] == track: + if c["track_id"] == track_id: rank = c["rank"] break elif "artist" in keys: - artist = sqldb.get_artist(sqldb.get_artist_id(keys['artist'],dbconn=dbconn),dbconn=dbconn) + artist_id = sqldb.get_artist_id(keys['artist'],dbconn=dbconn) + #artist = sqldb.get_artist(artist_id,dbconn=dbconn) # ^this is the most useless line in programming history # but I like consistency - charts = get_charts_artists(timerange=rng,dbconn=dbconn) + charts = get_charts_artists(timerange=rng,resolve_ids=False,dbconn=dbconn) rank = None for c in charts: - if c["artist"] == artist: + if c["artist_id"] == artist_id: 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) + album_id = sqldb.get_album_id(keys['album'],dbconn=dbconn) + #album = sqldb.get_album(album_id,dbconn=dbconn) + charts = get_charts_albums(timerange=rng,resolve_ids=False,dbconn=dbconn) rank = None for c in charts: - if c["album"] == album: + if c["album_id"] == album_id: rank = c["rank"] break else: @@ -502,10 +505,10 @@ def track_info(dbconn=None,**keys): track_id = sqldb.get_track_id(track,dbconn=dbconn) track = sqldb.get_track(track_id,dbconn=dbconn) - alltimecharts = get_charts_tracks(timerange=alltime(),dbconn=dbconn) + alltimecharts = get_charts_tracks(timerange=alltime(),resolve_ids=False,dbconn=dbconn) #scrobbles = get_scrobbles_num(track=track,timerange=alltime()) - c = [e for e in alltimecharts if e["track"] == track][0] + c = [e for e in alltimecharts if e["track_id"] == track_id][0] scrobbles = c["scrobbles"] position = c["rank"] cert = None diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 0497456..95f5841 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -866,7 +866,6 @@ def count_scrobbles_by_artist(since,to,resolve_ids=True,dbconn=None): result = dbconn.execute(op).all() if resolve_ids: - counts = [row.count for row in result] artists = get_artists_map([row.artist_id for row in result],dbconn=dbconn) result = [{'scrobbles':row.count,'artist':artists[row.artist_id]} for row in result] else: @@ -889,7 +888,6 @@ def count_scrobbles_by_track(since,to,resolve_ids=True,dbconn=None): result = dbconn.execute(op).all() if resolve_ids: - 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] else: @@ -918,7 +916,6 @@ def count_scrobbles_by_album(since,to,resolve_ids=True,dbconn=None): 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: @@ -956,7 +953,6 @@ def count_scrobbles_by_album_of_artist(since,to,artist,resolve_ids=True,dbconn=N 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: @@ -994,7 +990,6 @@ def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=N 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: @@ -1005,7 +1000,7 @@ def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=N @cached_wrapper @connection_provider -def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None): +def count_scrobbles_by_track_of_artist(since,to,artist,resolve_ids=True,dbconn=None): artist_id = get_artist_id(artist,dbconn=dbconn) @@ -1026,16 +1021,18 @@ def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None): 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] + if resolve_ids: + 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] + else: + result = [{'scrobbles':row.count,'track_id':row.track_id} for row in result] result = rank(result,key='scrobbles') return result @cached_wrapper @connection_provider -def count_scrobbles_by_track_of_album(since,to,album,dbconn=None): +def count_scrobbles_by_track_of_album(since,to,album,resolve_ids=True,dbconn=None): album_id = get_album_id(album,dbconn=dbconn) @@ -1056,9 +1053,11 @@ def count_scrobbles_by_track_of_album(since,to,album,dbconn=None): 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] + if resolve_ids: + 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] + else: + result = [{'scrobbles':row.count,'track_id':row.track_id} for row in result] result = rank(result,key='scrobbles') return result From 246608f5e09d948164c114985b17f3c1b41b6984 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 05:33:30 +0200 Subject: [PATCH 041/176] Album updates now properly evict caches --- maloja/database/sqldb.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 95f5841..f511488 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -6,7 +6,7 @@ from datetime import datetime from threading import Lock from ..pkg_global.conf import data_dir -from .dbcache import cached_wrapper, cached_wrapper_individual +from .dbcache import cached_wrapper, cached_wrapper_individual, invalidate_caches, invalidate_entity_cache from . import exceptions as exc from doreah.logging import log @@ -352,6 +352,11 @@ def add_track_to_album(track_id,album_id,replace=False,dbconn=None): ) result = dbconn.execute(op) + + invalidate_entity_cache() # because album info has changed + invalidate_caches() # changing album info of tracks will change album charts + + return True @connection_provider From 19de87cb66656350ed35cfc25256849ae4c1790d Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 14:10:53 +0200 Subject: [PATCH 042/176] Fixed ephemeral entity creation --- maloja/database/__init__.py | 15 ++++++++++----- maloja/database/exceptions.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index eae6634..0995496 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -110,7 +110,7 @@ def incoming_scrobble(rawscrobble,fix=True,client=None,api=None,dbconn=None): proxy_scrobble_all(scrobbledict['track']['artists'],scrobbledict['track']['title'],scrobbledict['time']) dbcache.invalidate_caches(scrobbledict['time']) - dbcache.invalidate_entity_cache() # because album info might have changed + #return {"status":"success","scrobble":scrobbledict} return scrobbledict @@ -450,7 +450,9 @@ def artist_info(dbconn=None,**keys): artist = keys.get('artist') if artist is None: raise exceptions.MissingEntityParameter() - artist_id = sqldb.get_artist_id(artist,dbconn=dbconn) + artist_id = sqldb.get_artist_id(artist,create_new=False,dbconn=dbconn) + if not artist_id: raise exceptions.ArtistDoesNotExist(artist) + artist = sqldb.get_artist(artist_id,dbconn=dbconn) alltimecharts = get_charts_artists(timerange=alltime(),dbconn=dbconn) #we cant take the scrobble number from the charts because that includes all countas scrobbles @@ -503,7 +505,9 @@ def track_info(dbconn=None,**keys): track = keys.get('track') if track is None: raise exceptions.MissingEntityParameter() - track_id = sqldb.get_track_id(track,dbconn=dbconn) + track_id = sqldb.get_track_id(track,create_new=False,dbconn=dbconn) + if not track_id: raise exceptions.TrackDoesNotExist(track['title']) + track = sqldb.get_track(track_id,dbconn=dbconn) alltimecharts = get_charts_tracks(timerange=alltime(),resolve_ids=False,dbconn=dbconn) #scrobbles = get_scrobbles_num(track=track,timerange=alltime()) @@ -539,9 +543,10 @@ 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) + album_id = sqldb.get_album_id(album,create_new=False,dbconn=dbconn) + if not album_id: raise exceptions.AlbumDoesNotExist(album['albumtitle']) + album = sqldb.get_album(album_id,dbconn=dbconn) alltimecharts = get_charts_albums(timerange=alltime(),dbconn=dbconn) #scrobbles = get_scrobbles_num(track=track,timerange=alltime()) diff --git a/maloja/database/exceptions.py b/maloja/database/exceptions.py index a2dadd9..0d4fce4 100644 --- a/maloja/database/exceptions.py +++ b/maloja/database/exceptions.py @@ -27,3 +27,19 @@ class MissingScrobbleParameters(Exception): class MissingEntityParameter(Exception): pass + +class EntityDoesNotExist(HTTPError): + entitytype = 'Entity' + def __init__(self,name): + self.entityname = name + super().__init__( + status=404, + body=f"The {self.entitytype} '{self.entityname}' does not exist in the database." + ) + +class ArtistDoesNotExist(EntityDoesNotExist): + entitytype = 'Artist' +class AlbumDoesNotExist(EntityDoesNotExist): + entitytype = 'Album' +class TrackDoesNotExist(EntityDoesNotExist): + entitytype = 'Track' From 55363bf31b86e7862f9ae9a6d4166bf4ecf8f73d Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 14:42:58 +0200 Subject: [PATCH 043/176] Disabled more maintenance nonsense when running tasks --- maloja/database/dbcache.py | 19 +++++++++-- maloja/database/sqldb.py | 64 ++++++++++++++++++++------------------ 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index 582a9e7..0bd0c9a 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -23,11 +23,13 @@ if malojaconfig['USE_GLOBAL_CACHE']: @runhourly def maintenance(): from . import AUX_MODE - if not AUX_MODE: - print_stats() - trim_cache() + if AUX_MODE: return + print_stats() + trim_cache() def print_stats(): + from . import AUX_MODE + if AUX_MODE: return for name,c in (('Cache',cache),('Entity Cache',entitycache)): hits, misses = c.get_stats() log(f"{name}: Size: {len(c)} | Hits: {hits}/{hits+misses} | Estimated Memory: {human_readable_size(c)}") @@ -35,6 +37,8 @@ if malojaconfig['USE_GLOBAL_CACHE']: def cached_wrapper(inner_func): + from . import AUX_MODE + if AUX_MODE: return inner_func def outer_func(*args,**kwargs): @@ -59,6 +63,8 @@ if malojaconfig['USE_GLOBAL_CACHE']: # we don't want a new cache entry for every single combination, but keep a common # cache that's aware of what we're calling def cached_wrapper_individual(inner_func): + from . import AUX_MODE + if AUX_MODE: return def outer_func(set_arg,**kwargs): if 'dbconn' in kwargs: @@ -83,6 +89,9 @@ if malojaconfig['USE_GLOBAL_CACHE']: return outer_func def invalidate_caches(scrobbletime=None): + from . import AUX_MODE + if AUX_MODE: return + cleared, kept = 0, 0 for k in cache.keys(): # VERY BIG TODO: differentiate between None as in 'unlimited timerange' and None as in 'time doesnt matter here'! @@ -95,10 +104,14 @@ if malojaconfig['USE_GLOBAL_CACHE']: def invalidate_entity_cache(): + from . import AUX_MODE + if AUX_MODE: return entitycache.clear() def trim_cache(): + from . import AUX_MODE + if AUX_MODE: return ramprct = psutil.virtual_memory().percent if ramprct > malojaconfig["DB_MAX_MEMORY"]: log(f"{ramprct}% RAM usage, clearing cache!") diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index f511488..c39ba5a 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1360,40 +1360,40 @@ def search_album(searchterm,dbconn=None): def clean_db(dbconn=None): from . import AUX_MODE + if AUX_MODE: return - if not AUX_MODE: - with SCROBBLE_LOCK: - log(f"Database Cleanup...") + with SCROBBLE_LOCK: + log(f"Database Cleanup...") - to_delete = [ - # tracks with no scrobbles (trackartist entries first) - "from trackartists where track_id in (select id from tracks where id not in (select track_id from scrobbles))", - "from tracks where id not in (select track_id from scrobbles)", - # artists with no tracks AND no albums - "from artists where id not in (select artist_id from trackartists) \ - and id not in (select target_artist from associated_artists) \ - and id not in (select artist_id from albumartists)", - # tracks with no artists (scrobbles first) - "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", - "from tracks where id not in (select track_id from trackartists)", - # albums with no tracks (albumartist entries first) - "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks where album_id is not null))", - "from albums where id not in (select album_id from tracks where album_id is not null)", - # albumartist entries that are missing a reference - "from albumartists where album_id not in (select album_id from tracks where album_id is not null)", - "from albumartists where artist_id not in (select id from artists)", - # trackartist entries that mare missing a reference - "from trackartists where track_id not in (select id from tracks)", - "from trackartists where artist_id not in (select id from artists)" - ] + to_delete = [ + # tracks with no scrobbles (trackartist entries first) + "from trackartists where track_id in (select id from tracks where id not in (select track_id from scrobbles))", + "from tracks where id not in (select track_id from scrobbles)", + # artists with no tracks AND no albums + "from artists where id not in (select artist_id from trackartists) \ + and id not in (select target_artist from associated_artists) \ + and id not in (select artist_id from albumartists)", + # tracks with no artists (scrobbles first) + "from scrobbles where track_id in (select id from tracks where id not in (select track_id from trackartists))", + "from tracks where id not in (select track_id from trackartists)", + # albums with no tracks (albumartist entries first) + "from albumartists where album_id in (select id from albums where id not in (select album_id from tracks where album_id is not null))", + "from albums where id not in (select album_id from tracks where album_id is not null)", + # albumartist entries that are missing a reference + "from albumartists where album_id not in (select album_id from tracks where album_id is not null)", + "from albumartists where artist_id not in (select id from artists)", + # trackartist entries that mare missing a reference + "from trackartists where track_id not in (select id from tracks)", + "from trackartists where artist_id not in (select id from artists)" + ] - for d in to_delete: - selection = dbconn.execute(sql.text(f"select * {d}")) - for row in selection.all(): - log(f"Deleting {row}") - deletion = dbconn.execute(sql.text(f"delete {d}")) + for d in to_delete: + selection = dbconn.execute(sql.text(f"select * {d}")) + for row in selection.all(): + log(f"Deleting {row}") + deletion = dbconn.execute(sql.text(f"delete {d}")) - log("Database Cleanup complete!") + log("Database Cleanup complete!") @@ -1407,6 +1407,10 @@ def clean_db(dbconn=None): @runmonthly def renormalize_names(): + + from . import AUX_MODE + if AUX_MODE: return + with SCROBBLE_LOCK: with engine.begin() as conn: rows = conn.execute(DB['artists'].select()).all() From 924d4718db8f1bf3c91733a2160dd33cf87e59b4 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 14:47:30 +0200 Subject: [PATCH 044/176] Fixed album parsing from raw scrobble, GH-207 --- maloja/database/sqldb.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index c39ba5a..b6fd6e1 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1513,7 +1513,7 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): # get all scrobbles of the respective tracks that have some info conditions = [ - DB['scrobbles'].c.extra.isnot(None) + DB['scrobbles'].c.extra.isnot(None) | DB['scrobbles'].c.rawscrobble.isnot(None) ] if track_ids is not None: # only do these tracks @@ -1537,10 +1537,13 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): # for each track, count what album info appears how often possible_albums = {} for row in result: - extrainfo = json.loads(row.extra) - albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") - albumartists = extrainfo.get("album_artists",[]) + albumtitle, albumartists = None, None + if row.extra: + extrainfo = json.loads(row.extra) + albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") + albumartists = extrainfo.get("album_artists",[]) if not albumtitle: + # either we didn't have info in the exta col, or there was no albumtitle # try the raw scrobble extrainfo = json.loads(row.rawscrobble) albumtitle = extrainfo.get("album_name") or extrainfo.get("album_title") From 2c44745abcc5dffe0ef6e1b28d74ee6ec422e5c4 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 14:50:53 +0200 Subject: [PATCH 045/176] This is getting worse and worse --- maloja/database/__init__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 0995496..184a437 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -1,6 +1,17 @@ # server from bottle import request, response, FormsDict + +# we're running an auxiliary task that doesn't require all the random background +# nonsense to be fired up +# this is temporary +# FIX YO DAMN ARCHITECTURE ALREADY +AUX_MODE = False +def set_aux_mode(): + global AUX_MODE + AUX_MODE = True + + # rest of the project from ..cleanup import CleanerAgent from .. import images @@ -45,14 +56,7 @@ dbstatus = { } -# we're running an auxiliary task that doesn't require all the random background -# nonsense to be fired up -# this is temporary -# FIX YO DAMN ARCHITECTURE ALREADY -AUX_MODE = False -def set_aux_mode(): - global AUX_MODE - AUX_MODE = True + From 0ba55d466dc967418c4555df72b399683fa91e92 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 14:59:54 +0200 Subject: [PATCH 046/176] Fixed more read-only id queries --- maloja/database/sqldb.py | 6 +++++- maloja/images.py | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index b6fd6e1..f64c43e 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -396,17 +396,21 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): #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'): + if trackdict.get('album') and create_new: + # if we don't supply create_new, it means we just want to get info about a track + # which means no need to write album info, even if it was new 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 + print("Creating new track") op = DB['tracks'].insert().values( **track_dict_to_db(trackdict,dbconn=dbconn) ) result = dbconn.execute(op) track_id = result.inserted_primary_key[0] + print(track_id) for artist_id in artist_ids: op = DB['trackartists'].insert().values( diff --git a/maloja/images.py b/maloja/images.py index 4c2fa22..f4425e4 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -111,20 +111,20 @@ def dl_image(url): ### even if we have already cached it, we will handle that on request def get_track_image(track=None,track_id=None): if track_id is None: - track_id = database.sqldb.get_track_id(track) + track_id = database.sqldb.get_track_id(track,create_new=False) return f"/image?type=track&id={track_id}" def get_artist_image(artist=None,artist_id=None): if artist_id is None: - artist_id = database.sqldb.get_artist_id(artist) + artist_id = database.sqldb.get_artist_id(artist,create_new=False) 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) + album_id = database.sqldb.get_album_id(album,create_new=False) return f"/image?type=album&id={album_id}" From 05759314f0d28fab592a3168caa344595bdddd26 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 15:19:17 +0200 Subject: [PATCH 047/176] Turned aux mode check into decorator --- maloja/database/__init__.py | 10 +++++++++- maloja/database/dbcache.py | 22 +++++----------------- maloja/database/sqldb.py | 9 +++------ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 184a437..f37910e 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -11,7 +11,15 @@ def set_aux_mode(): global AUX_MODE AUX_MODE = True - +# decorator that makes sure this function is only run in normal operation, +# not when we run a task that needs to access the database +def no_aux_mode(func): + def wrapper(*args,**kwargs): + if AUX_MODE: return + return func(*args,**kwargs) + return wrapper + + # rest of the project from ..cleanup import CleanerAgent from .. import images diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index 0bd0c9a..da812a5 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -10,7 +10,7 @@ from doreah.regular import runhourly from doreah.logging import log from ..pkg_global.conf import malojaconfig - +from . import no_aux_mode if malojaconfig['USE_GLOBAL_CACHE']: @@ -21,15 +21,12 @@ if malojaconfig['USE_GLOBAL_CACHE']: @runhourly + @no_aux_mode def maintenance(): - from . import AUX_MODE - if AUX_MODE: return print_stats() trim_cache() def print_stats(): - from . import AUX_MODE - if AUX_MODE: return for name,c in (('Cache',cache),('Entity Cache',entitycache)): hits, misses = c.get_stats() log(f"{name}: Size: {len(c)} | Hits: {hits}/{hits+misses} | Estimated Memory: {human_readable_size(c)}") @@ -37,8 +34,6 @@ if malojaconfig['USE_GLOBAL_CACHE']: def cached_wrapper(inner_func): - from . import AUX_MODE - if AUX_MODE: return inner_func def outer_func(*args,**kwargs): @@ -63,8 +58,6 @@ if malojaconfig['USE_GLOBAL_CACHE']: # we don't want a new cache entry for every single combination, but keep a common # cache that's aware of what we're calling def cached_wrapper_individual(inner_func): - from . import AUX_MODE - if AUX_MODE: return def outer_func(set_arg,**kwargs): if 'dbconn' in kwargs: @@ -88,9 +81,8 @@ if malojaconfig['USE_GLOBAL_CACHE']: return outer_func + @no_aux_mode def invalidate_caches(scrobbletime=None): - from . import AUX_MODE - if AUX_MODE: return cleared, kept = 0, 0 for k in cache.keys(): @@ -102,16 +94,12 @@ if malojaconfig['USE_GLOBAL_CACHE']: kept += 1 log(f"Invalidated {cleared} of {cleared+kept} DB cache entries") - + @no_aux_mode def invalidate_entity_cache(): - from . import AUX_MODE - if AUX_MODE: return entitycache.clear() - + @no_aux_mode def trim_cache(): - from . import AUX_MODE - if AUX_MODE: return ramprct = psutil.virtual_memory().percent if ramprct > malojaconfig["DB_MAX_MEMORY"]: log(f"{ramprct}% RAM usage, clearing cache!") diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index f64c43e..dec7da5 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -8,6 +8,7 @@ from threading import Lock from ..pkg_global.conf import data_dir from .dbcache import cached_wrapper, cached_wrapper_individual, invalidate_caches, invalidate_entity_cache from . import exceptions as exc +from . import no_aux_mode from doreah.logging import log from doreah.regular import runhourly, runmonthly @@ -1361,11 +1362,9 @@ def search_album(searchterm,dbconn=None): @runhourly @connection_provider +@no_aux_mode def clean_db(dbconn=None): - from . import AUX_MODE - if AUX_MODE: return - with SCROBBLE_LOCK: log(f"Database Cleanup...") @@ -1410,11 +1409,9 @@ def clean_db(dbconn=None): @runmonthly +@no_aux_mode def renormalize_names(): - from . import AUX_MODE - if AUX_MODE: return - with SCROBBLE_LOCK: with engine.begin() as conn: rows = conn.execute(DB['artists'].select()).all() From d8e5f6552e8921af05f77adcc40c48096c26e36f Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 17:31:46 +0200 Subject: [PATCH 048/176] Improved aux mode again --- maloja/database/__init__.py | 12 ++---------- maloja/database/dbcache.py | 1 - maloja/pkg_global/conf.py | 2 ++ maloja/proccontrol/tasks/import_scrobbles.py | 3 --- maloja/proccontrol/tasks/parse_albums.py | 6 +----- maloja/server.py | 3 +++ 6 files changed, 8 insertions(+), 19 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index f37910e..4549c9d 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -2,20 +2,12 @@ from bottle import request, response, FormsDict -# we're running an auxiliary task that doesn't require all the random background -# nonsense to be fired up -# this is temporary -# FIX YO DAMN ARCHITECTURE ALREADY -AUX_MODE = False -def set_aux_mode(): - global AUX_MODE - AUX_MODE = True - # decorator that makes sure this function is only run in normal operation, # not when we run a task that needs to access the database def no_aux_mode(func): def wrapper(*args,**kwargs): - if AUX_MODE: return + from ..pkg_global import conf + if conf.AUX_MODE: return return func(*args,**kwargs) return wrapper diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index da812a5..42695dc 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -98,7 +98,6 @@ if malojaconfig['USE_GLOBAL_CACHE']: def invalidate_entity_cache(): entitycache.clear() - @no_aux_mode def trim_cache(): ramprct = psutil.virtual_memory().percent if ramprct > malojaconfig["DB_MAX_MEMORY"]: diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index f519dc5..5d9b885 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -6,6 +6,8 @@ from doreah.configuration import types as tp from ..__pkginfo__ import VERSION +# this mode specifies whether we run some auxiliary task instead of the main server +AUX_MODE = True # if DATA_DIRECTORY is specified, this is the directory to use for EVERYTHING, no matter what diff --git a/maloja/proccontrol/tasks/import_scrobbles.py b/maloja/proccontrol/tasks/import_scrobbles.py index b34ccc0..1bab1c6 100644 --- a/maloja/proccontrol/tasks/import_scrobbles.py +++ b/maloja/proccontrol/tasks/import_scrobbles.py @@ -21,9 +21,6 @@ outputs = { def import_scrobbles(inputf): - from ...database import set_aux_mode - set_aux_mode() - from ...database.sqldb import add_scrobbles result = { diff --git a/maloja/proccontrol/tasks/parse_albums.py b/maloja/proccontrol/tasks/parse_albums.py index 125df0c..afeb8c9 100644 --- a/maloja/proccontrol/tasks/parse_albums.py +++ b/maloja/proccontrol/tasks/parse_albums.py @@ -1,11 +1,7 @@ - - +from doreah.io import col def parse_albums(replace=False): - from ...database import set_aux_mode - set_aux_mode() - from ...database.sqldb import guess_albums, get_album_id, add_track_to_album print("Parsing album information...") diff --git a/maloja/server.py b/maloja/server.py index 6f76524..a071c98 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -22,6 +22,7 @@ from .database.jinjaview import JinjaDBConnection 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 .pkg_global import conf from .jinjaenv.context import jinja_environment from .apis import init_apis, apikeystore @@ -285,6 +286,8 @@ logging.getLogger().addHandler(WaitressLogHandler()) def run_server(): + conf.AUX_MODE = False + log("Starting up Maloja server...") ## start database From e4bf26b86d6840d904b68b9ec68cbecfc481763e Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 17:38:36 +0200 Subject: [PATCH 049/176] Added meta table to database --- maloja/database/sqldb.py | 31 +++++++++++++++++++++++++++++++ maloja/pkg_global/conf.py | 12 ++---------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index dec7da5..c16cf30 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1,4 +1,5 @@ import sqlalchemy as sql +from sqlalchemy.dialects.sqlite import insert as sqliteinsert import json import unicodedata import math @@ -20,6 +21,13 @@ from doreah.regular import runhourly, runmonthly DBTABLES = { # name - type - foreign key - kwargs + '_maloja':{ + 'columns':[ + ("key", sql.String, {'primary_key':True}), + ("value", sql.String, {}) + ], + 'extraargs':(),'extrakwargs':{} + }, 'scrobbles':{ 'columns':[ ("timestamp", sql.Integer, {'primary_key':True}), @@ -151,6 +159,29 @@ def connection_provider(func): wrapper.__innerfunc__ = func return wrapper +@connection_provider +def get_maloja_info(keys,dbconn=None): + op = DB['_maloja'].select().where( + DB['_maloja'].c.key.in_(keys) + ) + result = dbconn.execute(op).all() + + info = {} + for row in result: + info[row.key] = row.value + return info + +@connection_provider +def set_maloja_info(info,dbconn=None): + for k in info: + op = sqliteinsert(DB['_maloja']).values( + key=k, value=info[k] + ).on_conflict_do_update( + index_elements=['key'], + set_={'value':info[k]} + ) + dbconn.execute(op) + ##### DB <-> Dict translations ## ATTENTION ALL ADVENTURERS diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 5d9b885..3f49c0f 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -302,15 +302,6 @@ data_dir = { -### write down the last ran version -with open(pthj(dir_settings['state'],".lastmalojaversion"),"w") as filed: - filed.write(VERSION) - filed.write("\n") - - - - - ### DOREAH CONFIGURATION from doreah import config @@ -336,7 +327,8 @@ config( custom_css_files = [f for f in os.listdir(data_dir['css']()) if f.lower().endswith('.css')] - +from ..database.sqldb import set_maloja_info +set_maloja_info({'last_run_version':VERSION}) # what the fuck did i just write # this spaghetti file is proudly sponsored by the rice crackers i'm eating at the From 517bc6f5c0ccb2263356f95945284fb84bc6e64d Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 21:04:30 +0200 Subject: [PATCH 050/176] Completely reworked album parsing --- maloja/__main__.py | 4 +- maloja/database/sqldb.py | 43 +++++----- maloja/proccontrol/tasks/parse_albums.py | 105 +++++++++++++++++++++-- maloja/web/jinja/artist.jinja | 6 +- 4 files changed, 123 insertions(+), 35 deletions(-) diff --git a/maloja/__main__.py b/maloja/__main__.py index 4694a40..e39fce3 100644 --- a/maloja/__main__.py +++ b/maloja/__main__.py @@ -148,7 +148,7 @@ def print_info(): print("Could not determine dependency versions.") print() -@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images'],shield=True) +@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images','prefer_existing'],shield=True) def main(*args,**kwargs): actions = { @@ -166,7 +166,7 @@ def main(*args,**kwargs): "generate":generate.generate_scrobbles, # maloja generate 400 "export":tasks.export, # maloja export "apidebug":apidebug.run, # maloja apidebug - "parsealbums":tasks.parse_albums, # maloja parsealbums + "parsealbums":tasks.parse_albums, # maloja parsealbums --strategy majority # aux "info":print_info } diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index c16cf30..5be84e6 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -483,14 +483,11 @@ def get_artist_id(artistname,create_new=True,dbconn=None): @cached_wrapper @connection_provider -def get_album_id(albumdict,create_new=True,dbconn=None): +def get_album_id(albumdict,create_new=True,ignore_albumartists=False,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( @@ -498,20 +495,23 @@ def get_album_id(albumdict,create_new=True,dbconn=None): ) 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]) + if ignore_albumartists: return row.id + else: + # 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 @@ -1601,7 +1601,7 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): }} if len(artists) == 0: # for albums without artist, assume track artist - res[track_id]["guess_artists"] = True + res[track_id]["guess_artists"] = [] else: res[track_id] = {"assigned":False,"reason":"Not enough data"} @@ -1610,7 +1610,7 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): - missing_artists = [track_id for track_id in res if res[track_id].get("guess_artists")] + missing_artists = [track_id for track_id in res if "guess_artists" in res[track_id]] #we're pointlessly getting the albumartist names here even though the IDs would be enough #but it's better for function separation I guess @@ -1627,10 +1627,7 @@ def guess_albums(track_ids=None,replace=False,dbconn=None): result = dbconn.execute(op).all() for row in result: - res[row.track_id]["assigned"]["artists"].append(row.name) - for track_id in res: - if res[track_id].get("guess_artists"): - del res[track_id]["guess_artists"] + res[row.track_id]["guess_artists"].append(row.name) return res diff --git a/maloja/proccontrol/tasks/parse_albums.py b/maloja/proccontrol/tasks/parse_albums.py index afeb8c9..7a9d354 100644 --- a/maloja/proccontrol/tasks/parse_albums.py +++ b/maloja/proccontrol/tasks/parse_albums.py @@ -1,19 +1,106 @@ from doreah.io import col -def parse_albums(replace=False): +def parse_albums(strategy=None,prefer_existing=False): + + if strategy not in ("track","none","all","majority","most"): + print(""" +Please specify your album parsing strategy: + + --strategy Specify what strategy to use when the scrobble contains + no information about album artists. + track Take the track artists. This can lead to + separate albums being created for compilation + albums or albums that have collaboration tracks. + none Merge all albums with the same name and assign + 'Various Artists' as the album artist. + all Merge all albums with the same name and assign + every artist that appears on the album as an album + artist. + majority Merge all albums with the same name and assign + artists that appear in at least half the tracks + of the album as album artists. [RECOMMENDED] + most Merge all albums with the same name and assign + the artist that appears most on the album as album + artist. + --prefer_existing If an album with the same name already exists, use it + without further examination of track artists. + """) + return + + from ...database.sqldb import guess_albums, get_album_id, add_track_to_album print("Parsing album information...") - result = guess_albums(replace=replace) + result = guess_albums() result = {track_id:result[track_id] for track_id in result if result[track_id]["assigned"]} - print("Adding",len(result),"tracks to albums...") + print("Found",col['yellow'](len(result)),"Tracks to assign albums to") + + result_authorative = {track_id:result[track_id] for track_id in result if result[track_id]["assigned"]["artists"]} + result_guesswork = {track_id:result[track_id] for track_id in result if not result[track_id]["assigned"]["artists"]} + i = 0 - for track_id in result: - album_id = get_album_id(result[track_id]["assigned"]) - add_track_to_album(track_id,album_id) - i += 1 + + def countup(i): + i+=1 if (i % 100) == 0: - print(i,"of",len(result)) - print("Done!") + print(f"Added album information for {i} of {len(result)} tracks...") + return i + + for track_id in result_authorative: + albuminfo = result[track_id]['assigned'] + album_id = get_album_id(albuminfo) + add_track_to_album(track_id,album_id) + i=countup(i) + + albums = {} + for track_id in result_guesswork: + albuminfo = result[track_id]['assigned'] + + # check if already exists + if prefer_existing: + album_id = get_album_id(albuminfo,ignore_albumartists=True,create_new=False) + if album_id: + add_track_to_album(track_id,album_id) + i=countup(i) + continue + + if strategy == 'track': + albuminfo['artists'] = result[track_id]['guess_artists'] + album_id = get_album_id(albuminfo) + add_track_to_album(track_id,album_id) + i=countup(i) + continue + + if strategy == 'none': + albuminfo['artists'] = [] + album_id = get_album_id(albuminfo) + add_track_to_album(track_id,album_id) + i=countup(i) + continue + + if strategy in ['all','majority','most']: + albums.setdefault(albuminfo['albumtitle'],{'track_ids':[],'artists':{}}) + albums[albuminfo['albumtitle']]['track_ids'].append(track_id) + for a in result[track_id]['guess_artists']: + albums[albuminfo['albumtitle']]['artists'].setdefault(a,0) + albums[albuminfo['albumtitle']]['artists'][a] += 1 + + + for title in albums: + artistoptions = albums[title]['artists'] + track_ids = albums[title]['track_ids'] + if strategy == 'all': + artists = [a for a in artistoptions] + elif strategy == 'majority': + artists = [a for a in artistoptions if artistoptions[a] >= (len(track_ids) / 2)] + elif strategy == 'most': + artists = [max(artistoptions,key=artistoptions.get)] + + for track_id in track_ids: + album_id = get_album_id({'albumtitle':title,'artists':artists}) + add_track_to_album(track_id,album_id) + i=countup(i) + + print(col['lawngreen']("Done!")) diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index f9b5c42..29d8879 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -90,7 +90,11 @@ -{% if info["isalbumartist"] %} +{% set albums_info = dbc.get_albums_artist_appears_on(filterkeys,limitkeys) %} +{% set ownalbums = albums_info.own_albums %} +{% set otheralbums = albums_info.appears_on %} + +{% if ownalbums or otheralbums %} {% if settings['ALBUM_SHOWCASE'] %}

Albums

From 54a085c5b23ab1bc1b2f3a51cf40666115d00deb Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 31 Mar 2023 21:06:54 +0200 Subject: [PATCH 051/176] Added startup upgrade task for album parsing --- maloja/database/__init__.py | 1 + maloja/database/exceptions.py | 2 +- maloja/upgrade.py | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 4549c9d..fcdb861 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -619,6 +619,7 @@ def start_db(): # Upgrade database from .. import upgrade upgrade.upgrade_db(sqldb.add_scrobbles) + upgrade.parse_old_albums() # Load temporary tables from . import associated diff --git a/maloja/database/exceptions.py b/maloja/database/exceptions.py index 0d4fce4..4a98d5c 100644 --- a/maloja/database/exceptions.py +++ b/maloja/database/exceptions.py @@ -16,7 +16,7 @@ class DatabaseNotBuilt(HTTPError): def __init__(self): super().__init__( status=503, - body="The Maloja Database is being upgraded to Version 3. This could take quite a long time! (~ 2-5 minutes per 10 000 scrobbles)", + body="The Maloja Database is being upgraded to support new Maloja features. This could take a while.", headers={"Retry-After":120} ) diff --git a/maloja/upgrade.py b/maloja/upgrade.py index 67d1176..a4f20cc 100644 --- a/maloja/upgrade.py +++ b/maloja/upgrade.py @@ -11,6 +11,9 @@ from .pkg_global.conf import data_dir, dir_settings from .apis import _apikeys +from .database.sqldb import get_maloja_info, set_maloja_info + + # Dealing with old style tsv files - these should be phased out everywhere def read_tsvs(path,types): result = [] @@ -40,7 +43,7 @@ def upgrade_apikeys(): except Exception: pass - +# v2 to v3 iupgrade def upgrade_db(callback_add_scrobbles): oldfolder = os.path.join(dir_settings['state'],"scrobbles") @@ -88,3 +91,13 @@ def upgrade_db(callback_add_scrobbles): callback_add_scrobbles(scrobblelist) os.rename(os.path.join(oldfolder,sf),os.path.join(newfolder,sf)) log("Done!",color='yellow') + + +# 3.2 album support +def parse_old_albums(): + setting_name = "db_upgrade_albums" + if get_maloja_info([setting_name]).get(setting_name): + pass + else: + pass + #set_maloja_info({setting_name:True}) From 9443ad2f624b94b3e9408534a8609b8bab48e4e5 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 00:49:31 +0200 Subject: [PATCH 052/176] Case insensitive album merging --- maloja/proccontrol/tasks/parse_albums.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/maloja/proccontrol/tasks/parse_albums.py b/maloja/proccontrol/tasks/parse_albums.py index 7a9d354..e499818 100644 --- a/maloja/proccontrol/tasks/parse_albums.py +++ b/maloja/proccontrol/tasks/parse_albums.py @@ -81,16 +81,18 @@ Please specify your album parsing strategy: continue if strategy in ['all','majority','most']: - albums.setdefault(albuminfo['albumtitle'],{'track_ids':[],'artists':{}}) - albums[albuminfo['albumtitle']]['track_ids'].append(track_id) + cleantitle = albuminfo['albumtitle'].lower() + albums.setdefault(cleantitle,{'track_ids':[],'artists':{},'title':albuminfo['albumtitle']}) + albums[cleantitle]['track_ids'].append(track_id) for a in result[track_id]['guess_artists']: - albums[albuminfo['albumtitle']]['artists'].setdefault(a,0) - albums[albuminfo['albumtitle']]['artists'][a] += 1 + albums[cleantitle]['artists'].setdefault(a,0) + albums[cleantitle]['artists'][a] += 1 - for title in albums: - artistoptions = albums[title]['artists'] - track_ids = albums[title]['track_ids'] + for cleantitle in albums: + artistoptions = albums[cleantitle]['artists'] + track_ids = albums[cleantitle]['track_ids'] + realtitle = albums[cleantitle]['title'] if strategy == 'all': artists = [a for a in artistoptions] elif strategy == 'majority': @@ -99,7 +101,7 @@ Please specify your album parsing strategy: artists = [max(artistoptions,key=artistoptions.get)] for track_id in track_ids: - album_id = get_album_id({'albumtitle':title,'artists':artists}) + album_id = get_album_id({'albumtitle':realtitle,'artists':artists}) add_track_to_album(track_id,album_id) i=countup(i) From 501984d04eaeeb0a928ee56fc3f0b7b0997e729b Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 04:16:58 +0200 Subject: [PATCH 053/176] Added option to show album art for tracks --- maloja/images.py | 8 ++++++++ maloja/pkg_global/conf.py | 1 + 2 files changed, 9 insertions(+) diff --git a/maloja/images.py b/maloja/images.py index f4425e4..e6ffec7 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -134,6 +134,14 @@ resolve_semaphore = BoundedSemaphore(8) def resolve_track_image(track_id): + if malojaconfig["USE_ALBUM_ARTWORK_FOR_TRACKS"]: + track = database.sqldb.get_track(track_id) + if "album" in track: + album_id = database.sqldb.get_album_id(track["album"]) + albumart = resolve_album_image(album_id) + if albumart: + return albumart + with resolve_semaphore: # check cache result = get_image_from_cache(track_id,'tracks') diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 3f49c0f..a9be47b 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -196,6 +196,7 @@ malojaconfig = Configuration( "album_showcase":(tp.Boolean(), "Display Album Showcase", True, "Display a graphical album showcase for artist overview pages instead of a chart list"), "display_art_icons":(tp.Boolean(), "Display Album/Artist Icons", True), "default_album_artist":(tp.String(), "Default Albumartist", "Various Artists"), + "use_album_artwork_for_tracks":(tp.Boolean(), "Use Album Artwork for tracks", True), "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), From 61d3015443747f96854e17a1279a28b5ce9a001f Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 14:21:12 +0200 Subject: [PATCH 054/176] Made image fetching asynchronous to incoming image requests --- maloja/images.py | 222 ++++++++++++++++++++++++++--------------------- maloja/server.py | 16 ++-- 2 files changed, 130 insertions(+), 108 deletions(-) diff --git a/maloja/images.py b/maloja/images.py index e6ffec7..639e464 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -12,7 +12,8 @@ import base64 import requests import datauri import io -from threading import Thread, Timer, BoundedSemaphore +from threading import Lock +from concurrent.futures import ThreadPoolExecutor import re import datetime @@ -25,6 +26,8 @@ DB = {} engine = sql.create_engine(f"sqlite:///{data_dir['cache']('images.sqlite')}", echo = False) meta = sql.MetaData() +dblock = Lock() + DB['artists'] = sql.Table( 'artists', meta, sql.Column('id',sql.Integer,primary_key=True), @@ -49,8 +52,18 @@ DB['albums'] = sql.Table( meta.create_all(engine) -def get_image_from_cache(id,table): +def get_image_from_cache(track_id=None,artist_id=None,album_id=None): now = int(datetime.datetime.now().timestamp()) + if track_id: + table = 'tracks' + id = track_id + elif album_id: + table = 'albums' + id = album_id + elif artist_id: + table = 'artists' + id = artist_id + with engine.begin() as conn: op = DB[table].select().where( DB[table].c.id==id, @@ -66,29 +79,31 @@ def get_image_from_cache(id,table): def set_image_in_cache(id,table,url): remove_image_from_cache(id,table) - now = int(datetime.datetime.now().timestamp()) - if url is None: - expire = now + (malojaconfig["CACHE_EXPIRE_NEGATIVE"] * 24 * 3600) - else: - expire = now + (malojaconfig["CACHE_EXPIRE_POSITIVE"] * 24 * 3600) + with dblock: + now = int(datetime.datetime.now().timestamp()) + if url is None: + expire = now + (malojaconfig["CACHE_EXPIRE_NEGATIVE"] * 24 * 3600) + else: + expire = now + (malojaconfig["CACHE_EXPIRE_POSITIVE"] * 24 * 3600) - raw = dl_image(url) + raw = dl_image(url) - with engine.begin() as conn: - op = DB[table].insert().values( - id=id, - url=url, - expire=expire, - raw=raw - ) - result = conn.execute(op) + with engine.begin() as conn: + op = DB[table].insert().values( + id=id, + url=url, + expire=expire, + raw=raw + ) + result = conn.execute(op) def remove_image_from_cache(id,table): - with engine.begin() as conn: - op = DB[table].delete().where( - DB[table].c.id==id, - ) - result = conn.execute(op) + with dblock: + with engine.begin() as conn: + op = DB[table].delete().where( + DB[table].c.id==id, + ) + result = conn.execute(op) def dl_image(url): if not malojaconfig["PROXY_IMAGES"]: return None @@ -107,122 +122,131 @@ def dl_image(url): + +resolver = ThreadPoolExecutor(max_workers=5) + ### getting images for any website embedding now ALWAYS returns just the generic link ### even if we have already cached it, we will handle that on request def get_track_image(track=None,track_id=None): if track_id is None: track_id = database.sqldb.get_track_id(track,create_new=False) - return f"/image?type=track&id={track_id}" + if malojaconfig["USE_ALBUM_ARTWORK_FOR_TRACKS"]: + if track is None: + track = database.sqldb.get_track(track_id) + if track.get("album"): + album_id = database.sqldb.get_album_id(track["album"]) + return get_album_image(album_id=album_id) + resolver.submit(resolve_image,track_id=track_id) + + return f"/image?track_id={track_id}" def get_artist_image(artist=None,artist_id=None): if artist_id is None: artist_id = database.sqldb.get_artist_id(artist,create_new=False) - return f"/image?type=artist&id={artist_id}" + resolver.submit(resolve_image,artist_id=artist_id) + + return f"/image?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,create_new=False) - return f"/image?type=album&id={album_id}" + resolver.submit(resolve_image,album_id=album_id) + + return f"/image?album_id={album_id}" -resolve_semaphore = BoundedSemaphore(8) +# this is to keep track of what is currently being resolved +# so new requests know that they don't need to queue another resolve +image_resolve_controller_lock = Lock() +image_resolve_controller = { + 'artists':set(), + 'albums':set(), + 'tracks':set() +} +# this function doesn't need to return any info +# it runs async to do all the work that takes time and only needs to write the result +# to the cache so the synchronous functions (http requests) can access it +def resolve_image(artist_id=None,track_id=None,album_id=None): + result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id) + if result is not None: + # No need to do anything + return -def resolve_track_image(track_id): + if artist_id: + entitytype = 'artist' + table = 'artists' + getfunc, entity_id = database.sqldb.get_artist, artist_id + elif track_id: + entitytype = 'track' + table = 'tracks' + getfunc, entity_id = database.sqldb.get_track, track_id + elif album_id: + entitytype = 'album' + table = 'albums' + getfunc, entity_id = database.sqldb.get_album, album_id - if malojaconfig["USE_ALBUM_ARTWORK_FOR_TRACKS"]: - track = database.sqldb.get_track(track_id) - if "album" in track: - album_id = database.sqldb.get_album_id(track["album"]) - albumart = resolve_album_image(album_id) - if albumart: - return albumart + # is another thread already working on this? + with image_resolve_controller_lock: + if entity_id in image_resolve_controller[table]: + return + else: + image_resolve_controller[table].add(entity_id) - with resolve_semaphore: - # check cache - result = get_image_from_cache(track_id,'tracks') - if result is not None: - return result - - track = database.sqldb.get_track(track_id) + try: + entity = getfunc(entity_id) # local image if malojaconfig["USE_LOCAL_IMAGES"]: - images = local_files(track=track) + images = local_files(**{entitytype: entity}) if len(images) != 0: result = random.choice(images) result = urllib.parse.quote(result) result = {'type':'url','value':result} - set_image_in_cache(track_id,'tracks',result['value']) + set_image_in_cache(artist_id or track_id or album_id,table,result['value']) return result # third party - result = thirdparty.get_image_track_all((track['artists'],track['title'])) + if artist_id: + result = thirdparty.get_image_artist_all(entity) + elif track_id: + result = thirdparty.get_image_track_all((entity['artists'],entity['title'])) + elif album_id: + result = thirdparty.get_image_album_all((entity['artists'],entity['albumtitle'])) + result = {'type':'url','value':result} - set_image_in_cache(track_id,'tracks',result['value']) + set_image_in_cache(artist_id or track_id or album_id,table,result['value']) + finally: + with image_resolve_controller_lock: + image_resolve_controller[table].remove(entity_id) + + +# the actual http request for the full image +def image_request(artist_id=None,track_id=None,album_id=None): + # check cache + result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id) + if result is not None: + # we got an entry, even if it's that there is no image (value None) + if result['value'] is None: + # use placeholder + placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" + if artist_id: + result['value'] = placeholder_url + f"123&colors={artist_id % 100}" + if track_id: + result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" + if album_id: + result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}" return result + else: + # no entry, which means we're still working on it + return {'type':'noimage','value':'wait'} -def resolve_artist_image(artist_id): - - with resolve_semaphore: - # check cache - result = get_image_from_cache(artist_id,'artists') - if result is not None: - return result - - artist = database.sqldb.get_artist(artist_id) - - # local image - if malojaconfig["USE_LOCAL_IMAGES"]: - images = local_files(artist=artist) - if len(images) != 0: - result = random.choice(images) - result = urllib.parse.quote(result) - result = {'type':'url','value':result} - set_image_in_cache(artist_id,'artists',result['value']) - return result - - # third party - result = thirdparty.get_image_artist_all(artist) - result = {'type':'url','value':result} - set_image_in_cache(artist_id,'artists',result['value']) - - 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): diff --git a/maloja/server.py b/maloja/server.py index a071c98..a92c1e9 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, resolve_album_image +from .images import image_request from .malojauri import uri_to_internal, remove_identical from .pkg_global.conf import malojaconfig, data_dir from .pkg_global import conf @@ -121,15 +121,13 @@ def deprecated_api(pth): @webserver.route("/image") def dynamic_image(): keys = FormsDict.decode(request.query) - if keys['type'] == 'track': - 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']) + result = image_request(**{k:int(keys[k]) for k in keys}) - if result is None or result['value'] in [None,'']: - return "" + if result['type'] == 'noimage' and result['value'] == 'wait': + # still being worked on + response.status = 503 + response.set_header('Retry-After',5) + return if result['type'] == 'raw': # data uris are directly served as image because a redirect to a data uri # doesnt work From 31661c41411e13a071c32b261b0cb3744455b8ef Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 15:28:33 +0200 Subject: [PATCH 055/176] Improved image proxying --- maloja/data_files/cache/images/dummy | 0 maloja/images.py | 67 ++++++++++++++++++---------- maloja/server.py | 11 ++--- 3 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 maloja/data_files/cache/images/dummy diff --git a/maloja/data_files/cache/images/dummy b/maloja/data_files/cache/images/dummy new file mode 100644 index 0000000..e69de29 diff --git a/maloja/images.py b/maloja/images.py index 639e464..19eb6b3 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -21,9 +21,14 @@ import sqlalchemy as sql +# remove old db file (columns missing) +try: + os.remove(data_dir['cache']('images.sqlite')) +except: + pass DB = {} -engine = sql.create_engine(f"sqlite:///{data_dir['cache']('images.sqlite')}", echo = False) +engine = sql.create_engine(f"sqlite:///{data_dir['cache']('imagecache.sqlite')}", echo = False) meta = sql.MetaData() dblock = Lock() @@ -33,51 +38,61 @@ DB['artists'] = sql.Table( sql.Column('id',sql.Integer,primary_key=True), sql.Column('url',sql.String), sql.Column('expire',sql.Integer), - sql.Column('raw',sql.String) +# sql.Column('raw',sql.String) + sql.Column('local',sql.Boolean), + sql.Column('localproxyurl',sql.String) ) DB['tracks'] = sql.Table( 'tracks', meta, sql.Column('id',sql.Integer,primary_key=True), sql.Column('url',sql.String), sql.Column('expire',sql.Integer), - sql.Column('raw',sql.String) +# sql.Column('raw',sql.String) + sql.Column('local',sql.Boolean), + sql.Column('localproxyurl',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) +# sql.Column('raw',sql.String) + sql.Column('local',sql.Boolean), + sql.Column('localproxyurl',sql.String) ) meta.create_all(engine) + + def get_image_from_cache(track_id=None,artist_id=None,album_id=None): now = int(datetime.datetime.now().timestamp()) if track_id: table = 'tracks' - id = track_id + entity_id = track_id elif album_id: table = 'albums' - id = album_id + entity_id = album_id elif artist_id: table = 'artists' - id = artist_id + entity_id = artist_id with engine.begin() as conn: op = DB[table].select().where( - DB[table].c.id==id, + DB[table].c.id==entity_id, DB[table].c.expire>now ) result = conn.execute(op).all() for row in result: - if row.raw is not None: - return {'type':'raw','value':row.raw} + if row.local: + return {'type':'localurl','value':row.url} + elif row.localproxyurl: + return {'type':'localurl','value':row.localproxyurl} else: return {'type':'url','value':row.url} # returns None as value if nonexistence cached return None # no cache entry -def set_image_in_cache(id,table,url): +def set_image_in_cache(id,table,url,local=False): remove_image_from_cache(id,table) with dblock: now = int(datetime.datetime.now().timestamp()) @@ -86,14 +101,18 @@ def set_image_in_cache(id,table,url): else: expire = now + (malojaconfig["CACHE_EXPIRE_POSITIVE"] * 24 * 3600) - raw = dl_image(url) + if not local and malojaconfig["PROXY_IMAGES"] and url is not None: + localproxyurl = dl_image(url) + else: + localproxyurl = None with engine.begin() as conn: op = DB[table].insert().values( id=id, url=url, expire=expire, - raw=raw + local=local, + localproxyurl=localproxyurl ) result = conn.execute(op) @@ -105,17 +124,19 @@ def remove_image_from_cache(id,table): ) result = conn.execute(op) + # TODO delete proxy + def dl_image(url): - if not malojaconfig["PROXY_IMAGES"]: return None - if url is None: return None - if url.startswith("/"): return None #local image try: r = requests.get(url) mime = r.headers.get('content-type') or 'image/jpg' data = io.BytesIO(r.content).read() - uri = datauri.DataURI.make(mime,charset='ascii',base64=True,data=data) - log(f"Downloaded {url} for local caching") - return uri + #uri = datauri.DataURI.make(mime,charset='ascii',base64=True,data=data) + targetname = '%030x' % random.getrandbits(128) + targetpath = data_dir['cache']('images',targetname) + with open(targetpath,'wb') as fd: + fd.write(data) + return os.path.join("cacheimages",targetname) except Exception: log(f"Image {url} could not be downloaded for local caching") return None @@ -206,8 +227,8 @@ def resolve_image(artist_id=None,track_id=None,album_id=None): if len(images) != 0: result = random.choice(images) result = urllib.parse.quote(result) - result = {'type':'url','value':result} - set_image_in_cache(artist_id or track_id or album_id,table,result['value']) + result = {'type':'localurl','value':result} + set_image_in_cache(artist_id or track_id or album_id,table,result['value'],local=True) return result # third party @@ -236,7 +257,7 @@ def image_request(artist_id=None,track_id=None,album_id=None): # use placeholder placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" if artist_id: - result['value'] = placeholder_url + f"123&colors={artist_id % 100}" + result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}" if track_id: result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" if album_id: @@ -377,6 +398,6 @@ def set_image(b64,**keys): log("Saved image as " + data_dir['images'](folder,filename),module="debug") # set as current picture in rotation - set_image_in_cache(id,dbtable,os.path.join("/images",folder,filename)) + set_image_in_cache(id,dbtable,os.path.join("/images",folder,filename),local=True) return os.path.join("/images",folder,filename) diff --git a/maloja/server.py b/maloja/server.py index a92c1e9..540f65c 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -128,13 +128,7 @@ def dynamic_image(): response.status = 503 response.set_header('Retry-After',5) return - if result['type'] == 'raw': - # data uris are directly served as image because a redirect to a data uri - # doesnt work - duri = datauri.DataURI(result['value']) - response.content_type = duri.mimetype - return duri.data - if result['type'] == 'url': + if result['type'] in ('url','localurl'): redirect(result['value'],307) @webserver.route("/images/") @@ -161,6 +155,9 @@ def static_image(pth): resp.set_header("Content-Type", "image/" + ext) return resp +@webserver.route("/cacheimages/") +def static_proxied_image(uuid): + return static_file(uuid,root=data_dir['cache']('images')) @webserver.route("/login") def login(): From 3b286bd7f288a6e3ef4be391d33d1bdb36b8ae51 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 15:31:18 +0200 Subject: [PATCH 056/176] Small fix to entity main image display --- maloja/web/jinja/album.jinja | 4 ++-- maloja/web/jinja/artist.jinja | 4 ++-- maloja/web/jinja/track.jinja | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 6a16da3..8dafdaf 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -45,11 +45,11 @@ {% if adminmode %}
{% else %} -
+
{% endif %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 29d8879..8249b99 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -53,11 +53,11 @@ {% if adminmode %}
{% else %} -
+
{% endif %} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 3fbc909..982097e 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -50,11 +50,11 @@ {% if adminmode %}
{% else %} -
+
{% endif %} From 31aaf23d808bc5adc845f4fb804eff9fa26e6476 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 15:46:15 +0200 Subject: [PATCH 057/176] Refactored images a bit --- maloja/images.py | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/maloja/images.py b/maloja/images.py index 19eb6b3..151c0cd 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -21,6 +21,9 @@ import sqlalchemy as sql +MAX_RESOLVE_THREADS = 10 + + # remove old db file (columns missing) try: os.remove(data_dir['cache']('images.sqlite')) @@ -63,19 +66,17 @@ DB['albums'] = sql.Table( meta.create_all(engine) - +def get_id_and_table(track_id=None,artist_id=None,album_id=None): + if track_id: + return track_id,'tracks' + elif album_id: + return album_id,'albums' + elif artist_id: + return artist_id,'artists' def get_image_from_cache(track_id=None,artist_id=None,album_id=None): now = int(datetime.datetime.now().timestamp()) - if track_id: - table = 'tracks' - entity_id = track_id - elif album_id: - table = 'albums' - entity_id = album_id - elif artist_id: - table = 'artists' - entity_id = artist_id + entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id) with engine.begin() as conn: op = DB[table].select().where( @@ -92,8 +93,10 @@ def get_image_from_cache(track_id=None,artist_id=None,album_id=None): return {'type':'url','value':row.url} # returns None as value if nonexistence cached return None # no cache entry -def set_image_in_cache(id,table,url,local=False): - remove_image_from_cache(id,table) +def set_image_in_cache(url,track_id=None,artist_id=None,album_id=None,local=False): + remove_image_from_cache(track_id=track_id,artist_id=artist_id,album_id=album_id) + entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id) + with dblock: now = int(datetime.datetime.now().timestamp()) if url is None: @@ -108,7 +111,7 @@ def set_image_in_cache(id,table,url,local=False): with engine.begin() as conn: op = DB[table].insert().values( - id=id, + id=entity_id, url=url, expire=expire, local=local, @@ -116,11 +119,13 @@ def set_image_in_cache(id,table,url,local=False): ) result = conn.execute(op) -def remove_image_from_cache(id,table): +def remove_image_from_cache(track_id=None,artist_id=None,album_id=None): + entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id) + with dblock: with engine.begin() as conn: op = DB[table].delete().where( - DB[table].c.id==id, + DB[table].c.id==entity_id, ) result = conn.execute(op) @@ -136,7 +141,7 @@ def dl_image(url): targetpath = data_dir['cache']('images',targetname) with open(targetpath,'wb') as fd: fd.write(data) - return os.path.join("cacheimages",targetname) + return os.path.join("/cacheimages",targetname) except Exception: log(f"Image {url} could not be downloaded for local caching") return None @@ -144,7 +149,7 @@ def dl_image(url): -resolver = ThreadPoolExecutor(max_workers=5) +resolver = ThreadPoolExecutor(max_workers=MAX_RESOLVE_THREADS) ### getting images for any website embedding now ALWAYS returns just the generic link ### even if we have already cached it, we will handle that on request @@ -228,7 +233,7 @@ def resolve_image(artist_id=None,track_id=None,album_id=None): result = random.choice(images) result = urllib.parse.quote(result) result = {'type':'localurl','value':result} - set_image_in_cache(artist_id or track_id or album_id,table,result['value'],local=True) + set_image_in_cache(artist_id=artist_id,track_id=track_id,album_id=album_id,url=result['value'],local=True) return result # third party @@ -240,7 +245,7 @@ def resolve_image(artist_id=None,track_id=None,album_id=None): result = thirdparty.get_image_album_all((entity['artists'],entity['albumtitle'])) result = {'type':'url','value':result} - set_image_in_cache(artist_id or track_id or album_id,table,result['value']) + set_image_in_cache(artist_id=artist_id,track_id=track_id,album_id=album_id,url=result['value']) finally: with image_resolve_controller_lock: image_resolve_controller[table].remove(entity_id) @@ -364,14 +369,17 @@ def set_image(b64,**keys): if "title" in keys: entity = {"track":keys} id = database.sqldb.get_track_id(entity['track']) + idkeys = {'track_id':id} dbtable = "tracks" elif "albumtitle" in keys: entity = {"album":keys} id = database.sqldb.get_album_id(entity['album']) + idkeys = {'album_id':id} dbtable = "albums" elif "artist" in keys: entity = keys id = database.sqldb.get_artist_id(entity['artist']) + idkeys = {'artist_id':id} dbtable = "artists" log("Trying to set image, b64 string: " + str(b64[:30] + "..."),module="debug") @@ -398,6 +406,6 @@ def set_image(b64,**keys): log("Saved image as " + data_dir['images'](folder,filename),module="debug") # set as current picture in rotation - set_image_in_cache(id,dbtable,os.path.join("/images",folder,filename),local=True) + set_image_in_cache(**idkeys,url=os.path.join("/images",folder,filename),local=True) return os.path.join("/images",folder,filename) From 72826f87feebb8048cefb59aed16f1d9371c6271 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 16:11:20 +0200 Subject: [PATCH 058/176] Removing proxied image after they expire --- maloja/images.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/maloja/images.py b/maloja/images.py index 151c0cd..188df16 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -21,7 +21,7 @@ import sqlalchemy as sql -MAX_RESOLVE_THREADS = 10 +MAX_RESOLVE_THREADS = 5 # remove old db file (columns missing) @@ -126,10 +126,19 @@ def remove_image_from_cache(track_id=None,artist_id=None,album_id=None): with engine.begin() as conn: op = DB[table].delete().where( DB[table].c.id==entity_id, + ).returning( + DB[table].c.id, + DB[table].c.localproxyurl ) - result = conn.execute(op) + result = conn.execute(op).all() + + for row in result: + targetpath = data_dir['cache']('images',row.localproxyurl.split('/')[-1]) + try: + os.remove(targetpath) + except: + pass - # TODO delete proxy def dl_image(url): try: From cbb1e0b2c235141c71e5307a2535fc654057bf95 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 17:02:41 +0200 Subject: [PATCH 059/176] Gave image request a bit of time to resolve --- maloja/images.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/maloja/images.py b/maloja/images.py index 188df16..ca0e783 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -16,12 +16,14 @@ from threading import Lock from concurrent.futures import ThreadPoolExecutor import re import datetime +import time import sqlalchemy as sql MAX_RESOLVE_THREADS = 5 +MAX_SECONDS_TO_RESOLVE_REQUEST = 5 # remove old db file (columns missing) @@ -263,23 +265,31 @@ def resolve_image(artist_id=None,track_id=None,album_id=None): # the actual http request for the full image def image_request(artist_id=None,track_id=None,album_id=None): - # check cache - result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id) - if result is not None: - # we got an entry, even if it's that there is no image (value None) - if result['value'] is None: - # use placeholder - placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" - if artist_id: - result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}" - if track_id: - result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" - if album_id: - result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}" - return result - else: - # no entry, which means we're still working on it - return {'type':'noimage','value':'wait'} + + # because we use lazyload, we can allow our http requests to take a little while at least + # not the full backend request, but a few seconds to give us time to fetch some images + # because 503 retry-after doesn't seem to be honored + attempt = 0 + while attempt < MAX_SECONDS_TO_RESOLVE_REQUEST: + attempt += 1 + # check cache + result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id) + if result is not None: + # we got an entry, even if it's that there is no image (value None) + if result['value'] is None: + # use placeholder + placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" + if artist_id: + result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}" + if track_id: + result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" + if album_id: + result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}" + return result + time.sleep(1) + + # no entry, which means we're still working on it + return {'type':'noimage','value':'wait'} From 1fcba941fa54f5a7128af5b15a357c000a5030e5 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 1 Apr 2023 17:35:44 +0200 Subject: [PATCH 060/176] Added placeholder images --- maloja/images.py | 22 +++++++++++++------- maloja/pkg_global/conf.py | 1 + maloja/web/static/svg/LICENSE | 21 +++++++++++++++++++ maloja/web/static/svg/placeholder_album.svg | 1 + maloja/web/static/svg/placeholder_artist.svg | 1 + maloja/web/static/svg/placeholder_track.svg | 1 + 6 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 maloja/web/static/svg/LICENSE create mode 100644 maloja/web/static/svg/placeholder_album.svg create mode 100644 maloja/web/static/svg/placeholder_artist.svg create mode 100644 maloja/web/static/svg/placeholder_track.svg diff --git a/maloja/images.py b/maloja/images.py index ca0e783..e576c84 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -278,13 +278,21 @@ def image_request(artist_id=None,track_id=None,album_id=None): # we got an entry, even if it's that there is no image (value None) if result['value'] is None: # use placeholder - placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" - if artist_id: - result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}" - if track_id: - result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" - if album_id: - result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}" + if malojaconfig["FANCY_PLACEHOLDER_ART"]: + placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" + if artist_id: + result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}" + if track_id: + result['value'] = placeholder_url + f"triangles&colors={track_id % 100}" + if album_id: + result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}" + else: + if artist_id: + result['value'] = "/static/svg/placeholder_artist.svg" + if track_id: + result['value'] = "/static/svg/placeholder_track.svg" + if album_id: + result['value'] = "/static/svg/placeholder_album.svg" return result time.sleep(1) diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index a9be47b..e6dac70 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -197,6 +197,7 @@ malojaconfig = Configuration( "display_art_icons":(tp.Boolean(), "Display Album/Artist Icons", True), "default_album_artist":(tp.String(), "Default Albumartist", "Various Artists"), "use_album_artwork_for_tracks":(tp.Boolean(), "Use Album Artwork for tracks", True), + "fancy_placeholder_art":(tp.Boolean(), "Use fancy placeholder artwork",True), "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/web/static/svg/LICENSE b/maloja/web/static/svg/LICENSE new file mode 100644 index 0000000..6be4c3e --- /dev/null +++ b/maloja/web/static/svg/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present Ionic (http://ionic.io/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/maloja/web/static/svg/placeholder_album.svg b/maloja/web/static/svg/placeholder_album.svg new file mode 100644 index 0000000..751e22a --- /dev/null +++ b/maloja/web/static/svg/placeholder_album.svg @@ -0,0 +1 @@ + diff --git a/maloja/web/static/svg/placeholder_artist.svg b/maloja/web/static/svg/placeholder_artist.svg new file mode 100644 index 0000000..f7e6b85 --- /dev/null +++ b/maloja/web/static/svg/placeholder_artist.svg @@ -0,0 +1 @@ + diff --git a/maloja/web/static/svg/placeholder_track.svg b/maloja/web/static/svg/placeholder_track.svg new file mode 100644 index 0000000..a6e6efb --- /dev/null +++ b/maloja/web/static/svg/placeholder_track.svg @@ -0,0 +1 @@ + From 676ca9d4f50960948be3102aea1d01581424e68f Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 8 Apr 2023 17:40:18 +0200 Subject: [PATCH 061/176] Some interlinking --- maloja/web/jinja/partials/top_albums.jinja | 2 +- maloja/web/jinja/partials/top_artists.jinja | 2 +- maloja/web/jinja/partials/top_tracks.jinja | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/maloja/web/jinja/partials/top_albums.jinja b/maloja/web/jinja/partials/top_albums.jinja index cdedd6a..7a2abb0 100644 --- a/maloja/web/jinja/partials/top_albums.jinja +++ b/maloja/web/jinja/partials/top_albums.jinja @@ -12,7 +12,7 @@ {% set thisrange = e.range %} {% set album = e.album %} - {{ thisrange.desc() }} + {{ thisrange.desc() }} {% if album is none %}
diff --git a/maloja/web/jinja/partials/top_artists.jinja b/maloja/web/jinja/partials/top_artists.jinja index 60c1481..a780611 100644 --- a/maloja/web/jinja/partials/top_artists.jinja +++ b/maloja/web/jinja/partials/top_artists.jinja @@ -12,7 +12,7 @@ {% set thisrange = e.range %} {% set artist = e.artist %} - {{ thisrange.desc() }} + {{ thisrange.desc() }} {% if artist is none %}
diff --git a/maloja/web/jinja/partials/top_tracks.jinja b/maloja/web/jinja/partials/top_tracks.jinja index 2c766f2..d05fef4 100644 --- a/maloja/web/jinja/partials/top_tracks.jinja +++ b/maloja/web/jinja/partials/top_tracks.jinja @@ -12,7 +12,7 @@ {% set thisrange = e.range %} {% set track = e.track %} - {{ thisrange.desc() }} + {{ thisrange.desc() }} {% if track is none %}
From eb5806be9957114584fa366a1c273241dcdd81c4 Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 16 Apr 2023 18:52:25 +0200 Subject: [PATCH 062/176] Made some list lengths 4-divisible --- maloja/web/jinja/album.jinja | 4 ++-- maloja/web/jinja/artist.jinja | 6 +++--- maloja/web/jinja/track.jinja | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 8dafdaf..ce6ddbe 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -79,7 +79,7 @@

Top Tracks

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

Last Scrobbles

-{% with amountkeys = {"perpage":15,"page":0} %} +{% with amountkeys = {"perpage":16,"page":0} %} {% include 'partials/scrobbles.jinja' %} {% endwith %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 8249b99..f6b22d6 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -102,7 +102,7 @@ {% else %}

Top Albums

- {% with amountkeys={"perpage":15,"page":0} %} + {% with amountkeys={"perpage":16,"page":0} %} {% include 'partials/charts_albums.jinja' %} {% endwith %} {% endif %} @@ -112,7 +112,7 @@ {% if info['scrobbles']>0 %}

Top Tracks

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

Last Scrobbles

-{% with amountkeys = {"perpage":15,"page":0} %} +{% with amountkeys = {"perpage":16,"page":0} %} {% include 'partials/scrobbles.jinja' %} {% endwith %} {% endif %} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 982097e..85f0c10 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -155,7 +155,7 @@

Last Scrobbles

-{% with amountkeys = {"perpage":15,"page":0} %} +{% with amountkeys = {"perpage":16,"page":0} %} {% include 'partials/scrobbles.jinja' %} {% endwith %} From d5c457a2e913db3771a333cad5444719eb4d5245 Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 7 May 2023 18:27:14 +0200 Subject: [PATCH 063/176] Added albums to manual scrobbling --- maloja/web/jinja/admin_manual.jinja | 15 ++++++ maloja/web/static/js/manualscrobble.js | 63 ++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/maloja/web/jinja/admin_manual.jinja b/maloja/web/jinja/admin_manual.jinja index f870f0c..3ed2ea5 100644 --- a/maloja/web/jinja/admin_manual.jinja +++ b/maloja/web/jinja/admin_manual.jinja @@ -26,6 +26,21 @@ + + + Album artists (Optional): + + + + + + + Album (Optional): + + + + +
diff --git a/maloja/web/static/js/manualscrobble.js b/maloja/web/static/js/manualscrobble.js index 5095337..6b7502a 100644 --- a/maloja/web/static/js/manualscrobble.js +++ b/maloja/web/static/js/manualscrobble.js @@ -10,11 +10,23 @@ function addArtist(artist) { document.getElementById("artists_td").insertBefore(artistelement,newartistfield); newartistfield.placeholder = "Backspace to remove last" } +function addAlbumartist(artist) { + var newartistfield = document.getElementById("albumartists"); + var artistelement = document.createElement("span"); + artistelement.innerHTML = artist; + artistelement.style = "padding:5px;"; + document.getElementById("albumartists_td").insertBefore(artistelement,newartistfield); + newartistfield.placeholder = "Backspace to remove last" +} function keyDetect(event) { if (event.key === "Enter" || event.key === "Tab") { addEnteredArtist() } if (event.key === "Backspace" && document.getElementById("artists").value == "") { removeArtist() } } +function keyDetect2(event) { + if (event.key === "Enter" || event.key === "Tab") { addEnteredAlbumartist() } + if (event.key === "Backspace" && document.getElementById("albumartists").value == "") { removeAlbumartist() } +} function addEnteredArtist() { var newartistfield = document.getElementById("artists"); @@ -24,6 +36,14 @@ function addEnteredArtist() { addArtist(newartist); } } +function addEnteredAlbumartist() { + var newartistfield = document.getElementById("albumartists"); + var newartist = newartistfield.value.trim(); + newartistfield.value = ""; + if (newartist != "") { + addAlbumartist(newartist); + } +} function removeArtist() { var artists = document.getElementById("artists_td").getElementsByTagName("span") var lastartist = artists[artists.length-1] @@ -32,14 +52,28 @@ function removeArtist() { document.getElementById("artists").placeholder = "Separate with Enter" } } +function removeAlbumartist() { + var artists = document.getElementById("albumartists_td").getElementsByTagName("span") + var lastartist = artists[artists.length-1] + document.getElementById("albumartists_td").removeChild(lastartist); + if (artists.length < 1) { + document.getElementById("albumartists").placeholder = "Separate with Enter" + } +} function clear() { document.getElementById("title").value = ""; document.getElementById("artists").value = ""; + document.getElementById("album").value = ""; + document.getElementById("albumartists").value = ""; var artists = document.getElementById("artists_td").getElementsByTagName("span") while (artists.length > 0) { removeArtist(); } + var albumartists = document.getElementById("albumartists_td").getElementsByTagName("span") + while (albumartists.length > 0) { + removeAlbumartist(); + } } @@ -55,18 +89,30 @@ function scrobbleNew() { for (let node of artistnodes) { artists.push(node.textContent); } + + var albumartistnodes = document.getElementById("albumartists_td").getElementsByTagName("span"); + var albumartists = []; + for (let node of albumartistnodes) { + albumartists.push(node.textContent); + } + var title = document.getElementById("title").value; - scrobble(artists,title); + var album = document.getElementById("album").value; + scrobble(artists,title,albumartists,album); } -function scrobble(artists,title) { +function scrobble(artists,title,albumartists,album) { lastArtists = artists; lastTrack = title; + lastAlbum = album; + lastAlbumartists = albumartists; var payload = { "artists":artists, - "title":title + "title":title, + "albumartists": albumartists, + "album": album } @@ -74,12 +120,7 @@ function scrobble(artists,title) { neo.xhttpreq("/apis/mlj_1/newscrobble",data=payload,method="POST",callback=notifyCallback,json=true) } - document.getElementById("title").value = ""; - document.getElementById("artists").value = ""; - var artists = document.getElementById("artists_td").getElementsByTagName("span"); - while (artists.length > 0) { - removeArtist(); - } + clear() } function scrobbledone(req) { @@ -98,6 +139,10 @@ function repeatLast() { addArtist(artist); } document.getElementById("title").value = lastTrack; + for (let artist of lastAlbumartists) { + addAlbumartist(artist); + } + document.getElementById("album").value = lastAlbum; } From 0a4ac23dfa39d20f1d596adec189e988e5f505c0 Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 7 May 2023 22:49:50 +0200 Subject: [PATCH 064/176] Fixed manual scrobbling of compilation albums --- maloja/apis/native_v1.py | 4 ++-- maloja/web/jinja/admin_manual.jinja | 15 ++++++++++++++- maloja/web/static/js/manualscrobble.js | 21 ++++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index e7d2ff4..0975bf3 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -444,7 +444,7 @@ def post_scrobble( artists:list=[], title:str="", album:str=None, - albumartists:list=[], + albumartists:list=None, duration:int=None, length:int=None, time:int=None, @@ -478,7 +478,7 @@ def post_scrobble( } # for logging purposes, don't pass values that we didn't actually supply - rawscrobble = {k:rawscrobble[k] for k in rawscrobble if rawscrobble[k]} + rawscrobble = {k:rawscrobble[k] for k in rawscrobble if rawscrobble[k] is not None} # [] should be passed result = database.incoming_scrobble( diff --git a/maloja/web/jinja/admin_manual.jinja b/maloja/web/jinja/admin_manual.jinja index 3ed2ea5..fc3e778 100644 --- a/maloja/web/jinja/admin_manual.jinja +++ b/maloja/web/jinja/admin_manual.jinja @@ -4,6 +4,14 @@ {% block scripts %} + {% endblock %} @@ -45,10 +53,15 @@
+ + Use track artists as album artists fallback + +

+ -
+ diff --git a/maloja/web/static/js/manualscrobble.js b/maloja/web/static/js/manualscrobble.js index 6b7502a..e64f342 100644 --- a/maloja/web/static/js/manualscrobble.js +++ b/maloja/web/static/js/manualscrobble.js @@ -1,5 +1,7 @@ -var lastArtists = [] -var lastTrack = "" +var lastArtists = []; +var lastTrack = ""; +var lastAlbumartists = []; +var lastAlbum = ""; function addArtist(artist) { @@ -96,8 +98,17 @@ function scrobbleNew() { albumartists.push(node.textContent); } + if (albumartists.length == 0) { + var use_track_artists = document.getElementById('use_track_artists_for_album').checked; + if (use_track_artists) { + albumartists = null; + } + } + var title = document.getElementById("title").value; var album = document.getElementById("album").value; + + scrobble(artists,title,albumartists,album); } @@ -111,9 +122,13 @@ function scrobble(artists,title,albumartists,album) { var payload = { "artists":artists, "title":title, - "albumartists": albumartists, "album": album } + if (albumartists != null) { + payload['albumartists'] = albumartists + } + + console.log(payload); if (title != "" && artists.length > 0) { From 99c295b0e499e207333d8fc39af21101ba5b75cc Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 20 May 2023 21:28:05 +0200 Subject: [PATCH 065/176] Fixed handling of empty album name --- maloja/database/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index fcdb861..8969ea1 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -160,6 +160,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): # New plan, do this further down # NONE always means there is simply no info, so make a guess or whatever the options say + # could use the track artists, but probably check if any album with the same name exists first # various artists always needs to be specified via [] # TODO @@ -185,7 +186,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None): "rawscrobble":rawscrobble } - if scrobbledict["track"]["album"]["albumtitle"] is None: + if not scrobbledict["track"]["album"]["albumtitle"]: del scrobbledict["track"]["album"] return scrobbledict From 5d92857642855aabf125e40af2712803a5994e1c Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 21 May 2023 02:25:55 +0200 Subject: [PATCH 066/176] Added some space to container build output --- Containerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Containerfile b/Containerfile index 358efe2..55057d8 100644 --- a/Containerfile +++ b/Containerfile @@ -12,6 +12,7 @@ COPY --chown=abc:abc ./requirements.txt ./requirements.txt # it may be possible to decrease image size slightly by using build stage and # copying all site-packages to runtime stage but the image is already pretty small RUN \ + echo "" && \ echo "**** install build packages ****" && \ apk add --no-cache --virtual=build-deps \ gcc \ @@ -23,18 +24,22 @@ RUN \ libc-dev \ py3-pip \ linux-headers && \ + echo "" && \ echo "**** install runtime packages ****" && \ apk add --no-cache \ python3 \ py3-lxml \ tzdata && \ + echo "" && \ echo "**** install pip dependencies ****" && \ python3 -m ensurepip && \ pip3 install -U --no-cache-dir \ pip \ wheel && \ + echo "" && \ echo "**** install maloja requirements ****" && \ pip3 install --no-cache-dir -r requirements.txt && \ + echo "" && \ echo "**** cleanup ****" && \ apk del --purge \ build-deps && \ @@ -47,6 +52,7 @@ RUN \ COPY --chown=abc:abc . . RUN \ + echo "" && \ echo "**** install maloja ****" && \ apk add --no-cache --virtual=install-deps \ py3-pip && \ From 759e88db3e5316916834e8ba7737e46d321facaf Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 21 May 2023 02:40:15 +0200 Subject: [PATCH 067/176] Just to be sure --- .../config/rules/predefined/krateng_kpopgirlgroups.tsv | 3 +++ maloja/database/dbcache.py | 2 +- maloja/database/sqldb.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/maloja/data_files/config/rules/predefined/krateng_kpopgirlgroups.tsv b/maloja/data_files/config/rules/predefined/krateng_kpopgirlgroups.tsv index a0c10b4..e608c5a 100644 --- a/maloja/data_files/config/rules/predefined/krateng_kpopgirlgroups.tsv +++ b/maloja/data_files/config/rules/predefined/krateng_kpopgirlgroups.tsv @@ -208,6 +208,9 @@ countas Wonyoung IVE countas Yujin IVE countas Gaeul IVE +# Pristin +countas Pristin V Pristin + # Popular Remixes artistintitle Areia Remix Areia artistintitle Areia Kpop Areia diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index 42695dc..254d248 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -87,7 +87,7 @@ if malojaconfig['USE_GLOBAL_CACHE']: cleared, kept = 0, 0 for k in cache.keys(): # VERY BIG TODO: differentiate between None as in 'unlimited timerange' and None as in 'time doesnt matter here'! - if scrobbletime is None or (k[3] is None or scrobbletime >= k[3]) and (k[4] is None or scrobbletime <= k[4]): + if scrobbletime is None or ((k[3] is None or scrobbletime >= k[3]) and (k[4] is None or scrobbletime <= k[4])): cleared += 1 del cache[k] else: diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 5be84e6..4f908dc 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -436,13 +436,13 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): if not create_new: return None - print("Creating new track") + #print("Creating new track") op = DB['tracks'].insert().values( **track_dict_to_db(trackdict,dbconn=dbconn) ) result = dbconn.execute(op) track_id = result.inserted_primary_key[0] - print(track_id) + #print(track_id) for artist_id in artist_ids: op = DB['trackartists'].insert().values( From 03f3952d1ac65cdbf55be7bb69f850a0a5bf8314 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 24 May 2023 16:05:15 +0200 Subject: [PATCH 068/176] Turned tiles from table into grid --- maloja/web/jinja/icons/nodata.jinja | 4 +- .../jinja/partials/charts_albums_tiles.jinja | 54 +++---- .../jinja/partials/charts_artists_tiles.jinja | 54 +++---- .../jinja/partials/charts_tracks_tiles.jinja | 53 +++---- maloja/web/static/css/maloja.css | 140 +++++++++--------- 5 files changed, 130 insertions(+), 175 deletions(-) diff --git a/maloja/web/jinja/icons/nodata.jinja b/maloja/web/jinja/icons/nodata.jinja index dde0cbb..8ddb3d1 100644 --- a/maloja/web/jinja/icons/nodata.jinja +++ b/maloja/web/jinja/icons/nodata.jinja @@ -1,7 +1,7 @@ - +

No scrobbles yet! - +
diff --git a/maloja/web/jinja/partials/charts_albums_tiles.jinja b/maloja/web/jinja/partials/charts_albums_tiles.jinja index 400d68b..86f7ba2 100644 --- a/maloja/web/jinja/partials/charts_albums_tiles.jinja +++ b/maloja/web/jinja/partials/charts_albums_tiles.jinja @@ -6,40 +6,26 @@ {% 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 %} - +
+ {% if charts_14[0] is none %} + {% include 'icons/nodata.jinja' %} {% 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.albumtitle }} -
-
-
-
+ {% for entry in charts_14 %} + {% if entry is not none %} + {% set album = entry.album %} + {% set rank = entry.rank %} + + {% else %} +
+ {% endif %} + {% endfor %} + +
diff --git a/maloja/web/jinja/partials/charts_artists_tiles.jinja b/maloja/web/jinja/partials/charts_artists_tiles.jinja index 23ac18e..ef97ede 100644 --- a/maloja/web/jinja/partials/charts_artists_tiles.jinja +++ b/maloja/web/jinja/partials/charts_artists_tiles.jinja @@ -6,40 +6,26 @@ {% 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 %} - +
+ {% if charts_14[0] is none %} + {% include 'icons/nodata.jinja' %} {% 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 artist = entry.artist %} - {% set rank = entry.rank %} - - {% else -%} - - {%- endif -%} - {%- endfor -%} - - {%- endfor -%} -
- -
- #{{ rank }} {{ artist }} -
-
-
-
+ {% for entry in charts_14 %} + {% if entry is not none %} + {% set artist = entry.artist %} + {% set rank = entry.rank %} + + {% else %} +
+ {% endif %} + {% endfor %} + +
diff --git a/maloja/web/jinja/partials/charts_tracks_tiles.jinja b/maloja/web/jinja/partials/charts_tracks_tiles.jinja index ba66a10..6b238a6 100644 --- a/maloja/web/jinja/partials/charts_tracks_tiles.jinja +++ b/maloja/web/jinja/partials/charts_tracks_tiles.jinja @@ -6,39 +6,26 @@ {% 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 %} - +
+ {% if charts_14[0] is none %} + {% include 'icons/nodata.jinja' %} {% 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 track = entry.track %} - {% set rank = entry.rank %} - - {% else -%} - - {%- endif %} - {%- endfor -%} - - {%- endfor %} -
- -
- #{{ rank }} {{ track.title }} -
-
-
-
+ {% for entry in charts_14 %} + {% if entry is not none %} + {% set track = entry.track %} + {% set rank = entry.rank %} + + {% else %} +
+ {% endif %} + {% endfor %} + +
diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 239b44a..3c838db 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -800,53 +800,76 @@ table.misc td { +div.tiles { + display: grid; + grid-template-columns: repeat(18, 50px); + grid-template-rows: repeat(6, 50px); + grid-auto-flow: row dense; - - - -table.tiles_top td { - padding:0px; - border:0px; } -table.tiles_top:hover td td { +div.tiles div { + height: 100%; + width: 100%; + background-size: cover; + background-position:top center; + overflow: hidden; +} + +div.tiles:hover div { opacity:0.5; filter: grayscale(80%); } -table.tiles_top:hover td td:hover { +div.tiles:hover div:hover { opacity:1; filter: grayscale(0%); } -table.tiles_top, table.tiles_sub { - border-collapse: collapse; +div.tiles div.tile:nth-child(1) { + grid-column: span 6; + grid-row: span 6; + + font-size:100% +} + +div.tiles div.tile:nth-child(2), +div.tiles div.tile:nth-child(3), +div.tiles div.tile:nth-child(4), +div.tiles div.tile:nth-child(5) { + grid-column: span 3; + grid-row: span 3; + + font-size:80% +} + +div.tiles div.tile:nth-child(2), +div.tiles div.tile:nth-child(4) { + grid-column-start: 7; + grid-column-end: span 3; +} +div.tiles div.tile:nth-child(3), +div.tiles div.tile:nth-child(5) { + grid-column-start: 10; + grid-column-end: span 3; +} + +div.tiles div.tile:nth-child(6), +div.tiles div.tile:nth-child(7), +div.tiles div.tile:nth-child(8), +div.tiles div.tile:nth-child(9), +div.tiles div.tile:nth-child(10), +div.tiles div.tile:nth-child(11), +div.tiles div.tile:nth-child(12), +div.tiles div.tile:nth-child(13), +div.tiles div.tile:nth-child(14) { + grid-column: span 2; + grid-row: span 2; + + font-size:60% } - -table.tiles_top>tbody>tr>td { - height:300px; - width:300px; -} - - -table.tiles_sub { - height:100%; - width:100%; -} - -table.tiles_sub div { - height:100%; - width:100%; -} - -table.tiles_top td div { - background-size:cover; - background-position:top center; - vertical-align:bottom; -} - -table.tiles_top td span { +div.tiles span { background-color:rgba(0,0,0,0.7); display: inline-block; margin-top:2%; @@ -854,48 +877,21 @@ table.tiles_top td span { max-width: 67%; vertical-align: text-top; } -table.tiles_top td a:hover { +div.tiles span { + overflow-wrap: anywhere; +} + +div.tiles a:hover { text-decoration: none; } -table.tiles_1x1 td { - height:100%; - width:100%; - font-size:100% -} -table.tiles_2x2 td { - height:50%; - width:50%; - font-size:80% -} -table.tiles_3x3 td { - height:33.333%; - width:33.333%; - font-size:60% -} -table.tiles_4x4 td { - font-size:50% -} -table.tiles_5x5 td { - font-size:40% -} -/* Safari fix */ -table.tiles_sub.tiles_3x3 td div { - min-height: 100px; - min-width: 100px; -} -table.tiles_sub.tiles_2x2 td div { - min-height: 150px; - min-width: 150px; -} -table.tiles_sub.tiles_1x1 td div { - min-height: 300px; - min-width: 300px; -} -table.tiles_sub a span { - overflow-wrap: anywhere; -} + + + + + + div#showcase_container { From 75c0ab385bbf4c405a8e2481da501b88ac75c8aa Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 24 May 2023 16:26:35 +0200 Subject: [PATCH 069/176] Fixed track image fetch without album info --- maloja/images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/images.py b/maloja/images.py index e6ffec7..ab668dc 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -136,7 +136,7 @@ def resolve_track_image(track_id): if malojaconfig["USE_ALBUM_ARTWORK_FOR_TRACKS"]: track = database.sqldb.get_track(track_id) - if "album" in track: + if track.get("album"): album_id = database.sqldb.get_album_id(track["album"]) albumart = resolve_album_image(album_id) if albumart: From 6ff7aa2ee035eb81e61b85b28ba2f7cbb55130f5 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 24 May 2023 17:44:50 +0200 Subject: [PATCH 070/176] Experimental start page redesign --- maloja/pkg_global/conf.py | 3 +- maloja/web/jinja/partials/info_album.jinja | 45 +++++++ maloja/web/jinja/partials/info_artist.jinja | 59 +++++++++ maloja/web/jinja/partials/info_track.jinja | 48 +++++++ maloja/web/jinja/snippets/links.jinja | 2 +- maloja/web/jinja/start.jinja | 121 ++++-------------- .../startpage_modules/charts_albums.jinja | 19 +++ .../startpage_modules/charts_artists.jinja | 19 +++ .../startpage_modules/charts_tracks.jinja | 19 +++ .../jinja/startpage_modules/featured.jinja | 58 +++++++++ .../startpage_modules/lastscrobbles.jinja | 15 +++ .../web/jinja/startpage_modules/pulse.jinja | 17 +++ maloja/web/static/css/maloja.css | 38 +----- maloja/web/static/css/startpage.css | 49 +++++++ maloja/web/static/js/rangeselect.js | 21 +-- 15 files changed, 386 insertions(+), 147 deletions(-) create mode 100644 maloja/web/jinja/partials/info_album.jinja create mode 100644 maloja/web/jinja/partials/info_artist.jinja create mode 100644 maloja/web/jinja/partials/info_track.jinja create mode 100644 maloja/web/jinja/startpage_modules/charts_albums.jinja create mode 100644 maloja/web/jinja/startpage_modules/charts_artists.jinja create mode 100644 maloja/web/jinja/startpage_modules/charts_tracks.jinja create mode 100644 maloja/web/jinja/startpage_modules/featured.jinja create mode 100644 maloja/web/jinja/startpage_modules/lastscrobbles.jinja create mode 100644 maloja/web/jinja/startpage_modules/pulse.jinja create mode 100644 maloja/web/static/css/startpage.css diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index a9be47b..f8c61f4 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -189,8 +189,7 @@ malojaconfig = Configuration( "parse_remix_artists":(tp.Boolean(), "Parse Remix Artists", False) }, "Web Interface":{ - "default_range_charts_artists":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range Artist Charts", "year"), - "default_range_charts_tracks":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range Track Charts", "year"), + "default_range_startpage":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range for Startpage Stats", "year"), "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), "album_showcase":(tp.Boolean(), "Display Album Showcase", True, "Display a graphical album showcase for artist overview pages instead of a chart list"), diff --git a/maloja/web/jinja/partials/info_album.jinja b/maloja/web/jinja/partials/info_album.jinja new file mode 100644 index 0000000..95a9ca9 --- /dev/null +++ b/maloja/web/jinja/partials/info_album.jinja @@ -0,0 +1,45 @@ +{% import 'snippets/links.jinja' as links %} +{% import 'partials/awards_album.jinja' as awards %} + +{% set album = filterkeys.album %} +{% set info = dbc.album_info({'album':album}) %} + +{% set encodedalbum = mlj_uri.uriencode({'album':album}) %} + + + + + + + +
+ {% 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) }} + + +
diff --git a/maloja/web/jinja/partials/info_artist.jinja b/maloja/web/jinja/partials/info_artist.jinja new file mode 100644 index 0000000..f3308a9 --- /dev/null +++ b/maloja/web/jinja/partials/info_artist.jinja @@ -0,0 +1,59 @@ +{% import 'snippets/links.jinja' as links %} +{% import 'partials/awards_artist.jinja' as awards %} + + +{% set artist = filterkeys.artist %} +{% set info = db.artist_info(artist=artist) %} + +{% set credited = info.get('replace') %} +{% set included = info.get('associated') %} + +{% if credited is not none %} + {% set competes = false %} +{% else %} + {% set credited = artist %} + {% set competes = true %} +{% endif %} + + + + + + + +
+ {% if adminmode %} +
+ {% else %} +
+
+ {% endif %} +
+

{{ info.artist | e }}

+ {% if competes and info['scrobbles']>0 %}#{{ info.position }}{% endif %} +
+ {% if competes and included %} + associated: {{ links.links(included) }} + {% elif not competes %} + Competing under {{ links.link(credited) }} (#{{ info.position }}) + {% endif %} + +

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

+ + + + + {% if competes %} + {{ awards.medals(info) }} + {{ awards.topweeks(info) }} + {% endif %} + {{ awards.certs(artist) }} + + +
diff --git a/maloja/web/jinja/partials/info_track.jinja b/maloja/web/jinja/partials/info_track.jinja new file mode 100644 index 0000000..f46e342 --- /dev/null +++ b/maloja/web/jinja/partials/info_track.jinja @@ -0,0 +1,48 @@ +{% import 'snippets/links.jinja' as links %} + +{% set track = filterkeys.track %} +{% set info = dbc.track_info({'track':track}) %} + +{% import 'partials/awards_track.jinja' as awards %} + + + + + + + +
+ {% if adminmode %} +
+ {% else %} +
+
+ {% endif %} +
+ {{ links.links(track.artists) }}
+

{{ info.track.title | e }}

+ {{ awards.certs(track) }} + #{{ info.position }} +
+ {% if info.track.album %} + from {{ links.link(info.track.album) }}
+ {% endif %} + +

+ {% if adminmode %}{% endif %} + {{ info['scrobbles'] }} Scrobbles +

+ + + + + + {{ awards.medals(info) }} + {{ awards.topweeks(info) }} + + +
diff --git a/maloja/web/jinja/snippets/links.jinja b/maloja/web/jinja/snippets/links.jinja index 3b5c29d..f90a535 100644 --- a/maloja/web/jinja/snippets/links.jinja +++ b/maloja/web/jinja/snippets/links.jinja @@ -1,5 +1,5 @@ {% macro link(entity) -%} - {% if entity is mapping and 'title' in entity or 'albumtitle' in entity %} + {% if entity is mapping and ('title' in entity or 'albumtitle' in entity) %} {% set name = entity.title or entity.albumtitle %} {% else %} {% set name = entity %} diff --git a/maloja/web/jinja/start.jinja b/maloja/web/jinja/start.jinja index f8240da..eb22b37 100644 --- a/maloja/web/jinja/start.jinja +++ b/maloja/web/jinja/start.jinja @@ -3,109 +3,44 @@ {% block scripts %} - - + + + + {% endblock %} {% block content -%} - - -

Top Artists

- - {% for r in xcurrent -%} - - {{ r.localisation }} - - {{ "|" if not loop.last }} - {%- endfor %} - -

+
- {% for r in xcurrent -%} - - {%- endfor %} + {% for module in ['charts_artists','charts_tracks','charts_albums','pulse','lastscrobbles','featured'] %} +
+ + + {% include 'startpage_modules/' + module + '.jinja' %} + +
+ {% endfor %} +
- -

Top Tracks

- - {% for r in xcurrent -%} - - {{ r.localisation }} - - {{ "|" if not loop.last }} - {%- endfor %} - -

- - - {% for r in xcurrent -%} - - {%- endfor %} - - -
- - -

Last Scrobbles

- - {% for range in xcurrent %} - {{ range.localisation }} - {{ dbc.get_scrobbles_num({'timerange':range.range}) }} - {% endfor %} -

- - - - - {%- with amountkeys = {"perpage":12,"page":0}, shortTimeDesc=True -%} - {% include 'partials/scrobbles.jinja' %} - {%- endwith -%} - - - -
- - - - - -

Pulse

- - {% for range in xranges -%} - - {{ range.localisation }} - - {{ "|" if not loop.last }} - {%- endfor %} -

- - {% for range in xranges -%} - - {%- endfor %} - - -
{%- endblock %} diff --git a/maloja/web/jinja/startpage_modules/charts_albums.jinja b/maloja/web/jinja/startpage_modules/charts_albums.jinja new file mode 100644 index 0000000..671fe10 --- /dev/null +++ b/maloja/web/jinja/startpage_modules/charts_albums.jinja @@ -0,0 +1,19 @@ +

Top Albums

+ +{% for r in xcurrent -%} + + {{ r.localisation }} + + {{ "|" if not loop.last }} +{%- endfor %} + +

+ + +{% for r in xcurrent -%} + +{%- endfor %} diff --git a/maloja/web/jinja/startpage_modules/charts_artists.jinja b/maloja/web/jinja/startpage_modules/charts_artists.jinja new file mode 100644 index 0000000..0c78c89 --- /dev/null +++ b/maloja/web/jinja/startpage_modules/charts_artists.jinja @@ -0,0 +1,19 @@ +

Top Artists

+ +{% for r in xcurrent -%} + + {{ r.localisation }} + + {{ "|" if not loop.last }} +{%- endfor %} + +

+ + +{% for r in xcurrent -%} + +{%- endfor %} diff --git a/maloja/web/jinja/startpage_modules/charts_tracks.jinja b/maloja/web/jinja/startpage_modules/charts_tracks.jinja new file mode 100644 index 0000000..4ae6e4c --- /dev/null +++ b/maloja/web/jinja/startpage_modules/charts_tracks.jinja @@ -0,0 +1,19 @@ +

Top Tracks

+ +{% for r in xcurrent -%} + + {{ r.localisation }} + + {{ "|" if not loop.last }} +{%- endfor %} + +

+ + +{% for r in xcurrent -%} + +{%- endfor %} diff --git a/maloja/web/jinja/startpage_modules/featured.jinja b/maloja/web/jinja/startpage_modules/featured.jinja new file mode 100644 index 0000000..1c96ea2 --- /dev/null +++ b/maloja/web/jinja/startpage_modules/featured.jinja @@ -0,0 +1,58 @@ +

Featured

+ + +{% set entitytypes = [ + {'identifier':'artist','localisation':"Artist", 'template':"info_artist.jinja", 'filterkeys':{"artist":"Blackpink"} }, + {'identifier':'track','localisation':"Track", 'template':"info_track.jinja", 'filterkeys':{"track": {"artists": ["Red Velvet"], "title": "Russian Roulette"}} }, + {'identifier':'album','localisation':"Album", 'template':"info_album.jinja", 'filterkeys':{"album": {"artists": ["TWICE"], "albumtitle": "The Story Begins"}} } +] %} + + +{% for t in entitytypes -%} + + {{ t.localisation }} + + {{ "|" if not loop.last }} +{%- endfor %} + +


+ + +{% for t in entitytypes -%} + +{%- endfor %} + + + + + diff --git a/maloja/web/jinja/startpage_modules/lastscrobbles.jinja b/maloja/web/jinja/startpage_modules/lastscrobbles.jinja new file mode 100644 index 0000000..ae0da70 --- /dev/null +++ b/maloja/web/jinja/startpage_modules/lastscrobbles.jinja @@ -0,0 +1,15 @@ +

Last Scrobbles

+ +{% for range in xcurrent %} + {{ range.localisation }} + {{ dbc.get_scrobbles_num({'timerange':range.range}) }} +{% endfor %} +

+ + + + + {%- with amountkeys = {"perpage":12,"page":0}, shortTimeDesc=True -%} + {% include 'partials/scrobbles.jinja' %} + {%- endwith -%} + diff --git a/maloja/web/jinja/startpage_modules/pulse.jinja b/maloja/web/jinja/startpage_modules/pulse.jinja new file mode 100644 index 0000000..5efdc00 --- /dev/null +++ b/maloja/web/jinja/startpage_modules/pulse.jinja @@ -0,0 +1,17 @@ +

Pulse

+ +{% for range in xranges -%} + + {{ range.localisation }} + + {{ "|" if not loop.last }} +{%- endfor %} +

+ +{% for range in xranges -%} + +{%- endfor %} diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 3c838db..95c17c1 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -802,10 +802,12 @@ table.misc td { div.tiles { display: grid; - grid-template-columns: repeat(18, 50px); - grid-template-rows: repeat(6, 50px); + grid-template-columns: repeat(18, calc(100% / 18)); + grid-template-rows: repeat(6, calc(100% / 6)); grid-auto-flow: row dense; + aspect-ratio: 3 / 1; + } div.tiles div { @@ -982,35 +984,3 @@ span.stat_module_pulse, span.stat_module_topartists, span.stat_module_toptracks display: inline-block; vertical-align: top; } - -/* -** -** -** SIDE LIST ON START PAGE -** -** -*/ - - -@media (min-width: 1600px) { -div.sidelist { - position:absolute; - right:0px; - top:0px; - width:40%; - height:100%; - --current-bg-color: var(--base-color-light); /* drag this information through inheritances */ - background-color: var(--current-bg-color); - padding-left:30px; - padding-right:30px; -} -} - -div.sidelist table { - width:100%; - table-layout:fixed; -} - -div.sidelist table.list td.time { - width:17%; -} diff --git a/maloja/web/static/css/startpage.css b/maloja/web/static/css/startpage.css new file mode 100644 index 0000000..2d3fe9b --- /dev/null +++ b/maloja/web/static/css/startpage.css @@ -0,0 +1,49 @@ +div#startpage { + display: grid; + justify-content: center; + + display: fixed; + grid-column-gap: 25px; + grid-row-gap: 25px; +} + + +@media (min-width: 2201px) { + div#startpage { + grid-template-columns: 30vw 30vw 30vw; + grid-template-rows: 45vh 45vh; + + grid-template-areas: + "charts_artists charts_tracks charts_albums" + "lastscrobbles featured pulse"; + } +} + +@media (min-width: 1401px) and (max-width: 2200px) { + div#startpage { + grid-template-columns: 45vw 45vw; + grid-template-rows: 45vh 45vh 45vh; + + grid-template-areas: + "charts_artists lastscrobbles" + "charts_tracks pulse" + "charts_albums featured"; + } +} + +@media (max-width: 1400px) { + div#startpage { + grid-template-columns: 90vw; + + grid-template-areas: + "charts_artists" + "charts_tracks" + "charts_albums" + "lastscrobbles" + "pulse"; + } + + #start_page_module_featured { + display: none; + } +} diff --git a/maloja/web/static/js/rangeselect.js b/maloja/web/static/js/rangeselect.js index f42d789..2561a69 100644 --- a/maloja/web/static/js/rangeselect.js +++ b/maloja/web/static/js/rangeselect.js @@ -2,7 +2,7 @@ localStorage = window.localStorage; function showRange(identifier,unit) { // Make all modules disappear - modules = document.getElementsByClassName("stat_module_" + identifier); + var modules = document.getElementsByClassName("stat_module_" + identifier); for (var i=0;i Date: Fri, 11 Aug 2023 17:16:14 +0200 Subject: [PATCH 071/176] Added featured module to startpage --- maloja/database/__init__.py | 12 ++++++++++++ maloja/web/jinja/startpage_modules/featured.jinja | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 8969ea1..974b1cb 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -574,6 +574,18 @@ def album_info(dbconn=None,**keys): } + +### TODO: FIND COOL ALGORITHM TO SELECT FEATURED STUFF +@waitfordb +def get_featured(dbconn=None): + # temporary stand-in + result = { + "artist": get_charts_artists(timerange=alltime())[0]['artist'], + "album": get_charts_albums(timerange=alltime())[0]['album'], + "track": get_charts_tracks(timerange=alltime())[0]['track'] + } + return result + def get_predefined_rulesets(dbconn=None): validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/maloja/web/jinja/startpage_modules/featured.jinja b/maloja/web/jinja/startpage_modules/featured.jinja index 1c96ea2..bfc2bcb 100644 --- a/maloja/web/jinja/startpage_modules/featured.jinja +++ b/maloja/web/jinja/startpage_modules/featured.jinja @@ -1,10 +1,13 @@ + +

Featured

+{% set featured = dbc.get_featured() %} {% set entitytypes = [ - {'identifier':'artist','localisation':"Artist", 'template':"info_artist.jinja", 'filterkeys':{"artist":"Blackpink"} }, - {'identifier':'track','localisation':"Track", 'template':"info_track.jinja", 'filterkeys':{"track": {"artists": ["Red Velvet"], "title": "Russian Roulette"}} }, - {'identifier':'album','localisation':"Album", 'template':"info_album.jinja", 'filterkeys':{"album": {"artists": ["TWICE"], "albumtitle": "The Story Begins"}} } + {'identifier':'artist','localisation':"Artist", 'template':"info_artist.jinja", 'filterkeys':{"artist": featured.artist } }, + {'identifier':'track','localisation':"Track", 'template':"info_track.jinja", 'filterkeys':{"track": featured.track } }, + {'identifier':'album','localisation':"Album", 'template':"info_album.jinja", 'filterkeys':{"album": featured.album } } ] %} @@ -21,6 +24,7 @@ {% for t in entitytypes -%} From f189be23098a023da1a88fffaf08b32d5ad6684c Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 11 Aug 2023 17:37:32 +0200 Subject: [PATCH 072/176] Added icons for track association --- maloja/web/jinja/album.jinja | 1 + maloja/web/jinja/artist.jinja | 1 + maloja/web/jinja/icons/add_album.jinja | 6 ++++++ maloja/web/jinja/icons/add_album_confirm.jinja | 5 +++++ maloja/web/jinja/icons/add_artist.jinja | 5 +++++ maloja/web/jinja/icons/add_artist_confirm.jinja | 5 +++++ maloja/web/jinja/track.jinja | 2 ++ 7 files changed, 25 insertions(+) create mode 100644 maloja/web/jinja/icons/add_album.jinja create mode 100644 maloja/web/jinja/icons/add_album_confirm.jinja create mode 100644 maloja/web/jinja/icons/add_artist.jinja create mode 100644 maloja/web/jinja/icons/add_artist_confirm.jinja diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 6a16da3..b697b07 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -23,6 +23,7 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} + {% include 'icons/add_album_confirm.jinja' %} {% endif %} {% endblock %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 29d8879..ccde8a2 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -33,6 +33,7 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} + {% include 'icons/add_artist_confirm.jinja' %} {% endif %} {% endblock %} diff --git a/maloja/web/jinja/icons/add_album.jinja b/maloja/web/jinja/icons/add_album.jinja new file mode 100644 index 0000000..1a3d942 --- /dev/null +++ b/maloja/web/jinja/icons/add_album.jinja @@ -0,0 +1,6 @@ +
+ + + + +
diff --git a/maloja/web/jinja/icons/add_album_confirm.jinja b/maloja/web/jinja/icons/add_album_confirm.jinja new file mode 100644 index 0000000..2d50fb2 --- /dev/null +++ b/maloja/web/jinja/icons/add_album_confirm.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/icons/add_artist.jinja b/maloja/web/jinja/icons/add_artist.jinja new file mode 100644 index 0000000..a86e973 --- /dev/null +++ b/maloja/web/jinja/icons/add_artist.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/icons/add_artist_confirm.jinja b/maloja/web/jinja/icons/add_artist_confirm.jinja new file mode 100644 index 0000000..44bad86 --- /dev/null +++ b/maloja/web/jinja/icons/add_artist_confirm.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 3fbc909..cad536b 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -28,6 +28,8 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} + {% include 'icons/add_artist.jinja' %} + {% include 'icons/add_album.jinja' %} {% endif %} {% endblock %} From d645707ff1ccc3ff2d5f9126ddc41129aea6dcaa Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 11 Aug 2023 18:57:27 +0200 Subject: [PATCH 073/176] Implemented client logic for association, GH-227 --- maloja/web/jinja/album.jinja | 4 +- maloja/web/jinja/artist.jinja | 2 +- maloja/web/jinja/icons/add_album.jinja | 2 +- maloja/web/jinja/icons/add_artist.jinja | 2 +- .../web/jinja/icons/association_cancel.jinja | 7 + maloja/web/jinja/icons/association_mark.jinja | 5 + maloja/web/jinja/track.jinja | 4 +- maloja/web/static/js/edit.js | 128 ++++++++++++++++++ 8 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 maloja/web/jinja/icons/association_cancel.jinja create mode 100644 maloja/web/jinja/icons/association_mark.jinja diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index b697b07..3142c9c 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -23,7 +23,9 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} - {% include 'icons/add_album_confirm.jinja' %} + {% include 'icons/add_album.jinja' %} + {% include 'icons/association_mark.jinja' %} + {% include 'icons/association_cancel.jinja' %} {% endif %} {% endblock %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index ccde8a2..189005e 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -33,7 +33,7 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} - {% include 'icons/add_artist_confirm.jinja' %} + {% include 'icons/add_artist.jinja' %} {% endif %} {% endblock %} diff --git a/maloja/web/jinja/icons/add_album.jinja b/maloja/web/jinja/icons/add_album.jinja index 1a3d942..11d2d67 100644 --- a/maloja/web/jinja/icons/add_album.jinja +++ b/maloja/web/jinja/icons/add_album.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/add_artist.jinja b/maloja/web/jinja/icons/add_artist.jinja index a86e973..07a958f 100644 --- a/maloja/web/jinja/icons/add_artist.jinja +++ b/maloja/web/jinja/icons/add_artist.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/association_cancel.jinja b/maloja/web/jinja/icons/association_cancel.jinja new file mode 100644 index 0000000..a8d9615 --- /dev/null +++ b/maloja/web/jinja/icons/association_cancel.jinja @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/maloja/web/jinja/icons/association_mark.jinja b/maloja/web/jinja/icons/association_mark.jinja new file mode 100644 index 0000000..6b68dae --- /dev/null +++ b/maloja/web/jinja/icons/association_mark.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index cad536b..f1c084a 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -28,8 +28,8 @@ {% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_cancel.jinja' %} - {% include 'icons/add_artist.jinja' %} - {% include 'icons/add_album.jinja' %} + {% include 'icons/association_mark.jinja' %} + {% include 'icons/association_cancel.jinja' %} {% endif %} {% endblock %} diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index e25f9c9..c9369b4 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -190,6 +190,8 @@ function doneEditing() { // MERGING function showValidMergeIcons() { + + // merge const lcst = window.sessionStorage; var key = "marked_for_merge_" + entity_type; var current_stored = (lcst.getItem(key) || '').split(","); @@ -218,6 +220,74 @@ function showValidMergeIcons() { } } + // mark for association + if ((entity_type == 'track') || (entity_type == 'album')) { + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + entity_type; + var current_stored = (lcst.getItem(key) || '').split(","); + current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + + var associationmarkicon = document.getElementById('associationmarkicon'); + var associationcancelicon = document.getElementById('associationcancelicon'); + + associationmarkicon.classList.add('hide'); + associationcancelicon.classList.add('hide'); + + + if (current_stored.length == 0) { + associationmarkicon.classList.remove('hide'); + } + else { + associationcancelicon.classList.remove('hide'); + + if (current_stored.includes(entity_id)) { + + } + else { + associationmarkicon.classList.remove('hide'); + } + } + } + + + + // association confirm + if ((entity_type == 'artist') || (entity_type == 'album')) { + var target_entity_types = {artist:['album','track'], album:['track']}; + var to_associate = {}; + var to_associate_all = []; + for (var target_entity_type of target_entity_types[entity_type]) { + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + target_entity_type; + var current_stored = (lcst.getItem(key) || '').split(","); + to_associate[target_entity_type] = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + to_associate_all = to_associate_all.concat(to_associate[target_entity_type]); + } + + var associateicon = document.getElementById('associate' + entity_type + 'icon'); + + associateicon.classList.add('hide'); + + + if (to_associate_all.length == 0) { + + } + else { + associateicon.classList.remove('hide'); + if (entity_type == 'artist') { + associateicon.title = "Add this artist to " + to_associate['album'].length + " albums and " + to_associate['track'].length + " tracks"; + } + else { + associateicon.title = "Add " + to_associate['track'].length + " tracks to this album"; + } + + } + } + + + + + } @@ -233,6 +303,19 @@ function markForMerge() { showValidMergeIcons(); } +function markForAssociate() { + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + entity_type; + var current_stored = (lcst.getItem(key) || '').split(","); + current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + current_stored.push(entity_id); + current_stored = [...new Set(current_stored)]; + lcst.setItem(key,current_stored); //this already formats it correctly + var whattoadd = ((entity_type == 'track') ? "Artists or Album" : "Artists") + notify("Marked " + entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") + showValidMergeIcons(); +} + function merge() { const lcst = window.sessionStorage; var key = "marked_for_merge_" + entity_type; @@ -262,6 +345,44 @@ function merge() { lcst.removeItem(key); } + + +function associate() { + const lcst = window.sessionStorage; + var target_entity_types = {artist:['album','track'], album:['track']}; + for (var target_entity_type of target_entity_types[entity_type]) { + var key = "marked_for_associate_" + target_entity_type; + console.log('get',key); + var current_stored = (lcst.getItem(key) || '').split(","); + current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + + callback_func = function(req){ + if (req.status == 200) { + //window.location.reload(); + showValidMergeIcons(); + notifyCallback(req); + } + else { + notifyCallback(req); + } + }; + + neo.xhttpreq( + "/apis/mlj_1/associate_" + target_entity_type + "s_to_" + entity_type, + data={ + 'source_ids':current_stored, + 'target_id':entity_id + }, + method="POST", + callback=callback_func, + json=true + ); + + lcst.removeItem(key); + } + +} + function cancelMerge() { const lcst = window.sessionStorage; var key = "marked_for_merge_" + entity_type; @@ -269,3 +390,10 @@ function cancelMerge() { showValidMergeIcons(); notify("Cancelled merge!","") } +function cancelAssociate() { + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + entity_type; + lcst.setItem(key,[]); + showValidMergeIcons(); + notify("Cancelled association!","") +} From 0032d0b70a1d68f471bae3989f618dff77d3ea0a Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 11 Aug 2023 19:46:27 +0200 Subject: [PATCH 074/176] Implemented server logic for association, fix GH-227 --- maloja/apis/native_v1.py | 34 +++++++++++++++++++++ maloja/database/__init__.py | 37 +++++++++++++++++++++++ maloja/database/sqldb.py | 35 ++++++++++++++++++++- maloja/web/static/css/maloja.css | 2 +- maloja/web/static/js/edit.js | 52 +++++++++++++++++++------------- 5 files changed, 137 insertions(+), 23 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 0975bf3..31e0bc9 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -767,6 +767,40 @@ def merge_artists(target_id,source_ids): "status":"success" } +@api.post("associate_albums_to_artist") +@authenticated_function(api=True) +@catch_exceptions +def associate_albums_to_artist(target_id,source_ids): + result = database.associate_albums_to_artist(target_id,source_ids) + if result: + return { + "status":"success", + "desc":f"{result['target']} was added as album artist of {', '.join(src['albumtitle'] for src in result['sources'])}" + } + +@api.post("associate_tracks_to_artist") +@authenticated_function(api=True) +@catch_exceptions +def associate_tracks_to_artist(target_id,source_ids): + result = database.associate_tracks_to_artist(target_id,source_ids) + if result: + return { + "status":"success", + "desc":f"{result['target']} was added as artist for {', '.join(src['title'] for src in result['sources'])}" + } + +@api.post("associate_tracks_to_album") +@authenticated_function(api=True) +@catch_exceptions +def associate_tracks_to_album(target_id,source_ids): + result = database.associate_tracks_to_album(target_id,source_ids) + if result: + return { + "status":"success", + "desc":f"{', '.join(src['title'] for src in result['sources'])} were added to {result['target']['albumtitle']}" + } + + @api.post("reparse_scrobble") @authenticated_function(api=True) @catch_exceptions diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 974b1cb..aa57c4e 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -265,6 +265,43 @@ def merge_albums(target_id,source_ids): +@waitfordb +def associate_albums_to_artist(target_id,source_ids): + sources = [sqldb.get_album(id) for id in source_ids] + target = sqldb.get_artist(target_id) + log(f"Adding {sources} into {target}") + sqldb.add_artists_to_albums(artist_ids=[target_id],album_ids=source_ids) + result = {'sources':sources,'target':target} + dbcache.invalidate_entity_cache() + dbcache.invalidate_caches() + + return result + +@waitfordb +def associate_tracks_to_artist(target_id,source_ids): + sources = [sqldb.get_track(id) for id in source_ids] + target = sqldb.get_artist(target_id) + log(f"Adding {sources} into {target}") + sqldb.add_artists_to_tracks(artist_ids=[target_id],track_ids=source_ids) + result = {'sources':sources,'target':target} + dbcache.invalidate_entity_cache() + dbcache.invalidate_caches() + + return result + +@waitfordb +def associate_tracks_to_album(target_id,source_ids): + sources = [sqldb.get_track(id) for id in source_ids] + target = sqldb.get_album(target_id) + log(f"Adding {sources} into {target}") + sqldb.add_tracks_to_albums({src:target_id for src in source_ids}) + result = {'sources':sources,'target':target} + dbcache.invalidate_entity_cache() + dbcache.invalidate_caches() + + return result + + @waitfordb def get_scrobbles(dbconn=None,**keys): diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 5be84e6..ca98479 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -555,7 +555,7 @@ def edit_scrobble(scrobble_id,scrobbleupdatedict,dbconn=None): dbconn.execute(op) - +# edit function only for primary db information (not linked fields) @connection_provider def edit_artist(id,artistupdatedict,dbconn=None): @@ -578,6 +578,7 @@ def edit_artist(id,artistupdatedict,dbconn=None): return True +# edit function only for primary db information (not linked fields) @connection_provider def edit_track(id,trackupdatedict,dbconn=None): @@ -600,6 +601,7 @@ def edit_track(id,trackupdatedict,dbconn=None): return True +# edit function only for primary db information (not linked fields) @connection_provider def edit_album(id,albumupdatedict,dbconn=None): @@ -623,6 +625,37 @@ def edit_album(id,albumupdatedict,dbconn=None): return True +### Edit associations + +@connection_provider +def add_artists_to_tracks(track_ids,artist_ids,dbconn=None): + + op = DB['trackartists'].insert().values([ + {'track_id':track_id,'artist_id':artist_id} + for track_id in track_ids for artist_id in artist_ids + ]) + + result = dbconn.execute(op) + clean_db(dbconn=dbconn) + + return True + + +@connection_provider +def add_artists_to_albums(album_ids,artist_ids,dbconn=None): + + op = DB['albumartists'].insert().values([ + {'album_id':album_id,'artist_id':artist_id} + for album_id in album_ids for artist_id in artist_ids + ]) + + result = dbconn.execute(op) + clean_db(dbconn=dbconn) + + return True + + + ### Merge @connection_provider diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 95c17c1..f46bc1e 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -209,7 +209,7 @@ div#notification_area { div#notification_area div.notification { background-color:white; width:400px; - height:50px; + min-height:50px; margin-bottom:7px; padding:9px; opacity:0.4; diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index c9369b4..501ae7b 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -247,6 +247,13 @@ function showValidMergeIcons() { associationmarkicon.classList.remove('hide'); } } + + if (entity_type == 'track') { + associationmarkicon.title = "Mark this track to add to album or add artist"; + } + else { + associationmarkicon.title = "Mark this album to add artist"; + } } @@ -356,29 +363,32 @@ function associate() { var current_stored = (lcst.getItem(key) || '').split(","); current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - callback_func = function(req){ - if (req.status == 200) { - //window.location.reload(); - showValidMergeIcons(); - notifyCallback(req); - } - else { - notifyCallback(req); - } - }; + if (current_stored.length != 0) { + callback_func = function(req){ + if (req.status == 200) { + //window.location.reload(); + showValidMergeIcons(); + notifyCallback(req); + } + else { + notifyCallback(req); + } + }; - neo.xhttpreq( - "/apis/mlj_1/associate_" + target_entity_type + "s_to_" + entity_type, - data={ - 'source_ids':current_stored, - 'target_id':entity_id - }, - method="POST", - callback=callback_func, - json=true - ); + neo.xhttpreq( + "/apis/mlj_1/associate_" + target_entity_type + "s_to_" + entity_type, + data={ + 'source_ids':current_stored, + 'target_id':entity_id + }, + method="POST", + callback=callback_func, + json=true + ); + + lcst.removeItem(key); + } - lcst.removeItem(key); } } From 174f096a0521d5dd19bc7dd103c8b24faca92475 Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 11 Aug 2023 20:23:47 +0200 Subject: [PATCH 075/176] Added time selection for manual scrobbling, fix GH-221 --- maloja/web/jinja/admin_manual.jinja | 15 +++++++++++++++ maloja/web/static/js/manualscrobble.js | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/maloja/web/jinja/admin_manual.jinja b/maloja/web/jinja/admin_manual.jinja index fc3e778..2820ac7 100644 --- a/maloja/web/jinja/admin_manual.jinja +++ b/maloja/web/jinja/admin_manual.jinja @@ -48,9 +48,24 @@ + + + + Custom Time: + + + + + + +
diff --git a/maloja/web/static/js/manualscrobble.js b/maloja/web/static/js/manualscrobble.js index e64f342..bc276c1 100644 --- a/maloja/web/static/js/manualscrobble.js +++ b/maloja/web/static/js/manualscrobble.js @@ -108,11 +108,20 @@ function scrobbleNew() { var title = document.getElementById("title").value; var album = document.getElementById("album").value; + if (document.getElementById("use_custom_time").checked) { + var date = new Date(document.getElementById("scrobble_datetime").value + ':00Z'); + var timestamp = (date.getTime() + (date.getTimezoneOffset() * 60000)) / 1000; + } + else { + var timestamp = null; + } - scrobble(artists,title,albumartists,album); + + + scrobble(artists,title,albumartists,album,timestamp); } -function scrobble(artists,title,albumartists,album) { +function scrobble(artists,title,albumartists,album,timestamp) { lastArtists = artists; lastTrack = title; @@ -125,7 +134,10 @@ function scrobble(artists,title,albumartists,album) { "album": album } if (albumartists != null) { - payload['albumartists'] = albumartists + payload['albumartists'] = albumartists; + } + if (timestamp != null) { + payload['time'] = timestamp; } console.log(payload); From 748a7ce31826bc8c152f364d0ca2253f97761acf Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 11 Aug 2023 20:44:07 +0200 Subject: [PATCH 076/176] Prevented artist creation from looking up nonexistent track, fix GH-219 --- maloja/database/sqldb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 4f908dc..acbfba9 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -404,7 +404,7 @@ def add_tracks_to_albums(track_to_album_id_dict,replace=False,dbconn=None): @connection_provider 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 = [get_artist_id(a,create_new=create_new,dbconn=dbconn) for a in trackdict['artists']] artist_ids = list(set(artist_ids)) From 4a87500cd5bc326af4bba9d3c7ad42bdbc4520bd Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 12 Aug 2023 18:56:47 +0200 Subject: [PATCH 077/176] Unified naming for stat selection UIs --- dev/releases/3.2.yml | 5 +- maloja/web/jinja/abstracts/base.jinja | 10 +++ maloja/web/jinja/album.jinja | 14 ++-- maloja/web/jinja/artist.jinja | 14 ++-- maloja/web/jinja/start.jinja | 18 +----- .../startpage_modules/charts_albums.jinja | 2 +- .../startpage_modules/charts_artists.jinja | 2 +- .../startpage_modules/charts_tracks.jinja | 2 +- .../jinja/startpage_modules/featured.jinja | 2 +- .../web/jinja/startpage_modules/pulse.jinja | 2 +- maloja/web/jinja/track.jinja | 14 ++-- maloja/web/static/js/cookies.js | 64 ------------------- .../js/{rangeselect.js => statselect.js} | 25 ++++++-- 13 files changed, 62 insertions(+), 112 deletions(-) delete mode 100644 maloja/web/static/js/cookies.js rename maloja/web/static/js/{rangeselect.js => statselect.js} (75%) diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index 4a6442c..f6d53d1 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -1,9 +1,12 @@ -minor_release_name: "Momo" +minor_release_name: "Nicole" 3.2.0: notes: - "[Architecture] Switched to linuxserver.io container base image" - "[Feature] Added basic support for albums" + - "[Feature] New start page" + - "[Feature] Added UI for track-artist, track-album and album-artist association" - "[Performance] Improved image rendering" - "[Bugfix] Fixed configuration of time format" - "[Bugfix] Fixed search on manual scrobble page" - "[Bugfix] Disabled DB maintenance while not running main server" + - "[Bugfix] Removed some nonsensical ephemereal database entry creations" diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index 8b3e8a6..59268d5 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -23,6 +23,16 @@ + diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index c74520d..6c579f4 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -4,7 +4,7 @@ {% import 'snippets/links.jinja' as links %} {% block scripts %} - + {% endblock %} @@ -97,8 +97,8 @@
{% for r in xranges %} {{ r.localisation }} @@ -110,7 +110,7 @@ {% for r in xranges %} @@ -127,8 +127,8 @@
{% for r in xranges %} {{ r.localisation }} @@ -140,7 +140,7 @@ {% for r in xranges %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 87597de..149d1c0 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -5,7 +5,7 @@ {% import 'partials/awards_artist.jinja' as awards %} {% block scripts %} - + {% endblock %} @@ -128,8 +128,8 @@
{% for r in xranges %} {{ r.localisation }} @@ -141,7 +141,7 @@ {% for r in xranges %} @@ -161,8 +161,8 @@ {% for r in xranges %} {{ r.localisation }} @@ -174,7 +174,7 @@ {% for r in xranges %} diff --git a/maloja/web/jinja/start.jinja b/maloja/web/jinja/start.jinja index eb22b37..b683a1d 100644 --- a/maloja/web/jinja/start.jinja +++ b/maloja/web/jinja/start.jinja @@ -3,24 +3,8 @@ {% block scripts %} - - - - } -}) - - - {% endblock %} diff --git a/maloja/web/jinja/startpage_modules/charts_albums.jinja b/maloja/web/jinja/startpage_modules/charts_albums.jinja index 671fe10..15f57f5 100644 --- a/maloja/web/jinja/startpage_modules/charts_albums.jinja +++ b/maloja/web/jinja/startpage_modules/charts_albums.jinja @@ -1,7 +1,7 @@

Top Albums

{% for r in xcurrent -%} - + {{ r.localisation }} {{ "|" if not loop.last }} diff --git a/maloja/web/jinja/startpage_modules/charts_artists.jinja b/maloja/web/jinja/startpage_modules/charts_artists.jinja index 0c78c89..9a239c2 100644 --- a/maloja/web/jinja/startpage_modules/charts_artists.jinja +++ b/maloja/web/jinja/startpage_modules/charts_artists.jinja @@ -1,7 +1,7 @@

Top Artists

{% for r in xcurrent -%} - + {{ r.localisation }} {{ "|" if not loop.last }} diff --git a/maloja/web/jinja/startpage_modules/charts_tracks.jinja b/maloja/web/jinja/startpage_modules/charts_tracks.jinja index 4ae6e4c..44bb80c 100644 --- a/maloja/web/jinja/startpage_modules/charts_tracks.jinja +++ b/maloja/web/jinja/startpage_modules/charts_tracks.jinja @@ -1,7 +1,7 @@

Top Tracks

{% for r in xcurrent -%} - + {{ r.localisation }} {{ "|" if not loop.last }} diff --git a/maloja/web/jinja/startpage_modules/featured.jinja b/maloja/web/jinja/startpage_modules/featured.jinja index bfc2bcb..0cf34a8 100644 --- a/maloja/web/jinja/startpage_modules/featured.jinja +++ b/maloja/web/jinja/startpage_modules/featured.jinja @@ -12,7 +12,7 @@ {% for t in entitytypes -%} - + {{ t.localisation }} {{ "|" if not loop.last }} diff --git a/maloja/web/jinja/startpage_modules/pulse.jinja b/maloja/web/jinja/startpage_modules/pulse.jinja index 5efdc00..282cc9f 100644 --- a/maloja/web/jinja/startpage_modules/pulse.jinja +++ b/maloja/web/jinja/startpage_modules/pulse.jinja @@ -1,7 +1,7 @@

Pulse

{% for range in xranges -%} - + {{ range.localisation }} {{ "|" if not loop.last }} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index f426fd3..0402fce 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -4,7 +4,7 @@ {% import 'snippets/links.jinja' as links %} {% block scripts %} - + {% for e in list %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + {{ entityrow.row(e['track']) }} + {% with inlineicons = True %} + + {% endwith %} + {% endif %} {% endfor %}
+ {% include 'icons/association_mark.jinja' %} + {% include 'icons/association_cancel.jinja' %} +
+ + diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index bd25e80..9d1ac30 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -83,7 +83,26 @@ div.clickable_icon.danger:hover svg { fill: red; } +#icon_bar svg { + width:24px; + height:24px; +} +.list svg { + height:16px; +} +.list div.clickable_icon { + display: inline-block; +} +.list tr.marked { + background-color: rgba(50,20,0,0.5); +} +.list tr.marked #associationmarkicon { + display:none; +} +.list tr:not(.marked) #associationcancelicon { + display:none; +} /** Footer diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index 2859708..2fb6d9c 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -310,17 +310,82 @@ function markForMerge() { showValidMergeIcons(); } -function markForAssociate() { +function markForAssociate(element) { + console.log(element); + const parentElement = element.closest('[data-entity_id]'); + console.log(parentElement); + // use local element for entity data, otherwise use from global scope (on entity info page) + var l_entity_type = parentElement ? parentElement.dataset.entity_type : entity_type; + var l_entity_id = parentElement ? parentElement.dataset.entity_id : entity_id; + var l_entity_name = parentElement ? parentElement.dataset.entity_name : entity_name; + l_entity_id = parseInt(l_entity_id); + + + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + l_entity_type; + var current_stored = (lcst.getItem(key) || '').split(","); + current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + current_stored.push(l_entity_id); + current_stored = [...new Set(current_stored)]; + lcst.setItem(key,current_stored); //this already formats it correctly + var whattoadd = ((l_entity_type == 'track') ? "Artists or Album" : "Artists") + notify("Marked " + l_entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") + if (!parentElement) { + showValidMergeIcons(); + } + else { + toggleAssociationIcons(parentElement); + } + +} + +function umarkForAssociate(element) { + const parentElement = element.closest('[data-entity_id]'); + // use local element for entity data, otherwise use from global scope (on entity info page) + var l_entity_type = parentElement ? parentElement.dataset.entity_type : entity_type; + var l_entity_id = parentElement ? parentElement.dataset.entity_id : entity_id; + var l_entity_name = parentElement ? parentElement.dataset.entity_name : entity_name; + l_entity_id = parseInt(l_entity_id); + + const lcst = window.sessionStorage; + var key = "marked_for_associate_" + l_entity_type; + var current_stored = (lcst.getItem(key) || '').split(","); + current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + + if (current_stored.indexOf(l_entity_id) > -1) { + current_stored.splice(current_stored.indexOf(l_entity_id),1); + current_stored = [...new Set(current_stored)]; + lcst.setItem(key,current_stored); //this already formats it correctly + var whattoadd = ((l_entity_type == 'track') ? "Artists or Album" : "Artists") + notify("Unmarked " + l_entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") + if (!parentElement) { + showValidMergeIcons(); + } + else { + toggleAssociationIcons(parentElement); + } + } + else { + notify(entity_name + " was not marked!","") + } + +} + +function toggleAssociationIcons(element) { + var entity_type = element.dataset.entity_type; + var entity_id = element.dataset.entity_id; + entity_id = parseInt(entity_id); + const lcst = window.sessionStorage; var key = "marked_for_associate_" + entity_type; var current_stored = (lcst.getItem(key) || '').split(","); current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - current_stored.push(entity_id); - current_stored = [...new Set(current_stored)]; - lcst.setItem(key,current_stored); //this already formats it correctly - var whattoadd = ((entity_type == 'track') ? "Artists or Album" : "Artists") - notify("Marked " + entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") - showValidMergeIcons(); + + if (current_stored.indexOf(entity_id) > -1) { + element.classList.add('marked'); + } else { + element.classList.remove('marked'); + } } function merge() { From d8420cdb6754b265d6c55deafbceafe3a01b61d0 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 17 Oct 2023 19:36:59 +0200 Subject: [PATCH 099/176] Reworked web UI editing --- maloja/web/jinja/abstracts/base.jinja | 1 + maloja/web/jinja/album.jinja | 11 +- maloja/web/jinja/artist.jinja | 11 +- maloja/web/jinja/icons/add_album.jinja | 2 +- maloja/web/jinja/icons/add_artist.jinja | 2 +- .../web/jinja/icons/association_cancel.jinja | 7 +- maloja/web/jinja/icons/association_mark.jinja | 2 +- .../web/jinja/icons/association_unmark.jinja | 7 + maloja/web/jinja/icons/merge.jinja | 2 +- maloja/web/jinja/icons/merge_cancel.jinja | 7 +- maloja/web/jinja/icons/merge_mark.jinja | 2 +- maloja/web/jinja/icons/merge_unmark.jinja | 5 + maloja/web/jinja/partials/charts_albums.jinja | 5 +- .../web/jinja/partials/charts_artists.jinja | 5 +- maloja/web/jinja/partials/charts_tracks.jinja | 5 +- maloja/web/jinja/partials/list_tracks.jinja | 17 +- maloja/web/jinja/partials/scrobbles.jinja | 3 - maloja/web/jinja/snippets/entityrow.jinja | 9 +- maloja/web/jinja/track.jinja | 10 +- maloja/web/static/css/maloja.css | 77 +++- maloja/web/static/js/edit.js | 358 +++++++++--------- 21 files changed, 318 insertions(+), 230 deletions(-) create mode 100644 maloja/web/jinja/icons/association_unmark.jinja create mode 100644 maloja/web/jinja/icons/merge_unmark.jinja diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index 59268d5..91806e6 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -23,6 +23,7 @@ + - {% endblock %} {% set album = filterkeys.album %} @@ -20,13 +19,21 @@ {% block icon_bar %} {% if adminmode %} {% include 'icons/edit.jinja' %} + +
{% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} + {% include 'icons/merge_unmark.jinja' %} {% include 'icons/merge_cancel.jinja' %} +
+ +
{% include 'icons/add_album.jinja' %} {% include 'icons/association_mark.jinja' %} + {% include 'icons/association_unmark.jinja' %} {% include 'icons/association_cancel.jinja' %} - +
+ {% endif %} {% endblock %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 149d1c0..168994f 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -6,7 +6,6 @@ {% block scripts %} - {% endblock %} {% set artist = filterkeys.artist %} @@ -30,11 +29,19 @@ {% block icon_bar %} {% if adminmode %} {% include 'icons/edit.jinja' %} + +
{% include 'icons/merge.jinja' %} {% include 'icons/merge_mark.jinja' %} + {% include 'icons/merge_unmark.jinja' %} {% include 'icons/merge_cancel.jinja' %} +
+ +
{% include 'icons/add_artist.jinja' %} - + {% include 'icons/association_cancel.jinja' %} +
+ {% endif %} {% endblock %} diff --git a/maloja/web/jinja/icons/add_album.jinja b/maloja/web/jinja/icons/add_album.jinja index 11d2d67..928fb8f 100644 --- a/maloja/web/jinja/icons/add_album.jinja +++ b/maloja/web/jinja/icons/add_album.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/add_artist.jinja b/maloja/web/jinja/icons/add_artist.jinja index 07a958f..9ce9ff7 100644 --- a/maloja/web/jinja/icons/add_artist.jinja +++ b/maloja/web/jinja/icons/add_artist.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/association_cancel.jinja b/maloja/web/jinja/icons/association_cancel.jinja index 9729378..24a8b7b 100644 --- a/maloja/web/jinja/icons/association_cancel.jinja +++ b/maloja/web/jinja/icons/association_cancel.jinja @@ -1,7 +1,6 @@ -
+
- - - + +
diff --git a/maloja/web/jinja/icons/association_mark.jinja b/maloja/web/jinja/icons/association_mark.jinja index 7e27ca5..563d2c5 100644 --- a/maloja/web/jinja/icons/association_mark.jinja +++ b/maloja/web/jinja/icons/association_mark.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/association_unmark.jinja b/maloja/web/jinja/icons/association_unmark.jinja new file mode 100644 index 0000000..341ce08 --- /dev/null +++ b/maloja/web/jinja/icons/association_unmark.jinja @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/maloja/web/jinja/icons/merge.jinja b/maloja/web/jinja/icons/merge.jinja index dfe2dd9..97a8846 100644 --- a/maloja/web/jinja/icons/merge.jinja +++ b/maloja/web/jinja/icons/merge.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/merge_cancel.jinja b/maloja/web/jinja/icons/merge_cancel.jinja index 64c1d57..6462906 100644 --- a/maloja/web/jinja/icons/merge_cancel.jinja +++ b/maloja/web/jinja/icons/merge_cancel.jinja @@ -1,5 +1,6 @@ -
- - +
+ + +
diff --git a/maloja/web/jinja/icons/merge_mark.jinja b/maloja/web/jinja/icons/merge_mark.jinja index 8623dbc..c8298d9 100644 --- a/maloja/web/jinja/icons/merge_mark.jinja +++ b/maloja/web/jinja/icons/merge_mark.jinja @@ -1,4 +1,4 @@ -
+
diff --git a/maloja/web/jinja/icons/merge_unmark.jinja b/maloja/web/jinja/icons/merge_unmark.jinja new file mode 100644 index 0000000..244fa31 --- /dev/null +++ b/maloja/web/jinja/icons/merge_unmark.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/partials/charts_albums.jinja b/maloja/web/jinja/partials/charts_albums.jinja index a779e62..7b2caf2 100644 --- a/maloja/web/jinja/partials/charts_albums.jinja +++ b/maloja/web/jinja/partials/charts_albums.jinja @@ -28,11 +28,12 @@ {% 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 %} - + @@ -45,7 +46,7 @@ {% endif %} - {{ entityrow.row(e['album']) }} + {{ entityrow.row(e['album'],adminmode=True) }} diff --git a/maloja/web/jinja/partials/charts_artists.jinja b/maloja/web/jinja/partials/charts_artists.jinja index f3496fb..19413f9 100644 --- a/maloja/web/jinja/partials/charts_artists.jinja +++ b/maloja/web/jinja/partials/charts_artists.jinja @@ -30,12 +30,13 @@ {% set lastindex = firstindex + amountkeys.perpage %} + {% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %}
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %} {{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}
{% for e in charts %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + @@ -48,7 +49,7 @@ {% endif %} - {{ entityrow.row(e['artist']) }} + {{ entityrow.row(e['artist'],adminmode=True) }} diff --git a/maloja/web/jinja/partials/charts_tracks.jinja b/maloja/web/jinja/partials/charts_tracks.jinja index f9f3d70..5bd2c09 100644 --- a/maloja/web/jinja/partials/charts_tracks.jinja +++ b/maloja/web/jinja/partials/charts_tracks.jinja @@ -28,11 +28,12 @@ {% set firstindex = amountkeys.page * amountkeys.perpage %} {% set lastindex = firstindex + amountkeys.perpage %} + {% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %}
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %} {{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}
{% for e in charts %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + @@ -45,7 +46,7 @@ {% endif %} - {{ entityrow.row(e['track']) }} + {{ entityrow.row(e['track'],adminmode=True) }} diff --git a/maloja/web/jinja/partials/list_tracks.jinja b/maloja/web/jinja/partials/list_tracks.jinja index 0150bc8..2ac3ef1 100644 --- a/maloja/web/jinja/partials/list_tracks.jinja +++ b/maloja/web/jinja/partials/list_tracks.jinja @@ -6,22 +6,14 @@ {% set firstindex = amountkeys.page * amountkeys.perpage %} {% set lastindex = firstindex + amountkeys.perpage %} -
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %} {{ links.link_scrobbles([{'track':e.track,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}
{% for e in list %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + - {{ entityrow.row(e['track']) }} - - {% with inlineicons = True %} - - {% endwith %} + {{ entityrow.row(e['track'],adminmode=adminmode) }} {% endif %} @@ -30,9 +22,6 @@ diff --git a/maloja/web/jinja/partials/scrobbles.jinja b/maloja/web/jinja/partials/scrobbles.jinja index dc487c3..32d839b 100644 --- a/maloja/web/jinja/partials/scrobbles.jinja +++ b/maloja/web/jinja/partials/scrobbles.jinja @@ -6,9 +6,6 @@ {% import 'snippets/entityrow.jinja' as entityrow %} - - -
- {% include 'icons/association_mark.jinja' %} - {% include 'icons/association_cancel.jinja' %} -
{% for s in scrobbles -%} {%- if loop.index0 >= firstindex and loop.index0 < lastindex -%} diff --git a/maloja/web/jinja/snippets/entityrow.jinja b/maloja/web/jinja/snippets/entityrow.jinja index 2721f0c..843fe20 100644 --- a/maloja/web/jinja/snippets/entityrow.jinja +++ b/maloja/web/jinja/snippets/entityrow.jinja @@ -1,4 +1,4 @@ -{% macro row(entity,counting=[]) %} +{% macro row(entity,counting=[],adminmode=False) %} {% import 'snippets/links.jinja' as links %} @@ -35,4 +35,11 @@ {% endif %} +{% if adminmode and (entity is mapping) %} + +{% endif %} + {% endmacro %} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 0402fce..f446c71 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -5,7 +5,6 @@ {% block scripts %} - + {% endif %} {% endblock %} diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 9d1ac30..6d40ad4 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -67,6 +67,10 @@ div#icon_bar { right:30px; top:30px; } +.iconsubset { + display: inline-block; + padding-left:20px; +} div#icon_bar div.clickable_icon { display: inline-block; @@ -94,14 +98,75 @@ div.clickable_icon.danger:hover svg { display: inline-block; } -.list tr.marked { - background-color: rgba(50,20,0,0.5); +.list { + --color_bg_merge: rgba(0,0,90,0.7); + --color_fg_merge: lightblue; + --color_bg_associate: rgba(50,20,0,0.7); + --color_fg_associate: orange; } -.list tr.marked #associationmarkicon { - display:none; + +.list tr.marked_for_associate { + background-color: var(--color_bg_associate); + color: var(--color_fg_associate); } -.list tr:not(.marked) #associationcancelicon { - display:none; +.list tr.marked_for_merge { + background-color: var(--color_bg_merge); + color: var(--color_fg_merge);; +} + +@keyframes slideBackground { + 0% { + background-position: 100% 0; + } + 50% { + background-position: 0 0; + } + 100% { + background-position: 100% 0; + } +} +@keyframes colorChange { + 0% { + color: var(--color_fg_associate); + } + 50% { + color: var(--color_fg_merge); + } + 100% { + color: var(--color_fg_associate); + } +} + +.list tr.marked_for_associate.marked_for_merge { + background: linear-gradient(to left, var(--color_bg_associate), var(--color_bg_merge)); + background-size: 100% 100%; + animation: colorChange 4s infinite ease-in-out; +} + + +/* this is just to 'factor out' that big selector down there. +we want icons to not be displayed in list rows, but show them with reduced opacity in the top bar */ +.list { + --display_inactive_icons: none; +} +#icon_bar { + --display_inactive_icons: inline-block; +} + +.associateicons.marked_for_associate .associationmarkicon, /* already marked, cant mark again */ +.associateicons:not(.marked_for_associate) .associationunmarkicon, /* not marked, cant unmark */ +.associateicons:not(.somethingmarked_for_associate) .associatecancelicon, /* cant cancel when nothing is marked */ +.mergeicons.marked_for_merge #mergemarkicon, /* already marked, cant mark again */ +.mergeicons:not(.marked_for_merge) #mergeunmarkicon, /* not marked, cant unmark */ +.mergeicons:not(.somethingmarked_for_merge) #mergecancelicon, /* cant cancel when nothing is marked */ +.mergeicons:not(.somethingmarked_for_merge) #mergeicon, /* can't merge when nothing is selected */ +.mergeicons.marked_for_merge #mergeicon, /* cant merge into one of the things we have selected */ +.associateicons:not(.sources_marked_for_associate) #associatealbumicon, +.associateicons:not(.sources_marked_for_associate) #associateartisticon /* nothing marked yet, can't associate with this */ + { + pointer-events: none; + opacity:0.5; + display: var(--display_inactive_icons); } /** diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index 2fb6d9c..6caf52b 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -187,186 +187,117 @@ function doneEditing() { } } -// MERGING +// MERGING AND ASSOCIATION -function showValidMergeIcons() { +const associate_targets = { + album: ['artist'], + track: ['album','artist'], + artist: [] +}; - // merge +const associate_sources = { + artist: ['album','track'], + album: ['track'], + track: [] +}; + + +function getStoredList(key) { const lcst = window.sessionStorage; - var key = "marked_for_merge_" + entity_type; var current_stored = (lcst.getItem(key) || '').split(","); current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - - var mergeicon = document.getElementById('mergeicon'); - var mergemarkicon = document.getElementById('mergemarkicon'); - var mergecancelicon = document.getElementById('mergecancelicon'); - - mergeicon.classList.add('hide'); - mergemarkicon.classList.add('hide'); - mergecancelicon.classList.add('hide'); - - if (current_stored.length == 0) { - mergemarkicon.classList.remove('hide'); - } - else { - mergecancelicon.classList.remove('hide'); - - if (current_stored.includes(entity_id)) { - - } - else { - mergemarkicon.classList.remove('hide'); - mergeicon.classList.remove('hide'); - } - } - - // mark for association - if ((entity_type == 'track') || (entity_type == 'album')) { - const lcst = window.sessionStorage; - var key = "marked_for_associate_" + entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - - var associationmarkicon = document.getElementById('associationmarkicon'); - var associationcancelicon = document.getElementById('associationcancelicon'); - - associationmarkicon.classList.add('hide'); - associationcancelicon.classList.add('hide'); - - - if (current_stored.length == 0) { - associationmarkicon.classList.remove('hide'); - } - else { - associationcancelicon.classList.remove('hide'); - - if (current_stored.includes(entity_id)) { - - } - else { - associationmarkicon.classList.remove('hide'); - } - } - - if (entity_type == 'track') { - associationmarkicon.title = "Mark this track to add to album or add artist"; - } - else { - associationmarkicon.title = "Mark this album to add artist"; - } - } - - - - // association confirm - if ((entity_type == 'artist') || (entity_type == 'album')) { - var target_entity_types = {artist:['album','track'], album:['track']}; - var to_associate = {}; - var to_associate_all = []; - for (var target_entity_type of target_entity_types[entity_type]) { - const lcst = window.sessionStorage; - var key = "marked_for_associate_" + target_entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - to_associate[target_entity_type] = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - to_associate_all = to_associate_all.concat(to_associate[target_entity_type]); - } - - var associateicon = document.getElementById('associate' + entity_type + 'icon'); - - associateicon.classList.add('hide'); - - - if (to_associate_all.length == 0) { - - } - else { - associateicon.classList.remove('hide'); - if (entity_type == 'artist') { - associateicon.title = "Add this artist to " + to_associate['album'].length + " albums and " + to_associate['track'].length + " tracks"; - } - else { - associateicon.title = "Add " + to_associate['track'].length + " tracks to this album"; - } - - } - } - - - - - + return current_stored; +} +function storeList(key,list) { + const lcst = window.sessionStorage; + list = [...new Set(list)]; + lcst.setItem(key,list); //this already formats it correctly } -function markForMerge() { - const lcst = window.sessionStorage; - var key = "marked_for_merge_" + entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + +function markForMerge(element) { + const parentElement = element.closest('[data-entity_id]'); + + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + var entity_name = parentElement.dataset.entity_name; + entity_id = parseInt(entity_id); + + key = "marked_for_merge_" + entity_type; + var current_stored = getStoredList(key); current_stored.push(entity_id); - current_stored = [...new Set(current_stored)]; - lcst.setItem(key,current_stored); //this already formats it correctly + storeList(key,current_stored) + notify("Marked " + entity_name + " for merge","Currently " + current_stored.length + " marked!") - showValidMergeIcons(); + + toggleMergeIcons(parentElement); +} + +function unmarkForMerge(element) { + const parentElement = element.closest('[data-entity_id]'); + + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + var entity_name = parentElement.dataset.entity_name; + entity_id = parseInt(entity_id); + + var key = "marked_for_merge_" + entity_type; + var current_stored = getStoredList(key); + + if (current_stored.indexOf(entity_id) > -1) { + current_stored.splice(current_stored.indexOf(entity_id),1); + storeList(key,current_stored); + notify("Unmarked " + entity_name + " from merge","Currently " + current_stored.length + " marked!") + + toggleMergeIcons(parentElement); + } + else { + //notify(entity_name + " was not marked!","") + } } function markForAssociate(element) { - console.log(element); const parentElement = element.closest('[data-entity_id]'); - console.log(parentElement); - // use local element for entity data, otherwise use from global scope (on entity info page) - var l_entity_type = parentElement ? parentElement.dataset.entity_type : entity_type; - var l_entity_id = parentElement ? parentElement.dataset.entity_id : entity_id; - var l_entity_name = parentElement ? parentElement.dataset.entity_name : entity_name; - l_entity_id = parseInt(l_entity_id); + + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + var entity_name = parentElement.dataset.entity_name; + entity_id = parseInt(entity_id); - const lcst = window.sessionStorage; - var key = "marked_for_associate_" + l_entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); - current_stored.push(l_entity_id); - current_stored = [...new Set(current_stored)]; - lcst.setItem(key,current_stored); //this already formats it correctly - var whattoadd = ((l_entity_type == 'track') ? "Artists or Album" : "Artists") - notify("Marked " + l_entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") - if (!parentElement) { - showValidMergeIcons(); - } - else { - toggleAssociationIcons(parentElement); - } + var key = "marked_for_associate_" + entity_type; + var current_stored = getStoredList(key); + current_stored.push(entity_id); + storeList(key,current_stored); + + notify("Marked " + entity_name + " to add to " + associate_targets[entity_type].join(" or "),"Currently " + current_stored.length + " marked!") + + toggleAssociationIcons(parentElement); } function umarkForAssociate(element) { const parentElement = element.closest('[data-entity_id]'); - // use local element for entity data, otherwise use from global scope (on entity info page) - var l_entity_type = parentElement ? parentElement.dataset.entity_type : entity_type; - var l_entity_id = parentElement ? parentElement.dataset.entity_id : entity_id; - var l_entity_name = parentElement ? parentElement.dataset.entity_name : entity_name; - l_entity_id = parseInt(l_entity_id); - const lcst = window.sessionStorage; - var key = "marked_for_associate_" + l_entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + var entity_name = parentElement.dataset.entity_name; + entity_id = parseInt(entity_id); - if (current_stored.indexOf(l_entity_id) > -1) { - current_stored.splice(current_stored.indexOf(l_entity_id),1); - current_stored = [...new Set(current_stored)]; - lcst.setItem(key,current_stored); //this already formats it correctly - var whattoadd = ((l_entity_type == 'track') ? "Artists or Album" : "Artists") - notify("Unmarked " + l_entity_name + " to add " + whattoadd,"Currently " + current_stored.length + " marked!") - if (!parentElement) { - showValidMergeIcons(); - } - else { - toggleAssociationIcons(parentElement); - } + var key = "marked_for_associate_" + entity_type; + var current_stored = getStoredList(key); + + if (current_stored.indexOf(entity_id) > -1) { + current_stored.splice(current_stored.indexOf(entity_id),1); + storeList(key,current_stored); + + notify("Unmarked " + entity_name + " from association with " + associate_targets[entity_type].join(" or "),"Currently " + current_stored.length + " marked!") + + toggleAssociationIcons(parentElement); } else { - notify(entity_name + " was not marked!","") + //notify(entity_name + " was not marked!","") } } @@ -376,23 +307,78 @@ function toggleAssociationIcons(element) { var entity_id = element.dataset.entity_id; entity_id = parseInt(entity_id); - const lcst = window.sessionStorage; var key = "marked_for_associate_" + entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + var current_stored = getStoredList(key); if (current_stored.indexOf(entity_id) > -1) { - element.classList.add('marked'); + element.classList.add('marked_for_associate'); } else { - element.classList.remove('marked'); + element.classList.remove('marked_for_associate'); + } + + if (current_stored.length > 0) { + element.classList.add('somethingmarked_for_associate'); + } + else { + element.classList.remove('somethingmarked_for_associate'); + } + + var sourcetypes = associate_sources[entity_type]; + var sourcelist = []; + for (var src of sourcetypes) { + var key = "marked_for_associate_" + src; + sourcelist = sourcelist.concat(getStoredList(key)); + } + if (sourcelist.length > 0) { + element.classList.add('sources_marked_for_associate'); + } + else { + element.classList.remove('sources_marked_for_associate'); + } + +} + +function toggleMergeIcons(element) { + var entity_type = element.dataset.entity_type; + var entity_id = element.dataset.entity_id; + entity_id = parseInt(entity_id); + + var key = "marked_for_merge_" + entity_type; + var current_stored = getStoredList(key); + + if (current_stored.indexOf(entity_id) > -1) { + element.classList.add('marked_for_merge'); + } else { + element.classList.remove('marked_for_merge'); + } + + + if (current_stored.length > 0) { + element.classList.add('somethingmarked_for_merge'); + } + else { + element.classList.remove('somethingmarked_for_merge'); } } +document.addEventListener('DOMContentLoaded',function(){ + var listrows = document.getElementsByClassName('listrow'); + for (var row of listrows) { + toggleAssociationIcons(row); + toggleMergeIcons(row); //just for the coloring, no icons + } + var topbars = document.getElementsByClassName('iconsubset'); + for (var bar of topbars) { + toggleAssociationIcons(bar); + toggleMergeIcons(bar); + } +}) + + + function merge() { - const lcst = window.sessionStorage; var key = "marked_for_merge_" + entity_type; - var current_stored = lcst.getItem(key).split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + var current_stored = getStoredList(key); callback_func = function(req){ if (req.status == 200) { @@ -415,26 +401,28 @@ function merge() { json=true ); - lcst.removeItem(key); + storeList(key,[]); } -function associate() { - const lcst = window.sessionStorage; - var target_entity_types = {artist:['album','track'], album:['track']}; +function associate(element) { + const parentElement = element.closest('[data-entity_id]'); + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + entity_id = parseInt(entity_id); + var requests_todo = 0; - for (var target_entity_type of target_entity_types[entity_type]) { + for (var target_entity_type of associate_sources[entity_type]) { var key = "marked_for_associate_" + target_entity_type; - var current_stored = (lcst.getItem(key) || '').split(","); - current_stored = current_stored.filter((x)=>x).map((x)=>parseInt(x)); + var current_stored = getStoredList(key); if (current_stored.length != 0) { requests_todo += 1; callback_func = function(req){ if (req.status == 200) { - showValidMergeIcons(); + toggleAssociationIcons(parentElement); notifyCallback(req); requests_todo -= 1; if (requests_todo == 0) { @@ -458,24 +446,30 @@ function associate() { json=true ); - lcst.removeItem(key); + storeList(key,[]); } } } -function cancelMerge() { - const lcst = window.sessionStorage; +function cancelMerge(element) { + const parentElement = element.closest('[data-entity_id]'); + + var entity_type = parentElement.dataset.entity_type; + var key = "marked_for_merge_" + entity_type; - lcst.setItem(key,[]); - showValidMergeIcons(); - notify("Cancelled merge!","") + storeList(key,[]) + toggleMergeIcons(parentElement); + notify("Cancelled " + entity_type + " merge!","") } -function cancelAssociate() { - const lcst = window.sessionStorage; +function cancelAssociate(element) { + const parentElement = element.closest('[data-entity_id]'); + + var entity_type = parentElement.dataset.entity_type; + var key = "marked_for_associate_" + entity_type; - lcst.setItem(key,[]); - showValidMergeIcons(); - notify("Cancelled association!","") + storeList(key,[]) + toggleAssociationIcons(parentElement); + notify("Cancelled " + entity_type + " association!","") } From 920e7efd7fdaf83367c86959b7d68872b2efb64b Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 17 Oct 2023 22:09:39 +0200 Subject: [PATCH 100/176] Inline edit functionality now also for merging --- maloja/web/jinja/partials/charts_albums.jinja | 2 +- maloja/web/jinja/partials/charts_artists.jinja | 2 +- maloja/web/jinja/partials/charts_tracks.jinja | 2 +- maloja/web/jinja/snippets/entityrow.jinja | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/maloja/web/jinja/partials/charts_albums.jinja b/maloja/web/jinja/partials/charts_albums.jinja index 7b2caf2..f5cd7e6 100644 --- a/maloja/web/jinja/partials/charts_albums.jinja +++ b/maloja/web/jinja/partials/charts_albums.jinja @@ -33,7 +33,7 @@
+{% include 'icons/association_mark.jinja' %} +{% include 'icons/association_unmark.jinja' %} +
{% for e in charts %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + diff --git a/maloja/web/jinja/partials/charts_artists.jinja b/maloja/web/jinja/partials/charts_artists.jinja index 19413f9..6f52fa4 100644 --- a/maloja/web/jinja/partials/charts_artists.jinja +++ b/maloja/web/jinja/partials/charts_artists.jinja @@ -36,7 +36,7 @@
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}
{% for e in charts %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + diff --git a/maloja/web/jinja/partials/charts_tracks.jinja b/maloja/web/jinja/partials/charts_tracks.jinja index 5bd2c09..293a2eb 100644 --- a/maloja/web/jinja/partials/charts_tracks.jinja +++ b/maloja/web/jinja/partials/charts_tracks.jinja @@ -33,7 +33,7 @@
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}
{% for e in charts %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + diff --git a/maloja/web/jinja/snippets/entityrow.jinja b/maloja/web/jinja/snippets/entityrow.jinja index 843fe20..bb6e2b1 100644 --- a/maloja/web/jinja/snippets/entityrow.jinja +++ b/maloja/web/jinja/snippets/entityrow.jinja @@ -37,6 +37,8 @@ {% if adminmode and (entity is mapping) %} From 2f654b56126049b29abdeeac265436cfc7b49db0 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 17 Oct 2023 22:20:55 +0200 Subject: [PATCH 101/176] Fixed inline icons displaying without admin mode --- maloja/web/jinja/partials/charts_albums.jinja | 2 +- maloja/web/jinja/partials/charts_artists.jinja | 2 +- maloja/web/jinja/partials/charts_tracks.jinja | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/maloja/web/jinja/partials/charts_albums.jinja b/maloja/web/jinja/partials/charts_albums.jinja index f5cd7e6..346250e 100644 --- a/maloja/web/jinja/partials/charts_albums.jinja +++ b/maloja/web/jinja/partials/charts_albums.jinja @@ -46,7 +46,7 @@ {% endif %} - {{ entityrow.row(e['album'],adminmode=True) }} + {{ entityrow.row(e['album'],adminmode=adminmode) }} diff --git a/maloja/web/jinja/partials/charts_artists.jinja b/maloja/web/jinja/partials/charts_artists.jinja index 6f52fa4..3e8044b 100644 --- a/maloja/web/jinja/partials/charts_artists.jinja +++ b/maloja/web/jinja/partials/charts_artists.jinja @@ -49,7 +49,7 @@ {% endif %} - {{ entityrow.row(e['artist'],adminmode=True) }} + {{ entityrow.row(e['artist'],adminmode=adminmode) }} diff --git a/maloja/web/jinja/partials/charts_tracks.jinja b/maloja/web/jinja/partials/charts_tracks.jinja index 293a2eb..7c18a5f 100644 --- a/maloja/web/jinja/partials/charts_tracks.jinja +++ b/maloja/web/jinja/partials/charts_tracks.jinja @@ -46,7 +46,7 @@ {% endif %} - {{ entityrow.row(e['track'],adminmode=True) }} + {{ entityrow.row(e['track'],adminmode=adminmode) }} From acf74020959b7619303f96c41e0aa94182481bfd Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 09:27:30 +0200 Subject: [PATCH 102/176] More fixes for inline editing --- maloja/web/jinja/partials/list_tracks.jinja | 2 +- maloja/web/jinja/snippets/entityrow.jinja | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/maloja/web/jinja/partials/list_tracks.jinja b/maloja/web/jinja/partials/list_tracks.jinja index 2ac3ef1..27353ce 100644 --- a/maloja/web/jinja/partials/list_tracks.jinja +++ b/maloja/web/jinja/partials/list_tracks.jinja @@ -10,7 +10,7 @@
{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %} +{% include 'icons/merge_mark.jinja' %} +{% include 'icons/merge_unmark.jinja' %} {% include 'icons/association_mark.jinja' %} {% include 'icons/association_unmark.jinja' %} {{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }} {{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }} {{ links.link_scrobbles([{'track':e.track,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}
{% for e in list %} {% if loop.index0 >= firstindex and loop.index0 < lastindex %} - + {{ entityrow.row(e['track'],adminmode=adminmode) }} diff --git a/maloja/web/jinja/snippets/entityrow.jinja b/maloja/web/jinja/snippets/entityrow.jinja index bb6e2b1..e7a1074 100644 --- a/maloja/web/jinja/snippets/entityrow.jinja +++ b/maloja/web/jinja/snippets/entityrow.jinja @@ -35,12 +35,15 @@ {% endif %} -{% if adminmode and (entity is mapping) %} + +{% if adminmode %} {% endif %} From b9e3cd76245dd8f4e4fb85af7140984bfd3d9340 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 10:58:46 +0200 Subject: [PATCH 103/176] Added UI selector for including associated artists --- dev/releases/3.2.yml | 2 + maloja/database/__init__.py | 22 ++++--- maloja/database/sqldb.py | 58 +++++++++++++------ maloja/jinjaenv/context.py | 4 ++ maloja/malojauri.py | 9 ++- .../jinja/snippets/filterdescription.jinja | 2 +- maloja/web/jinja/snippets/timeselection.jinja | 14 +++++ 7 files changed, 82 insertions(+), 29 deletions(-) diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index 46926d8..f416adc 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -6,6 +6,8 @@ minor_release_name: "Nicole" - "[Feature] Added basic support for albums" - "[Feature] New start page" - "[Feature] Added UI for track-artist, track-album and album-artist association" + - "[Feature] Added inline UI for association and merging in chart lists" + - "[Feature] Added UI selector for including associated artists" - "[Performance] Improved image rendering" - "[Bugfix] Fixed configuration of time format" - "[Bugfix] Fixed search on manual scrobble page" diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index b2cd5c0..f878373 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -309,8 +309,9 @@ def associate_tracks_to_album(target_id,source_ids): @waitfordb def get_scrobbles(dbconn=None,**keys): (since,to) = keys.get('timerange').timestamps() + associated = keys.get('associated',False) if 'artist' in keys: - result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,dbconn=dbconn) + result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,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: @@ -324,8 +325,9 @@ def get_scrobbles(dbconn=None,**keys): @waitfordb def get_scrobbles_num(dbconn=None,**keys): (since,to) = keys.get('timerange').timestamps() + associated = keys.get('associated',False) if 'artist' in keys: - result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,resolve_references=False,dbconn=dbconn)) + result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,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: @@ -370,14 +372,15 @@ def get_tracks_without_album(dbconn=None,resolve_ids=True): @waitfordb def get_charts_artists(dbconn=None,resolve_ids=True,**keys): (since,to) = keys.get('timerange').timestamps() - result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn) + associated = keys.get('associated',True) + result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=associated,dbconn=dbconn) return result @waitfordb def get_charts_tracks(dbconn=None,resolve_ids=True,**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'],resolve_ids=resolve_ids,dbconn=dbconn) + result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],associated=keys.get('associated',False),resolve_ids=resolve_ids,dbconn=dbconn) elif 'album' in keys: result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],resolve_ids=resolve_ids,dbconn=dbconn) else: @@ -388,7 +391,7 @@ def get_charts_tracks(dbconn=None,resolve_ids=True,**keys): def get_charts_albums(dbconn=None,resolve_ids=True,**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'],resolve_ids=resolve_ids,dbconn=dbconn) + result = sqldb.count_scrobbles_by_album_of_artist(since=since,to=to,artist=keys['artist'],associated=keys.get('associated',False),resolve_ids=resolve_ids,dbconn=dbconn) else: result = sqldb.count_scrobbles_by_album(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn) return result @@ -629,15 +632,16 @@ def get_featured(dbconn=None): alltime() ] funcs = { - "artist": get_charts_artists, - "album": get_charts_albums, - "track": get_charts_tracks + "artist": (get_charts_artists,{'associated':False}), + "album": (get_charts_albums,{}), + "track": (get_charts_tracks,{}) } result = {t:None for t in funcs} for entity_type in funcs: for r in ranges: - chart = funcs[entity_type](timerange=r) + func,kwargs = funcs[entity_type] + chart = func(timerange=r,**kwargs) if chart: result[entity_type] = chart[0][entity_type] break diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 508fc4c..c22ca39 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -763,19 +763,23 @@ def merge_albums(target_id,source_ids,dbconn=None): @cached_wrapper @connection_provider -def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,dbconn=None): +def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,associated=False,dbconn=None): if since is None: since=0 if to is None: to=now() - artist_id = get_artist_id(artist,dbconn=dbconn) + if associated: + artist_ids = get_associated_artists(artist,resolve_ids=False,dbconn=dbconn) + [get_artist_id(artist,dbconn=dbconn)] + else: + artist_ids = [get_artist_id(artist,dbconn=dbconn)] + jointable = sql.join(DB['scrobbles'],DB['trackartists'],DB['scrobbles'].c.track_id == DB['trackartists'].c.track_id) op = jointable.select().where( DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, - DB['trackartists'].c.artist_id==artist_id + DB['trackartists'].c.artist_id.in_(artist_ids) ).order_by(sql.asc('timestamp')) result = dbconn.execute(op).all() @@ -911,7 +915,7 @@ def get_tracks(dbconn=None): @cached_wrapper @connection_provider -def count_scrobbles_by_artist(since,to,resolve_ids=True,dbconn=None): +def count_scrobbles_by_artist(since,to,associated=True,resolve_ids=True,dbconn=None): jointable = sql.join( DB['scrobbles'], DB['trackartists'], @@ -924,18 +928,24 @@ def count_scrobbles_by_artist(since,to,resolve_ids=True,dbconn=None): DB['trackartists'].c.artist_id == DB['associated_artists'].c.source_artist, isouter=True ) + + if associated: + artistselect = sql.func.coalesce(DB['associated_artists'].c.target_artist,DB['trackartists'].c.artist_id) + else: + artistselect = DB['trackartists'].c.artist_id + op = sql.select( sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'), # only count distinct scrobbles - because of artist replacement, we could end up # with two artists of the same scrobble counting it twice for the same artist # e.g. Irene and Seulgi adding two scrobbles to Red Velvet for one real scrobble - sql.func.coalesce(DB['associated_artists'].c.target_artist,DB['trackartists'].c.artist_id).label('artist_id') + artistselect.label('artist_id') # use the replaced artist as artist to count if it exists, otherwise original one ).select_from(jointable2).where( DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since ).group_by( - sql.func.coalesce(DB['associated_artists'].c.target_artist,DB['trackartists'].c.artist_id) + artistselect ).order_by(sql.desc('count')) result = dbconn.execute(op).all() @@ -1001,9 +1011,12 @@ def count_scrobbles_by_album(since,to,resolve_ids=True,dbconn=None): @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): +def count_scrobbles_by_album_of_artist(since,to,artist,associated=False,resolve_ids=True,dbconn=None): - artist_id = get_artist_id(artist,dbconn=dbconn) + if associated: + artist_ids = get_associated_artists(artist,resolve_ids=False,dbconn=dbconn) + [get_artist_id(artist,dbconn=dbconn)] + else: + artist_ids = [get_artist_id(artist,dbconn=dbconn)] jointable = sql.join( DB['scrobbles'], @@ -1022,7 +1035,7 @@ def count_scrobbles_by_album_of_artist(since,to,artist,resolve_ids=True,dbconn=N ).select_from(jointable2).where( DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, - DB['albumartists'].c.artist_id == artist_id + DB['albumartists'].c.artist_id.in_(artist_ids) ).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count')) result = dbconn.execute(op).all() @@ -1038,9 +1051,12 @@ def count_scrobbles_by_album_of_artist(since,to,artist,resolve_ids=True,dbconn=N @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): +def count_scrobbles_of_artist_by_album(since,to,artist,associated=False,resolve_ids=True,dbconn=None): - artist_id = get_artist_id(artist,dbconn=dbconn) + if associated: + artist_ids = get_associated_artists(artist,resolve_ids=False,dbconn=dbconn) + [get_artist_id(artist,dbconn=dbconn)] + else: + artist_ids = [get_artist_id(artist,dbconn=dbconn)] jointable = sql.join( DB['scrobbles'], @@ -1059,7 +1075,7 @@ def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=N ).select_from(jointable2).where( DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, - DB['trackartists'].c.artist_id == artist_id + DB['trackartists'].c.artist_id.in_(artist_ids) ).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count')) result = dbconn.execute(op).all() @@ -1074,9 +1090,12 @@ def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=N @cached_wrapper @connection_provider -def count_scrobbles_by_track_of_artist(since,to,artist,resolve_ids=True,dbconn=None): +def count_scrobbles_by_track_of_artist(since,to,artist,associated=False,resolve_ids=True,dbconn=None): - artist_id = get_artist_id(artist,dbconn=dbconn) + if associated: + artist_ids = get_associated_artists(artist,resolve_ids=False,dbconn=dbconn) + [get_artist_id(artist,dbconn=dbconn)] + else: + artist_ids = [get_artist_id(artist,dbconn=dbconn)] jointable = sql.join( DB['scrobbles'], @@ -1090,7 +1109,7 @@ def count_scrobbles_by_track_of_artist(since,to,artist,resolve_ids=True,dbconn=N ).select_from(jointable).filter( DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, - DB['trackartists'].c.artist_id==artist_id + DB['trackartists'].c.artist_id.in_(artist_ids) ).group_by(DB['scrobbles'].c.track_id).order_by(sql.desc('count')) result = dbconn.execute(op).all() @@ -1301,7 +1320,7 @@ def get_albums_map(album_ids,dbconn=None): @cached_wrapper @connection_provider -def get_associated_artists(*artists,dbconn=None): +def get_associated_artists(*artists,resolve_ids=True,dbconn=None): artist_ids = [get_artist_id(a,dbconn=dbconn) for a in artists] jointable = sql.join( @@ -1319,8 +1338,11 @@ def get_associated_artists(*artists,dbconn=None): ) result = dbconn.execute(op).all() - artists = artists_db_to_dict(result,dbconn=dbconn) - return artists + if resolve_ids: + artists = artists_db_to_dict(result,dbconn=dbconn) + return artists + else: + return [a.id for a in result] @cached_wrapper @connection_provider diff --git a/maloja/jinjaenv/context.py b/maloja/jinjaenv/context.py index 908634a..25aa039 100644 --- a/maloja/jinjaenv/context.py +++ b/maloja/jinjaenv/context.py @@ -72,6 +72,10 @@ def update_jinja_environment(): {"identifier":"longtrailing","replacekeys":{"trail":3},"localisation":"Long Trailing"}, {"identifier":"inert","replacekeys":{"trail":10},"localisation":"Inert","heavy":True}, {"identifier":"cumulative","replacekeys":{"trail":math.inf},"localisation":"Cumulative","heavy":True} + ], + "xassociated": [ + {"identifier":"include_associated","replacekeys":{"associated":True},"localisation":"Associated"}, + {"identifier":"exclude_associated","replacekeys":{"associated":False},"localisation":"Exclusive"} ] } diff --git a/maloja/malojauri.py b/maloja/malojauri.py index 1ae4364..d996f36 100644 --- a/maloja/malojauri.py +++ b/maloja/malojauri.py @@ -31,12 +31,18 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api filterkeys = {"track":{"artists":keys.getall("artist"),"title":keys.get("title")}} elif type == "artist": filterkeys = {"artist":keys.get("artist")} - if "associated" in keys: filterkeys["associated"] = True + filterkeys["associated"] = (keys.get('associated','no').lower() == 'yes') + # associated is only used for filtering by artist, to indicate that we include associated artists + # for actual artist charts, to show that we want to count them, use 'unified' elif type == "album": filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("albumtitle") or keys.get("title")}} else: filterkeys = {} + # this can be the case regardless of actual entity filter + # e.g if i get scrobbles of an artist associated tells me i want all scrobbles by associated artists + # but seeing the artist charts (wich have no filterkeys) also is affected by this + # 2 limitkeys = {} since,to,within = None,None,None @@ -105,6 +111,7 @@ def internal_to_uri(keys): urikeys.append("artist",a) urikeys.append("albumtitle",keys["album"]["albumtitle"]) + #time if "timerange" in keys: keydict = keys["timerange"].urikeys() diff --git a/maloja/web/jinja/snippets/filterdescription.jinja b/maloja/web/jinja/snippets/filterdescription.jinja index 5652970..444f9cf 100644 --- a/maloja/web/jinja/snippets/filterdescription.jinja +++ b/maloja/web/jinja/snippets/filterdescription.jinja @@ -3,7 +3,7 @@ {% macro desc(filterkeys,limitkeys,prefix="by") %} {% if filterkeys.get('artist') is not none %} - {{ prefix }} {{ links.link(filterkeys.get('artist')) }} + {{ prefix }} {{ links.link(filterkeys.get('artist')) }}{% if filterkeys.get('associated') %} (and associated artists){% endif %} {% elif filterkeys.get('track') is not none %} of {{ links.link(filterkeys.get('track')) }} by {{ links.links(filterkeys["track"]["artists"]) }} diff --git a/maloja/web/jinja/snippets/timeselection.jinja b/maloja/web/jinja/snippets/timeselection.jinja index 99f1ef5..fe8b1d6 100644 --- a/maloja/web/jinja/snippets/timeselection.jinja +++ b/maloja/web/jinja/snippets/timeselection.jinja @@ -61,3 +61,17 @@ {% endif %} + + {% if 'artist' in filterkeys %} +
+ {% for o in xassociated %} + {% if o.replacekeys | map('compare_key_in_dicts',o.replacekeys,allkeys) | alltrue %} + {{ o.localisation }} + {% else %} + {{ o.localisation }} + {% endif %} + {{ "|" if not loop.last }} + {% endfor %} + +
+ {% endif %} From 22495692fb6a364c3232674c925e1cb01382332b Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 12:26:54 +0200 Subject: [PATCH 104/176] Fix GH-218 --- maloja/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maloja/__main__.py b/maloja/__main__.py index 78d9a7a..5e35b29 100644 --- a/maloja/__main__.py +++ b/maloja/__main__.py @@ -30,13 +30,13 @@ def print_header_info(): def get_instance(): try: - return int(subprocess.check_output(["pidof","maloja"])) + return int(subprocess.check_output(["pgrep","-x","maloja"])) except Exception: return None def get_instance_supervisor(): try: - return int(subprocess.check_output(["pidof","maloja_supervisor"])) + return int(subprocess.check_output(["pgrep","-x","maloja_supervisor"])) except Exception: return None From b5a9f41096f4a2f8105d86840ac9884ea568c7c6 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 13:27:38 +0200 Subject: [PATCH 105/176] Restored more associated artists functionality --- maloja/database/__init__.py | 5 ++++- maloja/web/jinja/partials/charts_artists.jinja | 2 +- maloja/web/jinja/partials/top_artists.jinja | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index f878373..372186e 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -374,6 +374,9 @@ def get_charts_artists(dbconn=None,resolve_ids=True,**keys): (since,to) = keys.get('timerange').timestamps() associated = keys.get('associated',True) result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=associated,dbconn=dbconn) + for entry in result: + if "artist" in entry: + entry['associated_artists'] = sqldb.get_associated_artists(entry['artist']) return result @waitfordb @@ -458,7 +461,7 @@ def get_top_artists(dbconn=None,**keys): for rng in rngs: try: res = get_charts_artists(timerange=rng,dbconn=dbconn)[0] - results.append({"range":rng,"artist":res["artist"],"scrobbles":res["scrobbles"]}) + results.append({"range":rng,"artist":res["artist"],"scrobbles":res["scrobbles"],"associated_artists":sqldb.get_associated_artists(res["artist"])}) except Exception: results.append({"range":rng,"artist":None,"scrobbles":0}) diff --git a/maloja/web/jinja/partials/charts_artists.jinja b/maloja/web/jinja/partials/charts_artists.jinja index 3e8044b..c0067cf 100644 --- a/maloja/web/jinja/partials/charts_artists.jinja +++ b/maloja/web/jinja/partials/charts_artists.jinja @@ -49,7 +49,7 @@ {% endif %} - {{ entityrow.row(e['artist'],adminmode=adminmode) }} + {{ entityrow.row(e['artist'],adminmode=adminmode,counting=e.associated_artists) }}
diff --git a/maloja/web/jinja/partials/top_artists.jinja b/maloja/web/jinja/partials/top_artists.jinja index a780611..1d785d1 100644 --- a/maloja/web/jinja/partials/top_artists.jinja +++ b/maloja/web/jinja/partials/top_artists.jinja @@ -20,9 +20,9 @@ {% else %} - {{ entityrow.row(artist) }} - - + {{ entityrow.row(artist,counting=e.associated_artists) }} + + {% endif %} From ad824626c3cf2b125b99752673b238064573b5cc Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 15:08:56 +0200 Subject: [PATCH 106/176] Fixed duplicate counting of scrobbles when including associated artists --- maloja/database/sqldb.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index c22ca39..fca8ef6 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -783,6 +783,17 @@ def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,as ).order_by(sql.asc('timestamp')) result = dbconn.execute(op).all() + # remove duplicates (multiple associated artists in the song, e.g. Irene & Seulgi being both counted as Red Velvet) + # distinct on doesn't seem to exist in sqlite + seen = set() + filtered_result = [] + for row in result: + if row.timestamp not in seen: + filtered_result.append(row) + seen.add(row.timestamp) + result = filtered_result + + if resolve_references: result = scrobbles_db_to_dict(result,dbconn=dbconn) #result = [scrobble_db_to_dict(row,resolve_references=resolve_references) for row in result] From a3831f9b7cbc7f25c5993df96d1751f956109b0b Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 18 Oct 2023 19:04:19 +0200 Subject: [PATCH 107/176] Added disassociation functionality --- maloja/apis/native_v1.py | 16 ++++---- maloja/database/__init__.py | 32 ++++++++++----- maloja/database/sqldb.py | 47 ++++++++++++++++++++++ maloja/web/jinja/album.jinja | 2 + maloja/web/jinja/artist.jinja | 1 + maloja/web/jinja/icons/disassociate.jinja | 5 +++ maloja/web/jinja/icons/remove_album.jinja | 6 +++ maloja/web/jinja/icons/remove_artist.jinja | 5 +++ maloja/web/static/css/maloja.css | 4 +- maloja/web/static/js/edit.js | 47 ++++++++++++++++++++++ 10 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 maloja/web/jinja/icons/disassociate.jinja create mode 100644 maloja/web/jinja/icons/remove_album.jinja create mode 100644 maloja/web/jinja/icons/remove_artist.jinja diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 48c4c84..c51c367 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -774,23 +774,25 @@ def merge_artists(target_id,source_ids): @api.post("associate_albums_to_artist") @authenticated_function(api=True) @catch_exceptions -def associate_albums_to_artist(target_id,source_ids): - result = database.associate_albums_to_artist(target_id,source_ids) +def associate_albums_to_artist(target_id,source_ids,remove=False): + result = database.associate_albums_to_artist(target_id,source_ids,remove=remove) + descword = "removed" if remove else "added" if result: return { "status":"success", - "desc":f"{result['target']} was added as album artist of {', '.join(src['albumtitle'] for src in result['sources'])}" + "desc":f"{result['target']} was {descword} as album artist of {', '.join(src['albumtitle'] for src in result['sources'])}" } @api.post("associate_tracks_to_artist") @authenticated_function(api=True) @catch_exceptions -def associate_tracks_to_artist(target_id,source_ids): - result = database.associate_tracks_to_artist(target_id,source_ids) +def associate_tracks_to_artist(target_id,source_ids,remove=False): + result = database.associate_tracks_to_artist(target_id,source_ids,remove=remove) + descword = "removed" if remove else "added" if result: return { "status":"success", - "desc":f"{result['target']} was added as artist for {', '.join(src['title'] for src in result['sources'])}" + "desc":f"{result['target']} was {descword} as artist for {', '.join(src['title'] for src in result['sources'])}" } @api.post("associate_tracks_to_album") @@ -801,7 +803,7 @@ def associate_tracks_to_album(target_id,source_ids): if result: return { "status":"success", - "desc":f"{', '.join(src['title'] for src in result['sources'])} were added to {result['target']['albumtitle']}" + "desc":f"{', '.join(src['title'] for src in result['sources'])} were " + f"added to {result['target']['albumtitle']}" if target_id else "removed from their album" } diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 372186e..067060b 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -269,11 +269,15 @@ def merge_albums(target_id,source_ids): @waitfordb -def associate_albums_to_artist(target_id,source_ids): +def associate_albums_to_artist(target_id,source_ids,remove=False): sources = [sqldb.get_album(id) for id in source_ids] target = sqldb.get_artist(target_id) - log(f"Adding {sources} into {target}") - sqldb.add_artists_to_albums(artist_ids=[target_id],album_ids=source_ids) + if remove: + log(f"Removing {sources} from {target}") + sqldb.remove_artists_from_albums(artist_ids=[target_id],album_ids=source_ids) + else: + log(f"Adding {sources} into {target}") + sqldb.add_artists_to_albums(artist_ids=[target_id],album_ids=source_ids) result = {'sources':sources,'target':target} dbcache.invalidate_entity_cache() dbcache.invalidate_caches() @@ -281,11 +285,15 @@ def associate_albums_to_artist(target_id,source_ids): return result @waitfordb -def associate_tracks_to_artist(target_id,source_ids): +def associate_tracks_to_artist(target_id,source_ids,remove=False): sources = [sqldb.get_track(id) for id in source_ids] target = sqldb.get_artist(target_id) - log(f"Adding {sources} into {target}") - sqldb.add_artists_to_tracks(artist_ids=[target_id],track_ids=source_ids) + if remove: + log(f"Removing {sources} from {target}") + sqldb.remove_artists_from_tracks(artist_ids=[target_id],track_ids=source_ids) + else: + log(f"Adding {sources} into {target}") + sqldb.add_artists_to_tracks(artist_ids=[target_id],track_ids=source_ids) result = {'sources':sources,'target':target} dbcache.invalidate_entity_cache() dbcache.invalidate_caches() @@ -294,10 +302,14 @@ def associate_tracks_to_artist(target_id,source_ids): @waitfordb def associate_tracks_to_album(target_id,source_ids): + # target_id None means remove from current album! sources = [sqldb.get_track(id) for id in source_ids] - target = sqldb.get_album(target_id) - log(f"Adding {sources} into {target}") - sqldb.add_tracks_to_albums({src:target_id for src in source_ids}) + if target_id: + target = sqldb.get_album(target_id) + log(f"Adding {sources} into {target}") + sqldb.add_tracks_to_albums({src:target_id for src in source_ids}) + else: + sqldb.remove_album(source_ids) result = {'sources':sources,'target':target} dbcache.invalidate_entity_cache() dbcache.invalidate_caches() @@ -350,7 +362,7 @@ def get_tracks(dbconn=None,**keys): def get_artists(dbconn=None): return sqldb.get_artists(dbconn=dbconn) - +@waitfordb def get_albums_artist_appears_on(dbconn=None,**keys): artist_id = sqldb.get_artist_id(keys['artist'],dbconn=dbconn) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index fca8ef6..25a8b4e 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -397,6 +397,14 @@ def add_tracks_to_albums(track_to_album_id_dict,replace=False,dbconn=None): for track_id in track_to_album_id_dict: add_track_to_album(track_id,track_to_album_id_dict[track_id],dbconn=dbconn) +@connection_provider +def remove_album(*track_ids,dbconn=None): + + DB['tracks'].update().where( + DB['tracks'].c.track_id.in_(track_ids) + ).values( + album_id=None + ) ### these will 'get' the ID of an entity, creating it if necessary @@ -640,6 +648,29 @@ def add_artists_to_tracks(track_ids,artist_ids,dbconn=None): return True +@connection_provider +def remove_artists_from_tracks(track_ids,artist_ids,dbconn=None): + + # only tracks that have at least one other artist + subquery = DB['trackartists'].select().where( + ~DB['trackartists'].c.artist_id.in_(artist_ids) + ).with_only_columns( + DB['trackartists'].c.track_id + ).distinct().alias('sub') + + op = DB['trackartists'].delete().where( + sql.and_( + DB['trackartists'].c.track_id.in_(track_ids), + DB['trackartists'].c.artist_id.in_(artist_ids), + DB['trackartists'].c.track_id.in_(subquery.select()) + ) + ) + + result = dbconn.execute(op) + clean_db(dbconn=dbconn) + + return True + @connection_provider def add_artists_to_albums(album_ids,artist_ids,dbconn=None): @@ -655,6 +686,22 @@ def add_artists_to_albums(album_ids,artist_ids,dbconn=None): return True +@connection_provider +def remove_artists_from_albums(album_ids,artist_ids,dbconn=None): + + # no check here, albums are allowed to have zero artists + + op = DB['albumartists'].delete().where( + sql.and_( + DB['albumartists'].c.album_id.in_(album_ids), + DB['albumartists'].c.artist_id.in_(artist_ids) + ) + ) + + result = dbconn.execute(op) + clean_db(dbconn=dbconn) + + return True ### Merge diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index ad70d3b..6d7ebe1 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -29,6 +29,8 @@
{% include 'icons/add_album.jinja' %} + {% include 'icons/association_mark.jinja' %} {% include 'icons/association_unmark.jinja' %} {% include 'icons/association_cancel.jinja' %} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 168994f..fe0a35c 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -39,6 +39,7 @@
{% include 'icons/add_artist.jinja' %} + {% include 'icons/remove_artist.jinja' %} {% include 'icons/association_cancel.jinja' %}
diff --git a/maloja/web/jinja/icons/disassociate.jinja b/maloja/web/jinja/icons/disassociate.jinja new file mode 100644 index 0000000..46bccc0 --- /dev/null +++ b/maloja/web/jinja/icons/disassociate.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/icons/remove_album.jinja b/maloja/web/jinja/icons/remove_album.jinja new file mode 100644 index 0000000..60c79af --- /dev/null +++ b/maloja/web/jinja/icons/remove_album.jinja @@ -0,0 +1,6 @@ +
+ + + + +
diff --git a/maloja/web/jinja/icons/remove_artist.jinja b/maloja/web/jinja/icons/remove_artist.jinja new file mode 100644 index 0000000..3af031c --- /dev/null +++ b/maloja/web/jinja/icons/remove_artist.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 6d40ad4..0828875 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -162,7 +162,9 @@ we want icons to not be displayed in list rows, but show them with reduced opaci .mergeicons:not(.somethingmarked_for_merge) #mergeicon, /* can't merge when nothing is selected */ .mergeicons.marked_for_merge #mergeicon, /* cant merge into one of the things we have selected */ .associateicons:not(.sources_marked_for_associate) #associatealbumicon, -.associateicons:not(.sources_marked_for_associate) #associateartisticon /* nothing marked yet, can't associate with this */ +.associateicons:not(.sources_marked_for_associate) #associateartisticon, +.associateicons:not(.sources_marked_for_associate) #removealbumicon, +.associateicons:not(.sources_marked_for_associate) #removeartisticon /* nothing marked yet, can't associate with this */ { pointer-events: none; opacity:0.5; diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index 6caf52b..4e38f3e 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -453,6 +453,53 @@ function associate(element) { } +function removeAssociate(element) { + const parentElement = element.closest('[data-entity_id]'); + var entity_type = parentElement.dataset.entity_type; + var entity_id = parentElement.dataset.entity_id; + entity_id = parseInt(entity_id); + + var requests_todo = 0; + for (var target_entity_type of associate_sources[entity_type]) { + var key = "marked_for_associate_" + target_entity_type; + var current_stored = getStoredList(key); + + if (current_stored.length != 0) { + requests_todo += 1; + callback_func = function(req){ + if (req.status == 200) { + + toggleAssociationIcons(parentElement); + notifyCallback(req); + requests_todo -= 1; + if (requests_todo == 0) { + setTimeout(window.location.reload.bind(window.location),1000); + } + + } + else { + notifyCallback(req); + } + }; + + neo.xhttpreq( + "/apis/mlj_1/associate_" + target_entity_type + "s_to_" + entity_type, + data={ + 'source_ids':current_stored, + 'target_id':entity_id, + 'remove': true + }, + method="POST", + callback=callback_func, + json=true + ); + + storeList(key,[]); + } + + } +} + function cancelMerge(element) { const parentElement = element.closest('[data-entity_id]'); From a12c52af1fe34fe4ffc401e5a1126275131fb09f Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 19 Oct 2023 17:26:08 +0200 Subject: [PATCH 108/176] Added ability to show artist charts without combining artists --- maloja/database/__init__.py | 4 ++-- maloja/malojauri.py | 4 ++++ maloja/web/jinja/charts_artists.jinja | 5 ++++- maloja/web/jinja/partials/charts_artists.jinja | 10 +++++----- maloja/web/jinja/snippets/timeselection.jinja | 6 +++--- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 067060b..847e5f1 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -384,8 +384,8 @@ def get_tracks_without_album(dbconn=None,resolve_ids=True): @waitfordb def get_charts_artists(dbconn=None,resolve_ids=True,**keys): (since,to) = keys.get('timerange').timestamps() - associated = keys.get('associated',True) - result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=associated,dbconn=dbconn) + separate = keys.get('separate',False) + result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=(not separate),dbconn=dbconn) for entry in result: if "artist" in entry: entry['associated_artists'] = sqldb.get_associated_artists(entry['artist']) diff --git a/maloja/malojauri.py b/maloja/malojauri.py index d996f36..6546317 100644 --- a/maloja/malojauri.py +++ b/maloja/malojauri.py @@ -78,6 +78,7 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api #5 specialkeys = {} if "remote" in keys: specialkeys["remote"] = keys["remote"] + specialkeys["separate"] = (keys.get('separate','no').lower() == 'yes') return filterkeys, limitkeys, delimitkeys, amountkeys, specialkeys @@ -144,6 +145,9 @@ def internal_to_uri(keys): if "perpage" in keys: urikeys.append("perpage",str(keys["perpage"])) + if keys.get("separate",False): + urikeys.append("separate","yes") + return urikeys diff --git a/maloja/web/jinja/charts_artists.jinja b/maloja/web/jinja/charts_artists.jinja index 627f38c..cd1be3a 100644 --- a/maloja/web/jinja/charts_artists.jinja +++ b/maloja/web/jinja/charts_artists.jinja @@ -7,7 +7,10 @@ {% endblock %} -{% set charts = dbc.get_charts_artists(filterkeys,limitkeys) %} +{% set charts = dbc.get_charts_artists(filterkeys,limitkeys,specialkeys) %} + + + {% set pages = math.ceil(charts.__len__() / amountkeys.perpage) %} {% if charts[0] is defined %} {% set topartist = charts[0].artist %} diff --git a/maloja/web/jinja/partials/charts_artists.jinja b/maloja/web/jinja/partials/charts_artists.jinja index c0067cf..4cd0063 100644 --- a/maloja/web/jinja/partials/charts_artists.jinja +++ b/maloja/web/jinja/partials/charts_artists.jinja @@ -2,7 +2,7 @@ {% import 'snippets/entityrow.jinja' as entityrow %} {% if charts is undefined %} - {% set charts = dbc.get_charts_artists(limitkeys) %} + {% set charts = dbc.get_charts_artists(limitkeys,specialkeys) %} {% endif %} {% if compare %} @@ -11,7 +11,7 @@ {% if compare is none %}{% set compare = False %}{% endif %} {% endif %} {% if compare %} - {% set prevartists = dbc.get_charts_artists({'timerange':compare}) %} + {% set prevartists = dbc.get_charts_artists({'timerange':compare},specialkeys) %} {% set lastranks = {} %} {% for a in prevartists %} @@ -49,11 +49,11 @@ {% endif %} - {{ entityrow.row(e['artist'],adminmode=adminmode,counting=e.associated_artists) }} + {{ entityrow.row(e['artist'],adminmode=adminmode,counting=([] if specialkeys.separate else e.associated_artists)) }} -
- + + {% endif %} {% endfor %} diff --git a/maloja/web/jinja/snippets/timeselection.jinja b/maloja/web/jinja/snippets/timeselection.jinja index fe8b1d6..efd5f4c 100644 --- a/maloja/web/jinja/snippets/timeselection.jinja +++ b/maloja/web/jinja/snippets/timeselection.jinja @@ -1,7 +1,7 @@ - {% set allkeys = [filterkeys,limitkeys,delimitkeys,amountkeys] | combine_dicts %} + {% set allkeys = [filterkeys,limitkeys,delimitkeys,amountkeys,specialkeys] | combine_dicts %} @@ -13,7 +13,7 @@ {% set nextrange = thisrange.next(1) %} {% if prevrange is not none %} - {{ prevrange.desc() }} + {{ prevrange.desc() }} « {% endif %} {% if prevrange is not none or nextrange is not none %} @@ -21,7 +21,7 @@ {% endif %} {% if nextrange is not none %} » - {{ nextrange.desc() }} + {{ nextrange.desc() }} {% endif %} From b9242d843e04762f5665c52b33df7accc49f4b9c Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 19 Oct 2023 17:26:52 +0200 Subject: [PATCH 109/176] Added UI for artist chart separation --- maloja/jinjaenv/context.py | 4 ++++ maloja/web/jinja/charts_artists.jinja | 2 +- maloja/web/jinja/snippets/timeselection.jinja | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/maloja/jinjaenv/context.py b/maloja/jinjaenv/context.py index 25aa039..2952649 100644 --- a/maloja/jinjaenv/context.py +++ b/maloja/jinjaenv/context.py @@ -76,6 +76,10 @@ def update_jinja_environment(): "xassociated": [ {"identifier":"include_associated","replacekeys":{"associated":True},"localisation":"Associated"}, {"identifier":"exclude_associated","replacekeys":{"associated":False},"localisation":"Exclusive"} + ], + "xseparate": [ + {"identifier":"count_combined","replacekeys":{"separate":False},"localisation":"Combined"}, + {"identifier":"count_separate","replacekeys":{"separate":True},"localisation":"Separate"} ] } diff --git a/maloja/web/jinja/charts_artists.jinja b/maloja/web/jinja/charts_artists.jinja index cd1be3a..b306578 100644 --- a/maloja/web/jinja/charts_artists.jinja +++ b/maloja/web/jinja/charts_artists.jinja @@ -32,7 +32,7 @@

Artist Charts

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

- {% with delimitkeys = {} %} + {% with delimitkeys = {}, artistchart=True %} {% include 'snippets/timeselection.jinja' %} {% endwith %} diff --git a/maloja/web/jinja/snippets/timeselection.jinja b/maloja/web/jinja/snippets/timeselection.jinja index efd5f4c..efd4e60 100644 --- a/maloja/web/jinja/snippets/timeselection.jinja +++ b/maloja/web/jinja/snippets/timeselection.jinja @@ -75,3 +75,16 @@ {% endif %} + + {% if artistchart %} +
+ {% for o in xseparate %} + {% if o.replacekeys | map('compare_key_in_dicts',o.replacekeys,allkeys) | alltrue %} + {{ o.localisation }} + {% else %} + {{ o.localisation }} + {% endif %} + {{ "|" if not loop.last }} + {% endfor %} +
+ {% endif %} From 87b625036719c005e914c6ef4245cb2af64c2da2 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 19 Oct 2023 17:55:01 +0200 Subject: [PATCH 110/176] Extended chart separation logic to performance --- maloja/database/__init__.py | 4 +++- maloja/web/jinja/partials/performance.jinja | 6 +++--- maloja/web/jinja/performance.jinja | 2 ++ maloja/web/jinja/snippets/links.jinja | 4 ++-- maloja/web/jinja/snippets/timeselection.jinja | 4 +++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 847e5f1..4bb4834 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -425,6 +425,8 @@ def get_pulse(dbconn=None,**keys): @waitfordb def get_performance(dbconn=None,**keys): + separate = keys.get('separate') + rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]}) results = [] @@ -443,7 +445,7 @@ def get_performance(dbconn=None,**keys): #artist = sqldb.get_artist(artist_id,dbconn=dbconn) # ^this is the most useless line in programming history # but I like consistency - charts = get_charts_artists(timerange=rng,resolve_ids=False,dbconn=dbconn) + charts = get_charts_artists(timerange=rng,resolve_ids=False,separate=separate,dbconn=dbconn) rank = None for c in charts: if c["artist_id"] == artist_id: diff --git a/maloja/web/jinja/partials/performance.jinja b/maloja/web/jinja/partials/performance.jinja index 558df31..12e07f4 100644 --- a/maloja/web/jinja/partials/performance.jinja +++ b/maloja/web/jinja/partials/performance.jinja @@ -1,6 +1,6 @@ {% import 'snippets/links.jinja' as links %} -{% set ranges = dbc.get_performance(filterkeys,limitkeys,delimitkeys) %} +{% set ranges = dbc.get_performance(filterkeys,limitkeys,delimitkeys,specialkeys) %} {% set minrank = ranges|map(attribute="rank")|reject("none")|max|default(60) %} {% set minrank = minrank + 20 %} @@ -13,11 +13,11 @@
diff --git a/maloja/web/jinja/performance.jinja b/maloja/web/jinja/performance.jinja index 5ab281d..dd6c711 100644 --- a/maloja/web/jinja/performance.jinja +++ b/maloja/web/jinja/performance.jinja @@ -25,7 +25,9 @@
{{ filterdesc.desc(filterkeys,limitkeys,prefix='of') }}

+ {% with artistchart = True %} {% include 'snippets/timeselection.jinja' %} + {% endwith %} diff --git a/maloja/web/jinja/snippets/links.jinja b/maloja/web/jinja/snippets/links.jinja index f90a535..67b940c 100644 --- a/maloja/web/jinja/snippets/links.jinja +++ b/maloja/web/jinja/snippets/links.jinja @@ -45,14 +45,14 @@ {%- endmacro %} -{% macro link_rank(filterkeys,timerange,rank=None,percent=None) %} +{% macro link_rank(filterkeys,specialkeys,timerange,rank=None,percent=None) %} {% 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}) %} + {% set url = mlj_uri.create_uri("/charts_artists",{'timerange':timerange},specialkeys) %} {% endif %} {% set rankclass = {1:'gold',2:'silver',3:'bronze'}[rank] or "" %} diff --git a/maloja/web/jinja/snippets/timeselection.jinja b/maloja/web/jinja/snippets/timeselection.jinja index efd4e60..e17510f 100644 --- a/maloja/web/jinja/snippets/timeselection.jinja +++ b/maloja/web/jinja/snippets/timeselection.jinja @@ -62,7 +62,9 @@ {% endif %} - {% if 'artist' in filterkeys %} + + {% if ('artist' in filterkeys) and (not artistchart) %}
{% for o in xassociated %} {% if o.replacekeys | map('compare_key_in_dicts',o.replacekeys,allkeys) | alltrue %} From 090aee3cf75db72fa1a19635bb5fa8a20530167a Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 19 Oct 2023 18:16:36 +0200 Subject: [PATCH 111/176] Extended chart separation logic to top_artists --- maloja/database/__init__.py | 4 +++- maloja/web/jinja/partials/top_artists.jinja | 10 +++++----- maloja/web/jinja/top_artists.jinja | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 4bb4834..048f1b2 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -469,12 +469,14 @@ def get_performance(dbconn=None,**keys): @waitfordb def get_top_artists(dbconn=None,**keys): + separate = keys.get('separate') + 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_artists(timerange=rng,dbconn=dbconn)[0] + res = get_charts_artists(timerange=rng,separate=separate,dbconn=dbconn)[0] results.append({"range":rng,"artist":res["artist"],"scrobbles":res["scrobbles"],"associated_artists":sqldb.get_associated_artists(res["artist"])}) except Exception: results.append({"range":rng,"artist":None,"scrobbles":0}) diff --git a/maloja/web/jinja/partials/top_artists.jinja b/maloja/web/jinja/partials/top_artists.jinja index 1d785d1..dae0f10 100644 --- a/maloja/web/jinja/partials/top_artists.jinja +++ b/maloja/web/jinja/partials/top_artists.jinja @@ -1,7 +1,7 @@ {% import 'snippets/links.jinja' as links %} {% import 'snippets/entityrow.jinja' as entityrow %} -{% set ranges = dbc.get_top_artists(limitkeys,delimitkeys) %} +{% set ranges = dbc.get_top_artists(limitkeys,delimitkeys,specialkeys) %} {% set maxbar = ranges|map(attribute="scrobbles")|max|default(1) %} {% if maxbar < 1 %}{% set maxbar = 1 %}{% endif %} @@ -12,7 +12,7 @@ {% set thisrange = e.range %} {% set artist = e.artist %}
- + {% if artist is none %} @@ -20,9 +20,9 @@ {% else %} - {{ entityrow.row(artist,counting=e.associated_artists) }} - - + {{ entityrow.row(artist,counting=([] if specialkeys.separate else e.associated_artists)) }} + + {% endif %} diff --git a/maloja/web/jinja/top_artists.jinja b/maloja/web/jinja/top_artists.jinja index ce4bf24..262963f 100644 --- a/maloja/web/jinja/top_artists.jinja +++ b/maloja/web/jinja/top_artists.jinja @@ -21,7 +21,9 @@ {{ filterdesc.desc(filterkeys,limitkeys) }}

+ {% with artistchart = True %} {% include 'snippets/timeselection.jinja' %} + {% endwith %}
{% include 'icons/merge_mark.jinja' %} {% include 'icons/merge_unmark.jinja' %} +{% if (entity is mapping) %} {% include 'icons/association_mark.jinja' %} {% include 'icons/association_unmark.jinja' %} +{% endif %} {{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}0 {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange}],amount=e.scrobbles) }} {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}{{ links.link_scrobbles([{'artist':artist,'timerange':thisrange,'associated':True}],amount=e.scrobbles) }} {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange,'associated':True}],percent=e.scrobbles*100/maxbar) }}
{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],percent=e['scrobbles']*100/maxbar) }}{{ links.link_scrobbles([{'artist':e['artist'],'associated':(not specialkeys.separate),'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}{{ links.link_scrobbles([{'artist':e['artist'],'associated':(not specialkeys.separate),'timerange':limitkeys.timerange}],percent=e['scrobbles']*100/maxbar) }}
{{ thisrange.desc() }} - {{ links.link_rank(filterkeys,thisrange,rank=t.rank) }} + {{ links.link_rank(filterkeys,specialkeys,thisrange,rank=t.rank) }} {% set prct = ((minrank+1-t.rank)*100/minrank if t.rank is not none else 0) %} - {{ links.link_rank(filterkeys,thisrange,percent=prct,rank=t.rank) }} + {{ links.link_rank(filterkeys,specialkeys,thisrange,percent=prct,rank=t.rank) }}
{{ thisrange.desc() }}{{ thisrange.desc() }}
0 {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange,'associated':True}],amount=e.scrobbles) }} {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange,'associated':True}],percent=e.scrobbles*100/maxbar) }}{{ links.link_scrobbles([{'artist':artist,'associated':(not specialkeys.separate),'timerange':thisrange}],amount=e.scrobbles) }} {{ links.link_scrobbles([{'artist':artist,'associated':(not specialkeys.separate),'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}
From c9668c04d275cf5975b3f53d23a0f44f8331fc99 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 19 Oct 2023 21:49:29 +0200 Subject: [PATCH 112/176] Fixed unneeded album creation --- maloja/database/sqldb.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 25a8b4e..6704b9a 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -439,7 +439,12 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): if trackdict.get('album') and create_new: # if we don't supply create_new, it means we just want to get info about a track # which means no need to write album info, even if it was new - add_track_to_album(row.id,get_album_id(trackdict['album'],dbconn=dbconn),replace=update_album,dbconn=dbconn) + # if we havent set update_album, we only want to assign the album in case the track + # has no album yet. this means we also only want to create a potentially new album in that case + album_id = get_album_id(trackdict['album'],create_new=(update_album or not row.album_id),dbconn=dbconn) + add_track_to_album(row.id,album_id,replace=(update_album or not row.album_id),dbconn=dbconn) + + return row.id if not create_new: return None From 9bc7d881d85f4691774dac67b768ebc1614471ab Mon Sep 17 00:00:00 2001 From: krateng Date: Fri, 20 Oct 2023 00:11:06 +0200 Subject: [PATCH 113/176] Some fixes --- maloja/__main__.py | 4 ++-- maloja/apis/native_v1.py | 4 ++-- maloja/database/sqldb.py | 5 +++-- maloja/malojatime.py | 13 +++++++++++-- maloja/server.py | 2 -- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/maloja/__main__.py b/maloja/__main__.py index 5e35b29..7a26a47 100644 --- a/maloja/__main__.py +++ b/maloja/__main__.py @@ -130,7 +130,7 @@ def run_supervisor(): def debug(): os.environ["MALOJA_DEV_MODE"] = 'true' conf.malojaconfig.load_environment() - direct() + run_server() def print_info(): print_header_info() @@ -172,7 +172,7 @@ def main(*args,**kwargs): } if "version" in kwargs: - print(info.VERSION) + print(pkginfo.VERSION) return True else: try: diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index c51c367..0cab1b5 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -2,10 +2,10 @@ import os import math import traceback -from bottle import response, static_file, request, FormsDict +from bottle import response, static_file, FormsDict from doreah.logging import log -from doreah.auth import authenticated_api, authenticated_api_with_alternate, authenticated_function +from doreah.auth import authenticated_function # nimrodel API from nimrodel import EAPI as API diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 6704b9a..e94246d 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -399,7 +399,7 @@ def add_tracks_to_albums(track_to_album_id_dict,replace=False,dbconn=None): @connection_provider def remove_album(*track_ids,dbconn=None): - + DB['tracks'].update().where( DB['tracks'].c.track_id.in_(track_ids) ).values( @@ -439,10 +439,11 @@ def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None): if trackdict.get('album') and create_new: # if we don't supply create_new, it means we just want to get info about a track # which means no need to write album info, even if it was new + # if we havent set update_album, we only want to assign the album in case the track # has no album yet. this means we also only want to create a potentially new album in that case album_id = get_album_id(trackdict['album'],create_new=(update_album or not row.album_id),dbconn=dbconn) - add_track_to_album(row.id,album_id,replace=(update_album or not row.album_id),dbconn=dbconn) + add_track_to_album(row.id,album_id,replace=update_album,dbconn=dbconn) return row.id diff --git a/maloja/malojatime.py b/maloja/malojatime.py index b383a27..a808d9b 100644 --- a/maloja/malojatime.py +++ b/maloja/malojatime.py @@ -1,7 +1,7 @@ from datetime import timezone, timedelta, date, time, datetime from calendar import monthrange -from os.path import commonprefix import math +from abc import ABC, abstractmethod from .pkg_global.conf import malojaconfig @@ -28,7 +28,7 @@ def register_scrobbletime(timestamp): # Generic Time Range -class MTRangeGeneric: +class MTRangeGeneric(ABC): # despite the above, ranges that refer to the exact same real time range should evaluate as equal def __eq__(self,other): @@ -68,6 +68,15 @@ class MTRangeGeneric: def __contains__(self,timestamp): return timestamp >= self.first_stamp() and timestamp <= self.last_stamp() + @abstractmethod + def first_stamp(self): + pass + + @abstractmethod + def last_stamp(self): + pass + + # Any range that has one defining base unit, whether week, year, etc. class MTRangeSingular(MTRangeGeneric): def fromstr(self): diff --git a/maloja/server.py b/maloja/server.py index 540f65c..e8f6288 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -1,9 +1,7 @@ # technical -import sys import os from threading import Thread from importlib import resources -import datauri import time From cd846c1abef3d49658699b03cfec687f89bddcb9 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 12:04:55 +0200 Subject: [PATCH 114/176] Added album certifications and changed certification design --- maloja/database/__init__.py | 6 +++ maloja/pkg_global/conf.py | 9 +++-- maloja/web/jinja/abstracts/base.jinja | 2 +- maloja/web/jinja/album.jinja | 6 ++- maloja/web/jinja/artist.jinja | 2 +- maloja/web/jinja/partials/awards_album.jinja | 38 +++++++++++++++++++ maloja/web/jinja/partials/awards_artist.jinja | 4 +- maloja/web/jinja/track.jinja | 4 +- maloja/web/static/css/maloja.css | 22 +++++++++++ 9 files changed, 84 insertions(+), 9 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 048f1b2..f1ffc41 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -624,6 +624,11 @@ def album_info(dbconn=None,**keys): c = [e for e in alltimecharts if e["album"] == album][0] scrobbles = c["scrobbles"] position = c["rank"] + cert = None + threshold_gold, threshold_platinum, threshold_diamond = malojaconfig["SCROBBLES_GOLD_ALBUM","SCROBBLES_PLATINUM_ALBUM","SCROBBLES_DIAMOND_ALBUM"] + if scrobbles >= threshold_diamond: cert = "diamond" + elif scrobbles >= threshold_platinum: cert = "platinum" + elif scrobbles >= threshold_gold: cert = "gold" return { "album":album, @@ -634,6 +639,7 @@ def album_info(dbconn=None,**keys): "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']], }, + "certification":cert, "topweeks":len([e for e in cached.weekly_topalbums if e == album_id]), "id":album_id } diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 05e8bc3..a4de766 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -155,9 +155,12 @@ malojaconfig = Configuration( "use_global_cache":(tp.Boolean(), "Use global DB Cache", True) }, "Fluff":{ - "scrobbles_gold":(tp.Integer(), "Scrobbles for Gold", 250, "How many scrobbles a track needs to be considered 'Gold' status"), - "scrobbles_platinum":(tp.Integer(), "Scrobbles for Platinum", 500, "How many scrobbles a track needs to be considered 'Platinum' status"), - "scrobbles_diamond":(tp.Integer(), "Scrobbles for Diamond", 1000, "How many scrobbles a track needs to be considered 'Diamond' status"), + "scrobbles_gold":(tp.Integer(), "Scrobbles for Gold (Track)", 250, "How many scrobbles a track needs to be considered 'Gold' status"), + "scrobbles_platinum":(tp.Integer(), "Scrobbles for Platinum (Track)",500, "How many scrobbles a track needs to be considered 'Platinum' status"), + "scrobbles_diamond":(tp.Integer(), "Scrobbles for Diamond (Track)",1000, "How many scrobbles a track needs to be considered 'Diamond' status"), + "scrobbles_gold_album":(tp.Integer(), "Scrobbles for Gold (Album)", 500, "How many scrobbles an album needs to be considered 'Gold' status"), + "scrobbles_platinum_album":(tp.Integer(), "Scrobbles for Platinum (Album)",750, "How many scrobbles an album needs to be considered 'Platinum' status"), + "scrobbles_diamond_album":(tp.Integer(), "Scrobbles for Diamond (Album)",1500, "How many scrobbles an album needs to be considered 'Diamond' status"), "name":(tp.String(), "Name", "Generic Maloja User") }, "Third Party Services":{ diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index 91806e6..dd6cc5c 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -40,7 +40,7 @@ {% block scripts %}{% endblock %} - + {% block content %} diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 6d7ebe1..64bfe60 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -15,6 +15,10 @@ {% set encodedalbum = mlj_uri.uriencode({'album':album}) %} +{% block custombodyclasses %} + {% if info.certification %}certified certified_{{ info.certification }}{% endif %} +{% endblock %} + {% block icon_bar %} {% if adminmode %} @@ -68,7 +72,6 @@ {{ links.links(album.artists) }}

{{ info.album.albumtitle | e }}

- {# awards.certs(album) #} #{{ info.position }}
@@ -82,6 +85,7 @@ {{ awards.medals(info) }} {{ awards.topweeks(info) }} + {{ awards.subcerts(info) }} diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index fe0a35c..25347bb 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -91,7 +91,7 @@ {{ awards.medals(info) }} {{ awards.topweeks(info) }} {% endif %} - {{ awards.certs(artist) }} + {{ awards.subcerts(artist) }} diff --git a/maloja/web/jinja/partials/awards_album.jinja b/maloja/web/jinja/partials/awards_album.jinja index b9337a2..d83f0e0 100644 --- a/maloja/web/jinja/partials/awards_album.jinja +++ b/maloja/web/jinja/partials/awards_album.jinja @@ -1,3 +1,5 @@ +{% import 'snippets/links.jinja' as links %} + {% macro medals(info) %} @@ -40,3 +42,39 @@ {%- endmacro %} + + + + +{% macro certs(album) %} + + + +{% set info = db.album_info(album=album) %} +{% if info.certification is not none %} + +{% endif %} + +{%- endmacro %} + +{% macro subcerts(album) %} + + + +{% set charts = db.get_charts_tracks(album=album.album,timerange=malojatime.alltime()) %} +{% for e in charts -%} + {%- if e.scrobbles >= settings.scrobbles_gold -%}{% set cert = 'gold' %}{%- endif -%} + {%- if e.scrobbles >= settings.scrobbles_platinum -%}{% set cert = 'platinum' %}{%- endif -%} + {%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%} + + {%- if cert -%} + + {%- endif %} + +{%- endfor %} + +{%- endmacro %} \ No newline at end of file diff --git a/maloja/web/jinja/partials/awards_artist.jinja b/maloja/web/jinja/partials/awards_artist.jinja index 10bfecc..9492633 100644 --- a/maloja/web/jinja/partials/awards_artist.jinja +++ b/maloja/web/jinja/partials/awards_artist.jinja @@ -52,9 +52,9 @@ -{% macro certs(artist) %} +{% macro subcerts(artist) %} - + {% set charts = db.get_charts_tracks(artist=artist,timerange=malojatime.alltime()) %} {% for e in charts -%} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index f446c71..4aaf990 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -20,6 +20,9 @@ {% set encodedtrack = mlj_uri.uriencode({'track':track}) %} +{% block custombodyclasses %} + {% if info.certification %}certified certified_{{ info.certification }}{% endif %} +{% endblock %} {% block icon_bar %} {% if adminmode %} @@ -69,7 +72,6 @@ {{ links.links(track.artists) }}

{{ info.track.title | e }}

- {{ awards.certs(track) }} #{{ info.position }}
{% if info.track.album %} diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 0828875..6241168 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -1,5 +1,10 @@ @import url("/grisons.css"); +:root { + --color-diamond: 103, 161, 253; + --color-platinum: 229, 228, 226; + --color-gold: 255,215,0; +} body { padding:15px; @@ -13,6 +18,23 @@ body { */ } +body.certified { + background: radial-gradient(circle at top left, rgba(var(--bg_special_color),0.5) 0%, var(--current-bg-color) 20%); + background-position: top left; + background-repeat: no-repeat; + background-attachment: fixed; + height:100%; +} +body.certified.certified_diamond { + --bg_special_color: var(--color-diamond); +} +body.certified.certified_platinum{ + --bg_special_color: var(--color-platinum); +} +body.certified.certified_gold { + --bg_special_color: var(--color-gold); +} + input[type="date"] { From 422a973eff8b78195041ce7553bb1bf5e8de4d91 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 12:13:58 +0200 Subject: [PATCH 115/176] Made entity info pages use subtemplates --- maloja/web/jinja/album.jinja | 37 +----------------- maloja/web/jinja/artist.jinja | 42 +-------------------- maloja/web/jinja/partials/info_album.jinja | 3 +- maloja/web/jinja/partials/info_artist.jinja | 2 +- maloja/web/jinja/partials/info_track.jinja | 1 - maloja/web/jinja/track.jinja | 40 +------------------- 6 files changed, 5 insertions(+), 120 deletions(-) diff --git a/maloja/web/jinja/album.jinja b/maloja/web/jinja/album.jinja index 64bfe60..13d9133 100644 --- a/maloja/web/jinja/album.jinja +++ b/maloja/web/jinja/album.jinja @@ -55,42 +55,7 @@ {% import 'partials/awards_album.jinja' as awards %} - - - - - -
- {% if adminmode %} -
- {% else %} -
-
- {% endif %} -
- {{ links.links(album.artists) }}
-

{{ info.album.albumtitle | e }}

- #{{ info.position }} -
- -

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

- - - - - - {{ awards.medals(info) }} - {{ awards.topweeks(info) }} - {{ awards.subcerts(info) }} - - -
+{% include 'partials/info_album.jinja' %}

Top Tracks

diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index 25347bb..5cf2d02 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -56,47 +56,7 @@ - - - - - -
- {% if adminmode %} -
- {% else %} -
-
- {% endif %} -
-

{{ info.artist | e }}

- {% if competes and info['scrobbles']>0 %}#{{ info.position }}{% endif %} -
- {% if competes and included %} - associated: {{ links.links(included) }} - {% elif not competes %} - Competing under {{ links.link(credited) }} (#{{ info.position }}) - {% endif %} - -

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

- - - - - {% if competes %} - {{ awards.medals(info) }} - {{ awards.topweeks(info) }} - {% endif %} - {{ awards.subcerts(artist) }} - - -
+{% include 'partials/info_artist.jinja' %} {% set albums_info = dbc.get_albums_artist_appears_on(filterkeys,limitkeys) %} diff --git a/maloja/web/jinja/partials/info_album.jinja b/maloja/web/jinja/partials/info_album.jinja index 8a42325..ccf680f 100644 --- a/maloja/web/jinja/partials/info_album.jinja +++ b/maloja/web/jinja/partials/info_album.jinja @@ -25,7 +25,6 @@ {% if condensed %}{% endif %}

{{ info.album.albumtitle | e }}

{%- if condensed -%}
{% endif %} - {# awards.certs(album) #} #{{ info.position }}
@@ -39,7 +38,7 @@ {{ awards.medals(info) }} {{ awards.topweeks(info) }} - + {{ awards.subcerts(info) }} diff --git a/maloja/web/jinja/partials/info_artist.jinja b/maloja/web/jinja/partials/info_artist.jinja index b338f54..cf2db9c 100644 --- a/maloja/web/jinja/partials/info_artist.jinja +++ b/maloja/web/jinja/partials/info_artist.jinja @@ -54,7 +54,7 @@ {{ awards.medals(info) }} {{ awards.topweeks(info) }} {% endif %} - {{ awards.certs(artist) }} + {{ awards.subcerts(artist) }} diff --git a/maloja/web/jinja/partials/info_track.jinja b/maloja/web/jinja/partials/info_track.jinja index 4852eeb..5c753bf 100644 --- a/maloja/web/jinja/partials/info_track.jinja +++ b/maloja/web/jinja/partials/info_track.jinja @@ -26,7 +26,6 @@ {% if condensed %}{% endif %}

{{ info.track.title | e }}

{%- if condensed -%}
{% endif %} - {%- if not condensed -%}{{ awards.certs(track) }}{% endif %} #{{ info.position }}
{% if info.track.album %} diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 4aaf990..3e77814 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -55,45 +55,7 @@ {% import 'partials/awards_track.jinja' as awards %} - - - - - -
- {% if adminmode %} -
- {% else %} -
-
- {% endif %} -
- {{ links.links(track.artists) }}
-

{{ info.track.title | e }}

- #{{ info.position }} -
- {% if info.track.album %} - from {{ links.link(info.track.album) }}
- {% endif %} - -

- {% if adminmode %}{% endif %} - {{ info['scrobbles'] }} Scrobbles -

- - - - - - {{ awards.medals(info) }} - {{ awards.topweeks(info) }} - - -
+{% include 'partials/info_track.jinja' %} From 59df62013687c93ec921de5232c5d740dc827480 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 13:47:10 +0200 Subject: [PATCH 116/176] Made things SHINY --- .../web/jinja/partials/album_showcase.jinja | 5 +- maloja/web/jinja/partials/awards_album.jinja | 6 +- maloja/web/jinja/partials/awards_artist.jinja | 6 +- maloja/web/jinja/partials/awards_track.jinja | 6 +- maloja/web/jinja/partials/info_album.jinja | 4 +- maloja/web/jinja/partials/info_artist.jinja | 4 +- maloja/web/jinja/partials/info_track.jinja | 4 +- maloja/web/static/css/maloja.css | 68 +++++++++++++------ 8 files changed, 66 insertions(+), 37 deletions(-) diff --git a/maloja/web/jinja/partials/album_showcase.jinja b/maloja/web/jinja/partials/album_showcase.jinja index ab53070..30b178c 100644 --- a/maloja/web/jinja/partials/album_showcase.jinja +++ b/maloja/web/jinja/partials/album_showcase.jinja @@ -11,7 +11,7 @@
 
-
+
@@ -23,11 +23,12 @@ {% endfor %} {% for album in otheralbums %} +{% set info = dbc.album_info({'album':album}) %} diff --git a/maloja/web/jinja/partials/info_artist.jinja b/maloja/web/jinja/partials/info_artist.jinja index cf2db9c..761938d 100644 --- a/maloja/web/jinja/partials/info_artist.jinja +++ b/maloja/web/jinja/partials/info_artist.jinja @@ -22,12 +22,12 @@ diff --git a/maloja/web/jinja/partials/info_track.jinja b/maloja/web/jinja/partials/info_track.jinja index 5c753bf..a7d794f 100644 --- a/maloja/web/jinja/partials/info_track.jinja +++ b/maloja/web/jinja/partials/info_track.jinja @@ -12,12 +12,12 @@ diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 6241168..4766a20 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -18,21 +18,28 @@ body { */ } + body.certified { - background: radial-gradient(circle at top left, rgba(var(--bg_special_color),0.5) 0%, var(--current-bg-color) 20%); - background-position: top left; + background: radial-gradient(circle at top left, rgba(var(--shine_color),0.2) 0%, var(--current-bg-color) 20%); + background-position: 0px 0px; background-repeat: no-repeat; background-attachment: fixed; height:100%; } -body.certified.certified_diamond { - --bg_special_color: var(--color-diamond); + + +.certified_diamond { + --shine_color: var(--color-diamond); } -body.certified.certified_platinum{ - --bg_special_color: var(--color-platinum); +.certified_platinum{ + --shine_color: var(--color-platinum); } -body.certified.certified_gold { - --bg_special_color: var(--color-gold); +.certified_gold { + --shine_color: var(--color-gold); +} + +body.certified .top_info .image div { + position: relative; } @@ -498,6 +505,8 @@ h2.headerwithextra+span.afterheader { padding:3px; margin:2px; border-radius:2px; + color:black; + --shine_color: 255, 255, 255; } .shiny { overflow: hidden; @@ -518,13 +527,13 @@ h2.headerwithextra+span.afterheader { background: rgba(255, 255, 255, 0.13); background: linear-gradient( to right, - rgba(255, 255, 255, 0.13) 0%, - rgba(255, 255, 255, 0.13) 77%, - rgba(255, 255, 255, 0.5) 92%, - rgba(255, 255, 255, 0.0) 100% + rgba(var(--shine_color), 0.0) 0%, + rgba(var(--shine_color), 0.13) 77%, + rgba(var(--shine_color), 0.5) 92%, + rgba(var(--shine_color), 0.0) 100% ); } -.shiny:hover:after { +.shiny.hovershiny:hover:after { opacity: 1; top: -30%; left: -30%; @@ -532,24 +541,43 @@ h2.headerwithextra+span.afterheader { transition-duration: 0.7s, 0.7s, 0.15s; transition-timing-function: ease; } -.shiny:active:after { +.shiny.hovershiny:active:after { opacity: 0; } +.shiny.alwaysshiny:after { + animation: shiny 9s infinite linear; +} -a.gold { +.shiny.gold { background-color:gold; - color:black; } -a.silver { +.shiny.silver { background-color:silver; - color:black; } -a.bronze { +.shiny.bronze { background-color:#cd7f32; - color:black; } +@keyframes shiny { + 0% { + top: -110%; + left: -210%; + opacity: 0; + } + 50% { + top: -30%; + left: -30%; + opacity: 1; + } + 100% { + top: -30%; + left: -30%; + opacity: 0; + } +} + + img.certrecord { From 0ef46148aeb2c509e1decd9236d35685f533df0e Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 13:52:24 +0200 Subject: [PATCH 117/176] Added missing info retrieval --- maloja/web/jinja/partials/album_showcase.jinja | 1 + 1 file changed, 1 insertion(+) diff --git a/maloja/web/jinja/partials/album_showcase.jinja b/maloja/web/jinja/partials/album_showcase.jinja index 30b178c..451ce25 100644 --- a/maloja/web/jinja/partials/album_showcase.jinja +++ b/maloja/web/jinja/partials/album_showcase.jinja @@ -7,6 +7,7 @@
{% for album in ownalbums %} +{% set info = dbc.album_info({'album':album}) %}
Appears on
-
+
diff --git a/maloja/web/jinja/partials/awards_album.jinja b/maloja/web/jinja/partials/awards_album.jinja index d83f0e0..668bce5 100644 --- a/maloja/web/jinja/partials/awards_album.jinja +++ b/maloja/web/jinja/partials/awards_album.jinja @@ -4,17 +4,17 @@ {% for year in info.medals.gold -%} - + {{ year }} {%- endfor %} {% for year in info.medals.silver -%} - + {{ year }} {%- endfor %} {% for year in info.medals.bronze -%} - + {{ year }} {%- endfor %} diff --git a/maloja/web/jinja/partials/awards_artist.jinja b/maloja/web/jinja/partials/awards_artist.jinja index 9492633..30db53e 100644 --- a/maloja/web/jinja/partials/awards_artist.jinja +++ b/maloja/web/jinja/partials/awards_artist.jinja @@ -5,17 +5,17 @@ {% for year in info.medals.gold -%} - + {{ year }} {%- endfor %} {% for year in info.medals.silver -%} - + {{ year }} {%- endfor %} {% for year in info.medals.bronze -%} - + {{ year }} {%- endfor %} diff --git a/maloja/web/jinja/partials/awards_track.jinja b/maloja/web/jinja/partials/awards_track.jinja index 12a1948..907d52a 100644 --- a/maloja/web/jinja/partials/awards_track.jinja +++ b/maloja/web/jinja/partials/awards_track.jinja @@ -2,17 +2,17 @@ {% for year in info.medals.gold -%} - + {{ year }} {%- endfor %} {% for year in info.medals.silver -%} - + {{ year }} {%- endfor %} {% for year in info.medals.bronze -%} - + {{ year }} {%- endfor %} diff --git a/maloja/web/jinja/partials/info_album.jinja b/maloja/web/jinja/partials/info_album.jinja index ccf680f..ed65744 100644 --- a/maloja/web/jinja/partials/info_album.jinja +++ b/maloja/web/jinja/partials/info_album.jinja @@ -11,12 +11,12 @@ {% if adminmode %}
{% else %} -
+
{% endif %}
{% if adminmode %}
{% else %} -
+
{% endif %}
{% if adminmode %}
{% else %} -
+
{% endif %}
 
From 464382f279b8c9004af4cbc0f20b20f7cb57790d Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 14:06:08 +0200 Subject: [PATCH 118/176] Fixed issue with image uploading --- maloja/apis/native_v1.py | 1 + 1 file changed, 1 insertion(+) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 0cab1b5..c51b8bd 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -534,6 +534,7 @@ def add_picture(b64,artist:Multi=[],title=None,albumtitle=None): 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 "associated" in k_filter: del k_filter["associated"] 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) From cc536f026ea3d7479ec9d461344a9bcdbf41bb92 Mon Sep 17 00:00:00 2001 From: krateng Date: Sat, 21 Oct 2023 21:57:50 +0200 Subject: [PATCH 119/176] Fixed issue with selector for performance page --- maloja/web/jinja/performance.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/web/jinja/performance.jinja b/maloja/web/jinja/performance.jinja index dd6c711..1ebcca8 100644 --- a/maloja/web/jinja/performance.jinja +++ b/maloja/web/jinja/performance.jinja @@ -25,7 +25,7 @@
{{ filterdesc.desc(filterkeys,limitkeys,prefix='of') }}

- {% with artistchart = True %} + {% with artistchart = (filterkeys.get('artist') is not none) %} {% include 'snippets/timeselection.jinja' %} {% endwith %} From ccf75898e7ebc454dddc114a893a055c08f78ad2 Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 22 Oct 2023 17:22:16 +0200 Subject: [PATCH 120/176] Added debugging for weird issue --- maloja/database/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index f1ffc41..cc8aeb9 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -621,9 +621,16 @@ def album_info(dbconn=None,**keys): #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"] + try: + c = [e for e in alltimecharts if e["album"] == album][0] + scrobbles = c["scrobbles"] + position = c["rank"] + except IndexError as e: + log(f"Error while finding album chart position for {album}",module="debug_special") + log(f"{e}",module="debug_special") + scrobbles, position = 0,0 + + cert = None threshold_gold, threshold_platinum, threshold_diamond = malojaconfig["SCROBBLES_GOLD_ALBUM","SCROBBLES_PLATINUM_ALBUM","SCROBBLES_DIAMOND_ALBUM"] if scrobbles >= threshold_diamond: cert = "diamond" From 3ec511e9b9af15224ea1d0ed04e16b3396839fbb Mon Sep 17 00:00:00 2001 From: kirinokirino Date: Sun, 22 Oct 2023 18:23:25 +0300 Subject: [PATCH 121/176] Add bash script for installing deps on archlinux. --- install/install_dependencies_arch.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100755 install/install_dependencies_arch.sh diff --git a/install/install_dependencies_arch.sh b/install/install_dependencies_arch.sh new file mode 100755 index 0000000..ab3a614 --- /dev/null +++ b/install/install_dependencies_arch.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +pacman -Syu +pacman -S --needed \ + gcc \ + python3 \ + libxml2 \ + libxslt \ + libffi \ + glibc \ + python-pip \ + linux-headers \ + python \ + python-lxml \ + tzdata \ + libvips From dfa564be0bd1a053c009a4a5ecb97457e4028f7e Mon Sep 17 00:00:00 2001 From: krateng Date: Sun, 22 Oct 2023 19:40:56 +0200 Subject: [PATCH 122/176] Image fetch now uses real entity info instead of just URL filterkeys --- maloja/web/jinja/partials/info_album.jinja | 2 +- maloja/web/jinja/partials/info_artist.jinja | 2 +- maloja/web/jinja/partials/info_track.jinja | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/maloja/web/jinja/partials/info_album.jinja b/maloja/web/jinja/partials/info_album.jinja index ed65744..8119bc8 100644 --- a/maloja/web/jinja/partials/info_album.jinja +++ b/maloja/web/jinja/partials/info_album.jinja @@ -12,7 +12,7 @@ {% if adminmode %}
{% else %} diff --git a/maloja/web/jinja/partials/info_artist.jinja b/maloja/web/jinja/partials/info_artist.jinja index 761938d..30d0026 100644 --- a/maloja/web/jinja/partials/info_artist.jinja +++ b/maloja/web/jinja/partials/info_artist.jinja @@ -23,7 +23,7 @@ {% if adminmode %}
{% else %} diff --git a/maloja/web/jinja/partials/info_track.jinja b/maloja/web/jinja/partials/info_track.jinja index a7d794f..64233cb 100644 --- a/maloja/web/jinja/partials/info_track.jinja +++ b/maloja/web/jinja/partials/info_track.jinja @@ -6,14 +6,13 @@ {% import 'partials/awards_track.jinja' as awards %} -
{% if adminmode %}
{% else %} From 16245701e0f3695870efa3ce05a3ae95e8ba014f Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 01:45:50 +0200 Subject: [PATCH 123/176] More CSS color variables --- maloja/web/static/css/maloja.css | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 4766a20..18abe3a 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -1,9 +1,13 @@ @import url("/grisons.css"); :root { - --color-diamond: 103, 161, 253; - --color-platinum: 229, 228, 226; - --color-gold: 255,215,0; + --color-certified-diamond: 103, 161, 253; + --color-certified-platinum: 229, 228, 226; + --color-certified-gold: 255, 215, 0; + + --color-rank-gold: var(--color-certified-gold); + --color-rank-silver: 192, 192, 192; + --color-rank-bronze: 205, 127, 50; } body { @@ -29,13 +33,13 @@ body.certified { .certified_diamond { - --shine_color: var(--color-diamond); + --shine_color: var(--color-certified-diamond); } .certified_platinum{ - --shine_color: var(--color-platinum); + --shine_color: var(--color-certified-platinum); } .certified_gold { - --shine_color: var(--color-gold); + --shine_color: var(--color-certified-gold); } body.certified .top_info .image div { @@ -550,13 +554,13 @@ h2.headerwithextra+span.afterheader { .shiny.gold { - background-color:gold; + background-color: rgba(var(--color-rank-gold),1); } .shiny.silver { - background-color:silver; + background-color: rgba(var(--color-rank-silver),1); } .shiny.bronze { - background-color:#cd7f32; + background-color: rgba(var(--color-rank-bronze),1); } @keyframes shiny { From fbafbaf1148a3a23304b3887893e7f1540f5e3f8 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 02:20:41 +0200 Subject: [PATCH 124/176] Cooler certification icons --- maloja/web/jinja/icons/cert_album.jinja | 24 ++++++++++++++++ maloja/web/jinja/icons/cert_track.jinja | 26 +++++++++++++++++ maloja/web/jinja/partials/awards_album.jinja | 27 ++++-------------- maloja/web/jinja/partials/awards_artist.jinja | 24 ++++++++++++++-- maloja/web/jinja/partials/awards_track.jinja | 19 ------------ maloja/web/static/css/maloja.css | 6 ++++ maloja/web/static/png/record_diamond.png | Bin 66389 -> 0 bytes maloja/web/static/png/record_gold.png | Bin 55907 -> 0 bytes .../web/static/png/record_gold_original.png | Bin 59456 -> 0 bytes maloja/web/static/png/record_platinum.png | Bin 82606 -> 0 bytes 10 files changed, 83 insertions(+), 43 deletions(-) create mode 100644 maloja/web/jinja/icons/cert_album.jinja create mode 100644 maloja/web/jinja/icons/cert_track.jinja delete mode 100644 maloja/web/static/png/record_diamond.png delete mode 100644 maloja/web/static/png/record_gold.png delete mode 100644 maloja/web/static/png/record_gold_original.png delete mode 100644 maloja/web/static/png/record_platinum.png diff --git a/maloja/web/jinja/icons/cert_album.jinja b/maloja/web/jinja/icons/cert_album.jinja new file mode 100644 index 0000000..f8de66f --- /dev/null +++ b/maloja/web/jinja/icons/cert_album.jinja @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/maloja/web/jinja/icons/cert_track.jinja b/maloja/web/jinja/icons/cert_track.jinja new file mode 100644 index 0000000..31d2e9b --- /dev/null +++ b/maloja/web/jinja/icons/cert_track.jinja @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/maloja/web/jinja/partials/awards_album.jinja b/maloja/web/jinja/partials/awards_album.jinja index 668bce5..5ff5850 100644 --- a/maloja/web/jinja/partials/awards_album.jinja +++ b/maloja/web/jinja/partials/awards_album.jinja @@ -34,31 +34,16 @@ - {% if info.topweeks > 0 %} + {% if info.topweeks > 0 -%} - {{ info.topweeks }} - - {% endif %} + {{ info.topweeks }} + {%- endif %} {%- endmacro %} - -{% macro certs(album) %} - - - -{% set info = db.album_info(album=album) %} -{% if info.certification is not none %} - -{% endif %} - -{%- endmacro %} - {% macro subcerts(album) %} @@ -70,9 +55,9 @@ {%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%} {%- if cert -%} - + + {% include 'icons/cert_track.jinja' %} + {%- endif %} {%- endfor %} diff --git a/maloja/web/jinja/partials/awards_artist.jinja b/maloja/web/jinja/partials/awards_artist.jinja index 30db53e..f545ea7 100644 --- a/maloja/web/jinja/partials/awards_artist.jinja +++ b/maloja/web/jinja/partials/awards_artist.jinja @@ -56,6 +56,22 @@ + +{% set albumcharts = db.get_charts_albums(artist=artist,timerange=malojatime.alltime()) %} +{% for e in albumcharts -%} + {%- if e.scrobbles >= settings.scrobbles_gold_album -%}{% set cert = 'gold' %}{%- endif -%} + {%- if e.scrobbles >= settings.scrobbles_platinum_album -%}{% set cert = 'platinum' %}{%- endif -%} + {%- if e.scrobbles >= settings.scrobbles_diamond_album -%}{% set cert = 'diamond' %}{%- endif -%} + + {%- if cert -%} + + {% include 'icons/cert_album.jinja' %} + + {%- endif %} + +{%- endfor %} + + {% set charts = db.get_charts_tracks(artist=artist,timerange=malojatime.alltime()) %} {% for e in charts -%} {%- if e.scrobbles >= settings.scrobbles_gold -%}{% set cert = 'gold' %}{%- endif -%} @@ -63,11 +79,13 @@ {%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%} {%- if cert -%} - + + {% include 'icons/cert_track.jinja' %} + {%- endif %} {%- endfor %} + + {%- endmacro %} diff --git a/maloja/web/jinja/partials/awards_track.jinja b/maloja/web/jinja/partials/awards_track.jinja index 907d52a..403cebd 100644 --- a/maloja/web/jinja/partials/awards_track.jinja +++ b/maloja/web/jinja/partials/awards_track.jinja @@ -42,22 +42,3 @@ {%- endmacro %} - - - - - - - -{% macro certs(track) %} - - - -{% set info = db.track_info(track=track) %} -{% if info.certification is not none %} - -{% endif %} - -{%- endmacro %} diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 18abe3a..4917685 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -42,6 +42,12 @@ body.certified { --shine_color: var(--color-certified-gold); } +.certified.smallcerticon svg { + /* we just use shine color as the color variable that represents the respective color of the currently applicable + certification - even when no shine is involved */ + fill: rgba(var(--shine_color),1); +} + body.certified .top_info .image div { position: relative; } diff --git a/maloja/web/static/png/record_diamond.png b/maloja/web/static/png/record_diamond.png deleted file mode 100644 index 0c6b4286b7475811ec99cb2c51f9676f9ea5f7ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66389 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 diff --git a/maloja/web/static/png/record_gold_original.png b/maloja/web/static/png/record_gold_original.png deleted file mode 100644 index 631b31afa5fa1ba833514a5e143c29f669b0cbcd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59456 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�z64PpHDK4-*IlkCFLn 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�#-z8fzotq*x?i1Kyo>q6>jwlmFFXZ9aQ>vS)8_ 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 N_fc9|stRHp{6BqX2oeAQ From f10e22e6a0b156434da4a6ff1f9dcd0e6e21e4f6 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 02:34:36 +0200 Subject: [PATCH 125/176] CSS variable reorganization --- maloja/web/static/css/maloja.css | 56 +++++++++++++++++--------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 4917685..bcf3a55 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -24,32 +24,27 @@ body { body.certified { - background: radial-gradient(circle at top left, rgba(var(--shine_color),0.2) 0%, var(--current-bg-color) 20%); + background: radial-gradient(circle at top left, rgba(var(--certification-color),0.5) 0%, var(--current-bg-color) 20%); background-position: 0px 0px; background-repeat: no-repeat; background-attachment: fixed; height:100%; } - +/* just make sure that whenever anything is certified, we set this variable. + how its used is the job of the specific elements */ .certified_diamond { - --shine_color: var(--color-certified-diamond); + --certification-color: var(--color-certified-diamond); } .certified_platinum{ - --shine_color: var(--color-certified-platinum); + --certification-color: var(--color-certified-platinum); } .certified_gold { - --shine_color: var(--color-certified-gold); + --certification-color: var(--color-certified-gold); } .certified.smallcerticon svg { - /* we just use shine color as the color variable that represents the respective color of the currently applicable - certification - even when no shine is involved */ - fill: rgba(var(--shine_color),1); -} - -body.certified .top_info .image div { - position: relative; + fill: rgba(var(--certification-color),1); } @@ -516,14 +511,30 @@ h2.headerwithextra+span.afterheader { margin:2px; border-radius:2px; color:black; - --shine_color: 255, 255, 255; + --shine-color: 255, 255, 255; + background-color: rgba(var(--rank-color),1); } +.medal.gold { + --rank-color: var(--color-rank-gold); +} +.medal.silver { + --rank-color: var(--color-rank-silver); +} +.medal.bronze { + --rank-color: var(--color-rank-bronze); +} + .shiny { overflow: hidden; position:relative; display: inline-block; } +.alwaysshiny { + /* alwaysshiny elements in general use the certification color for their shine */ + --shine-color: var(--certification-color); +} + .shiny:after { content: ""; position: absolute; @@ -537,10 +548,11 @@ h2.headerwithextra+span.afterheader { background: rgba(255, 255, 255, 0.13); background: linear-gradient( to right, - rgba(var(--shine_color), 0.0) 0%, - rgba(var(--shine_color), 0.13) 77%, - rgba(var(--shine_color), 0.5) 92%, - rgba(var(--shine_color), 0.0) 100% + rgba(var(--shine-color), 0.0) 0%, + rgba(var(--shine-color), 0.13) 77%, + rgba(var(--shine-color), 0.5) 92%, + rgba(var(--shine-color), 0.0) 100% + /* shiny things just use the shine-color var, they are unaware of cert status directly */ ); } .shiny.hovershiny:hover:after { @@ -559,16 +571,6 @@ h2.headerwithextra+span.afterheader { } -.shiny.gold { - background-color: rgba(var(--color-rank-gold),1); -} -.shiny.silver { - background-color: rgba(var(--color-rank-silver),1); -} -.shiny.bronze { - background-color: rgba(var(--color-rank-bronze),1); -} - @keyframes shiny { 0% { top: -110%; From 8bb6d9d7ad54b0b2bec0671a53d619665ac1212c Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 11:48:16 +0200 Subject: [PATCH 126/176] Profiler changes --- maloja/dev/profiler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/maloja/dev/profiler.py b/maloja/dev/profiler.py index 8d41455..209ffb2 100644 --- a/maloja/dev/profiler.py +++ b/maloja/dev/profiler.py @@ -28,10 +28,14 @@ def profile(func): if FULL_PROFILE: profiler.disable() - log(f"Executed {func.__name__} ({args}, {kwargs}) in {clock.stop():.2f}s",module="debug_performance") + seconds = clock.stop() + realfunc = func + while(hasattr(realfunc,'__innerfunc__')): + realfunc = realfunc.__innerfunc__ + log(f"Executed {realfunc.__name__} ({args}, {kwargs}) in {seconds:.2f}s",module="debug_performance") if FULL_PROFILE: try: - pstats.Stats(profiler).dump_stats(os.path.join(benchmarkfolder,f"{func.__name__}.stats")) + pstats.Stats(profiler).dump_stats(os.path.join(benchmarkfolder,f"{realfunc.__name__}.stats")) except Exception: pass From cf6f50169c65a5d03b8451d7f02bda53765853f8 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 12:52:50 +0200 Subject: [PATCH 127/176] Fixed error with Spotify album art fetching --- maloja/thirdparty/__init__.py | 2 +- maloja/thirdparty/spotify.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/maloja/thirdparty/__init__.py b/maloja/thirdparty/__init__.py index 75476f9..fe73bfd 100644 --- a/maloja/thirdparty/__init__.py +++ b/maloja/thirdparty/__init__.py @@ -230,7 +230,7 @@ class MetadataInterface(GenericInterface,abstract=True): def get_image_album(self,album): artists, title = album - artiststring = urllib.parse.quote(", ".join(artists)) + artiststring = urllib.parse.quote(", ".join(artists or [])) titlestring = urllib.parse.quote(title) response = urllib.request.urlopen( self.metadata["albumurl"].format(artist=artiststring,title=titlestring,**self.settings) diff --git a/maloja/thirdparty/spotify.py b/maloja/thirdparty/spotify.py index 2665865..898b379 100644 --- a/maloja/thirdparty/spotify.py +++ b/maloja/thirdparty/spotify.py @@ -15,7 +15,7 @@ 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}", + "albumurl": "https://api.spotify.com/v1/search?q=artist:{artist}%20album:{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"], # use album art From bcee8f56609be6a504a3381b26314bf0a80da0a7 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 16:15:11 +0200 Subject: [PATCH 128/176] Adjusted Spotify image fetching, GH-225 --- maloja/thirdparty/spotify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/maloja/thirdparty/spotify.py b/maloja/thirdparty/spotify.py index 898b379..ae6639b 100644 --- a/maloja/thirdparty/spotify.py +++ b/maloja/thirdparty/spotify.py @@ -14,9 +14,9 @@ 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}%20album:{title}&type=album&access_token={token}", - "artisturl": "https://api.spotify.com/v1/search?q=artist:{artist}&type=artist&access_token={token}", + "trackurl": "https://api.spotify.com/v1/search?q={title}&artist:{artist}%20type=track&access_token={token}", + "albumurl": "https://api.spotify.com/v1/search?q={title}&artist:{artist}%20type=album&access_token={token}", + "artisturl": "https://api.spotify.com/v1/search?q={artist}&type=artist&access_token={token}", "response_type":"json", "response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"], # use album art "response_parse_tree_album": ["albums","items",0,"images",0,"url"], From e3a551cb126ef60edfd68b0b24b10e4c32d4c4a0 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 23 Oct 2023 17:49:22 +0200 Subject: [PATCH 129/176] Do not code while distracted! --- maloja/thirdparty/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maloja/thirdparty/spotify.py b/maloja/thirdparty/spotify.py index ae6639b..8fedb77 100644 --- a/maloja/thirdparty/spotify.py +++ b/maloja/thirdparty/spotify.py @@ -14,8 +14,8 @@ class Spotify(MetadataInterface): } metadata = { - "trackurl": "https://api.spotify.com/v1/search?q={title}&artist:{artist}%20type=track&access_token={token}", - "albumurl": "https://api.spotify.com/v1/search?q={title}&artist:{artist}%20type=album&access_token={token}", + "trackurl": "https://api.spotify.com/v1/search?q={title}%20artist:{artist}&type=track&access_token={token}", + "albumurl": "https://api.spotify.com/v1/search?q={title}%20artist:{artist}&type=album&access_token={token}", "artisturl": "https://api.spotify.com/v1/search?q={artist}&type=artist&access_token={token}", "response_type":"json", "response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"], # use album art From 91e51a016781fa677757d6833c2a5d2735418051 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 14:14:26 +0200 Subject: [PATCH 130/176] Shortened Readme a bit --- README.md | 100 ++++++++++++++++++++++-------------------------------- 1 file changed, 41 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5ea06ac..d1fb4dd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![](https://img.shields.io/pypi/v/malojaserver?label=PyPI&style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/malojaserver/) [![](https://img.shields.io/docker/v/krateng/maloja?label=Dockerhub&style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/krateng/maloja) -Simple self-hosted music scrobble database to create personal listening statistics. No recommendations, no social network, no nonsense. +Simple self-hosted music scrobble database to create personal listening statistics. ![screenshot](https://raw.githubusercontent.com/krateng/maloja/master/screenshot.png) @@ -15,9 +15,9 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst * [Features](#features) * [How to install](#how-to-install) * [Requirements](#requirements) + * [Docker / Podman](#docker--podman) * [PyPI](#pypi) * [From Source](#from-source) - * [Docker / Podman](#docker--podman) * [Extras](#extras) * [How to use](#how-to-use) * [Basic control](#basic-control) @@ -44,10 +44,46 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst Maloja should run on any x86 or ARM machine that runs Python. -I can support you with issues best if you use **Alpine Linux**. +It is highly recommended to use **Docker** or **Podman**. Your CPU should have a single core passmark score of at the very least 1500. 500 MB RAM should give you a decent experience, but performance will benefit greatly from up to 2 GB. +### Docker / Podman + +Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile. + +Of note are these settings which should be passed as environmental variables to the container: + +* `MALOJA_SKIP_SETUP` -- Make the server setup process non-interactive. Maloja will not work properly in a container without this variable set. This is done by default in the provided Containerfile. +* `MALOJA_FORCE_PASSWORD` -- Set an admin password for Maloja. You only need this on the first run. +* `MALOJA_DATA_DIRECTORY` -- Set the directory in the container where configuration folders/files should be located + * Mount a [volume](https://docs.docker.com/engine/reference/builder/#volume) to the specified directory to access these files outside the container (and to make them persistent) + +You must publish a port on your host machine to bind to the container's web port (default 42010). The container uses IPv4 per default. + +An example of a minimum run configuration to access maloja via `localhost:42010`: + +```console + docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja +``` + +#### Linux Host + +**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you. + +If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid) + +To get the UID and GID for the current user run these commands from a terminal: + +* `id -u` -- prints UID (EX `1000`) +* `id -g` -- prints GID (EX `1001`) + +The modified run command with these variables would look like: + +```console + docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja +``` + ### PyPI You can install Maloja with @@ -75,41 +111,6 @@ Then install all the requirements and build the package, e.g.: pip install . ``` -### Docker / Podman - -Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile. - -Of note are these settings which should be passed as environmental variables to the container: - -* `MALOJA_SKIP_SETUP` -- Make the server setup process non-interactive. Maloja will not work properly in a container without this variable set. This is done by default in the provided Containerfile. -* `MALOJA_FORCE_PASSWORD` -- Set an admin password for Maloja. You only need this on the first run. -* `MALOJA_DATA_DIRECTORY` -- Set the directory in the container where configuration folders/files should be located - * Mount a [volume](https://docs.docker.com/engine/reference/builder/#volume) to the specified directory to access these files outside the container (and to make them persistent) - -You must publish a port on your host machine to bind to the container's web port (default 42010). The container uses IPv4 per default. - -An example of a minimum run configuration to access maloja via `localhost:42010`: - -```console - docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja -``` - -#### Linux Host - -**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you. - -If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid) - -To get the UID and GID for the current user run these commands from a terminal: - -* `id -u` -- prints UID (EX `1000`) -* `id -g` -- prints GID (EX `1001`) - -The modified run command with these variables would look like: - -```console - docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja -``` ### Extras @@ -117,31 +118,13 @@ The modified run command with these variables would look like: * Put your server behind a reverse proxy for SSL encryption. Make sure that you're proxying to the IPv6 or IPv4 address according to your settings. -* You can set up a cronjob to start your server on system boot, and potentially restart it on a regular basis: - -``` -@reboot sleep 15 && maloja start -42 0 7 * * maloja restart -``` - ## How to use ### Basic control -Start and stop the server in the background with - -```console - maloja start - maloja stop - maloja restart -``` - -If you need to run the server in the foreground, use - -```console - maloja run -``` +When not running in a container, you can run the application with `maloja run`. You can also run it in the background with +`maloja start` and `maloja stop`, but this might not be supported in the future. ### Data @@ -161,7 +144,6 @@ If you would like to import your previous scrobbles, use the command `maloja imp maloja import my_last_fm_export.csv ``` ---- To backup your data, run `maloja backup`, optional with `--include_images`. From f8e65cd6114f53f67ed3f7089352871ce0429a23 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 16:42:02 +0200 Subject: [PATCH 131/176] Some performance tweaks --- maloja/database/__init__.py | 23 ++++++++++++++++++----- maloja/database/sqldb.py | 21 +++++++++++++++------ maloja/dev/profiler.py | 6 ++++-- maloja/web/jinja/partials/scrobbles.jinja | 2 -- maloja/web/jinja/scrobbles.jinja | 5 +++-- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index cc8aeb9..24419d4 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -321,17 +321,30 @@ def associate_tracks_to_album(target_id,source_ids): @waitfordb def get_scrobbles(dbconn=None,**keys): (since,to) = keys.get('timerange').timestamps() + + reverse = keys.get('reverse',True) # comaptibility with old calls + if 'perpage' in keys: + limit = (keys.get('page',0)+1) * keys.get('perpage',100) + behead = keys.get('page',0) * keys.get('perpage',100) + else: + limit = None + behead = 0 + + associated = keys.get('associated',False) if 'artist' in keys: - result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,dbconn=dbconn) + result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,limit=limit,reverse=reverse,dbconn=dbconn) elif 'track' in keys: - result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,dbconn=dbconn) + result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn) elif 'album' in keys: - result = sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,dbconn=dbconn) + result = sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn) else: - result = sqldb.get_scrobbles(since=since,to=to,dbconn=dbconn) + result = sqldb.get_scrobbles(since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn) #return result[keys['page']*keys['perpage']:(keys['page']+1)*keys['perpage']] - return list(reversed(result)) + + #print(result) + + return list(result[behead:]) @waitfordb diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index e94246d..cd730cb 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -898,20 +898,30 @@ def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,dbco @cached_wrapper @connection_provider -def get_scrobbles(since=None,to=None,resolve_references=True,dbconn=None): +def get_scrobbles(since=None,to=None,resolve_references=True,limit=None,reverse=False,dbconn=None): + if since is None: since=0 if to is None: to=now() op = DB['scrobbles'].select().where( - DB['scrobbles'].c.timestamp<=to, - DB['scrobbles'].c.timestamp>=since, - ).order_by(sql.asc('timestamp')) + DB['scrobbles'].c.timestamp.between(since,to) + ) + if reverse: + op = op.order_by(sql.desc('timestamp')) + else: + op = op.order_by(sql.asc('timestamp')) + if limit: + op = op.limit(limit) + + result = dbconn.execute(op).all() if resolve_references: result = scrobbles_db_to_dict(result,dbconn=dbconn) #result = [scrobble_db_to_dict(row,resolve_references=resolve_references) for i,row in enumerate(result) if i=since, + DB['scrobbles'].c.timestamp.between(since,to) ) result = dbconn.execute(op).all() diff --git a/maloja/dev/profiler.py b/maloja/dev/profiler.py index 209ffb2..29bd231 100644 --- a/maloja/dev/profiler.py +++ b/maloja/dev/profiler.py @@ -34,10 +34,12 @@ def profile(func): realfunc = realfunc.__innerfunc__ log(f"Executed {realfunc.__name__} ({args}, {kwargs}) in {seconds:.2f}s",module="debug_performance") if FULL_PROFILE: + targetfilename = os.path.join(benchmarkfolder,f"{realfunc.__name__}.stats") try: - pstats.Stats(profiler).dump_stats(os.path.join(benchmarkfolder,f"{realfunc.__name__}.stats")) + pstats.Stats(profiler).dump_stats(targetfilename) + log(f"Saved benchmark as {targetfilename}") except Exception: - pass + log(f"Failed to save benchmark as {targetfilename}") return result diff --git a/maloja/web/jinja/partials/scrobbles.jinja b/maloja/web/jinja/partials/scrobbles.jinja index 32d839b..e54bd72 100644 --- a/maloja/web/jinja/partials/scrobbles.jinja +++ b/maloja/web/jinja/partials/scrobbles.jinja @@ -8,7 +8,6 @@ {% for s in scrobbles -%} - {%- if loop.index0 >= firstindex and loop.index0 < lastindex -%} {{ entityrow.row(s.track) }} @@ -41,6 +40,5 @@ {% endif %} - {%- endif -%} {% endfor %}
{{ malojatime.timestamp_desc(s["time"],short=shortTimeDesc) }}
diff --git a/maloja/web/jinja/scrobbles.jinja b/maloja/web/jinja/scrobbles.jinja index 0331bb9..a3cdbcb 100644 --- a/maloja/web/jinja/scrobbles.jinja +++ b/maloja/web/jinja/scrobbles.jinja @@ -4,8 +4,9 @@ {% import 'snippets/filterdescription.jinja' as filterdesc %} {% import 'snippets/pagination.jinja' as pagination %} +{% set totalscrobbles = dbc.get_scrobbles_num(filterkeys,limitkeys) %} {% set scrobbles = dbc.get_scrobbles(filterkeys,limitkeys,amountkeys) %} -{% set pages = math.ceil(scrobbles.__len__() / amountkeys.perpage) %} +{% set pages = math.ceil(totalscrobbles / amountkeys.perpage) %} {% if filterkeys.get('track') is not none %} {% set img = images.get_track_image(filterkeys.track) %} @@ -29,7 +30,7 @@

Scrobbles


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

{{ scrobbles.__len__() }} Scrobbles

+

{{ totalscrobbles }} Scrobbles


{% with delimitkeys = {} %} {% include 'snippets/timeselection.jinja' %} From 32b17c6a2cb35ec8849952261038bc389448de1c Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 16:42:43 +0200 Subject: [PATCH 132/176] Fixed duplicate albums and tracks from association edits --- maloja/database/sqldb.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index cd730cb..c3d8c63 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -650,7 +650,10 @@ def add_artists_to_tracks(track_ids,artist_ids,dbconn=None): ]) result = dbconn.execute(op) - clean_db(dbconn=dbconn) + + # the resulting tracks could now be duplicates of existing ones + # this also takes care of clean_db + merge_duplicate_tracks(dbconn=dbconn) return True @@ -673,7 +676,10 @@ def remove_artists_from_tracks(track_ids,artist_ids,dbconn=None): ) result = dbconn.execute(op) - clean_db(dbconn=dbconn) + + # the resulting tracks could now be duplicates of existing ones + # this also takes care of clean_db + merge_duplicate_tracks(dbconn=dbconn) return True @@ -687,7 +693,10 @@ def add_artists_to_albums(album_ids,artist_ids,dbconn=None): ]) result = dbconn.execute(op) - clean_db(dbconn=dbconn) + + # the resulting albums could now be duplicates of existing ones + # this also takes care of clean_db + merge_duplicate_albums(dbconn=dbconn) return True @@ -705,7 +714,10 @@ def remove_artists_from_albums(album_ids,artist_ids,dbconn=None): ) result = dbconn.execute(op) - clean_db(dbconn=dbconn) + + # the resulting albums could now be duplicates of existing ones + # this also takes care of clean_db + merge_duplicate_albums(dbconn=dbconn) return True @@ -1587,10 +1599,15 @@ def renormalize_names(): @connection_provider -def merge_duplicate_tracks(artist_id,dbconn=None): +def merge_duplicate_tracks(artist_id=None,dbconn=None): + + affected_track_conditions = [] + if artist_id: + affected_track_conditions = [DB['trackartists'].c.artist_id == artist_id] + rows = dbconn.execute( DB['trackartists'].select().where( - DB['trackartists'].c.artist_id == artist_id + *affected_track_conditions ) ) affected_tracks = [r.track_id for r in rows] @@ -1623,10 +1640,15 @@ def merge_duplicate_tracks(artist_id,dbconn=None): @connection_provider -def merge_duplicate_albums(artist_id,dbconn=None): +def merge_duplicate_albums(artist_id=None,dbconn=None): + + affected_album_conditions = [] + if artist_id: + affected_album_conditions = [DB['albumartists'].c.artist_id == artist_id] + rows = dbconn.execute( DB['albumartists'].select().where( - DB['albumartists'].c.artist_id == artist_id + *affected_album_conditions ) ) affected_albums = [r.album_id for r in rows] From 4d6b2647d16d6d3eb56baed58b873fda10a852c2 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 18:00:21 +0200 Subject: [PATCH 133/176] More performance tweaks --- maloja/database/__init__.py | 4 +++- maloja/database/sqldb.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 24419d4..a68d6c5 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -399,9 +399,11 @@ def get_charts_artists(dbconn=None,resolve_ids=True,**keys): (since,to) = keys.get('timerange').timestamps() separate = keys.get('separate',False) result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=(not separate),dbconn=dbconn) + + map = sqldb.get_associated_artist_map([entry['artist'] for entry in result if 'artist' in entry]) for entry in result: if "artist" in entry: - entry['associated_artists'] = sqldb.get_associated_artists(entry['artist']) + entry['associated_artists'] = map[entry['artist']] return result @waitfordb diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index c3d8c63..282a680 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1429,6 +1429,40 @@ def get_associated_artists(*artists,resolve_ids=True,dbconn=None): else: return [a.id for a in result] +@cached_wrapper +@connection_provider +def get_associated_artist_map(artists,resolve_ids=True,dbconn=None): + artist_ids = [get_artist_id(a,dbconn=dbconn) for a in artists] + + + jointable = sql.join( + DB['associated_artists'], + DB['artists'], + DB['associated_artists'].c.source_artist == DB['artists'].c.id + ) + + # we need to select to avoid multiple 'id' columns that will then + # be misinterpreted by the row-dict converter + op = sql.select( + DB['artists'], + DB['associated_artists'].c.target_artist + ).select_from(jointable).where( + DB['associated_artists'].c.target_artist.in_(artist_ids) + ) + result = dbconn.execute(op).all() + + artists_to_associated = {a_id:[] for a_id in artist_ids} + for row in result: + if resolve_ids: + artists_to_associated[row.target_artist].append(artists_db_to_dict([row],dbconn=dbconn)[0]) + else: + artists_to_associated[row.target_artist].append(row.id) + + artists_to_associated = {artists[artist_ids.index(k)]:v for k,v in artists_to_associated.items()} + + return artists_to_associated + + @cached_wrapper @connection_provider def get_credited_artists(*artists,dbconn=None): From d0f265d3caacc0c2f4703a576a2b4460d7abc951 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 18:05:12 +0200 Subject: [PATCH 134/176] Added missing parts to other scrobble list functions --- maloja/database/sqldb.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 282a680..8ce795f 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -828,7 +828,7 @@ def merge_albums(target_id,source_ids,dbconn=None): @cached_wrapper @connection_provider -def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,associated=False,dbconn=None): +def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,limit=None,reverse=False,associated=False,dbconn=None): if since is None: since=0 if to is None: to=now() @@ -845,7 +845,13 @@ def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,as DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, DB['trackartists'].c.artist_id.in_(artist_ids) - ).order_by(sql.asc('timestamp')) + ) + if reverse: + op = op.order_by(sql.desc('timestamp')) + else: + op = op.order_by(sql.asc('timestamp')) + if limit: + op = op.limit(limit) result = dbconn.execute(op).all() # remove duplicates (multiple associated artists in the song, e.g. Irene & Seulgi being both counted as Red Velvet) @@ -866,7 +872,7 @@ def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,as @cached_wrapper @connection_provider -def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbconn=None): +def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,limit=None,reverse=False,dbconn=None): if since is None: since=0 if to is None: to=now() @@ -877,7 +883,13 @@ def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbco DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, DB['scrobbles'].c.track_id==track_id - ).order_by(sql.asc('timestamp')) + ) + if reverse: + op = op.order_by(sql.desc('timestamp')) + else: + op = op.order_by(sql.asc('timestamp')) + if limit: + op = op.limit(limit) result = dbconn.execute(op).all() if resolve_references: @@ -887,7 +899,7 @@ def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbco @cached_wrapper @connection_provider -def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,dbconn=None): +def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,limit=None,reverse=False,dbconn=None): if since is None: since=0 if to is None: to=now() @@ -900,7 +912,13 @@ def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,dbco DB['scrobbles'].c.timestamp<=to, DB['scrobbles'].c.timestamp>=since, DB['tracks'].c.album_id==album_id - ).order_by(sql.asc('timestamp')) + ) + if reverse: + op = op.order_by(sql.desc('timestamp')) + else: + op = op.order_by(sql.asc('timestamp')) + if limit: + op = op.limit(limit) result = dbconn.execute(op).all() if resolve_references: From 0ed2cd2e35ba3bc9a055ad93df4d10f85cd79447 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 19:10:25 +0200 Subject: [PATCH 135/176] MOOOORE PERFORMANCE!!! --- maloja/database/__init__.py | 10 ++++++---- maloja/database/sqldb.py | 12 +++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index a68d6c5..9bb1bd4 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -400,10 +400,12 @@ def get_charts_artists(dbconn=None,resolve_ids=True,**keys): separate = keys.get('separate',False) result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=(not separate),dbconn=dbconn) - map = sqldb.get_associated_artist_map([entry['artist'] for entry in result if 'artist' in entry]) - for entry in result: - if "artist" in entry: - entry['associated_artists'] = map[entry['artist']] + if resolve_ids: + # only add associated info if we resolve + map = sqldb.get_associated_artist_map(artist_ids=[entry['artist_id'] for entry in result if 'artist_id' in entry]) + for entry in result: + if "artist_id" in entry: + entry['associated_artists'] = map[entry['artist_id']] return result @waitfordb diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 8ce795f..6ff019d 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1449,8 +1449,12 @@ def get_associated_artists(*artists,resolve_ids=True,dbconn=None): @cached_wrapper @connection_provider -def get_associated_artist_map(artists,resolve_ids=True,dbconn=None): - artist_ids = [get_artist_id(a,dbconn=dbconn) for a in artists] +def get_associated_artist_map(artists=[],artist_ids=None,resolve_ids=True,dbconn=None): + + ids_supplied = (artist_ids is not None) + + if not ids_supplied: + artist_ids = [get_artist_id(a,dbconn=dbconn) for a in artists] jointable = sql.join( @@ -1476,7 +1480,9 @@ def get_associated_artist_map(artists,resolve_ids=True,dbconn=None): else: artists_to_associated[row.target_artist].append(row.id) - artists_to_associated = {artists[artist_ids.index(k)]:v for k,v in artists_to_associated.items()} + if not ids_supplied: + # if we supplied the artists, we want to convert back for the result + artists_to_associated = {artists[artist_ids.index(k)]:v for k,v in artists_to_associated.items()} return artists_to_associated From f33df482fb2a9339fee0d40b5d2dcdf582be0e18 Mon Sep 17 00:00:00 2001 From: krateng Date: Tue, 24 Oct 2023 20:22:57 +0200 Subject: [PATCH 136/176] Highly questionable adjustments to footer design --- dev/releases/3.2.yml | 1 + maloja/thirdparty/spotify.py | 2 +- maloja/web/jinja/abstracts/base.jinja | 25 +++--- maloja/web/static/css/maloja.css | 110 +++++++++++++------------- 4 files changed, 65 insertions(+), 73 deletions(-) diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index f416adc..e356eb7 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -9,6 +9,7 @@ minor_release_name: "Nicole" - "[Feature] Added inline UI for association and merging in chart lists" - "[Feature] Added UI selector for including associated artists" - "[Performance] Improved image rendering" + - "[Performance] Optimized several database calls" - "[Bugfix] Fixed configuration of time format" - "[Bugfix] Fixed search on manual scrobble page" - "[Bugfix] Disabled DB maintenance while not running main server" diff --git a/maloja/thirdparty/spotify.py b/maloja/thirdparty/spotify.py index 8fedb77..3a8e55f 100644 --- a/maloja/thirdparty/spotify.py +++ b/maloja/thirdparty/spotify.py @@ -46,7 +46,7 @@ class Spotify(MetadataInterface): else: expire = responsedata.get("expires_in",3600) self.settings["token"] = responsedata["access_token"] - log("Successfully authenticated with Spotify") + #log("Successfully authenticated with Spotify") Timer(expire,self.authorize).start() except Exception as e: log("Error while authenticating with Spotify: " + repr(e)) diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index dd6cc5c..be9c746 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -67,22 +67,17 @@ +