Merge branch 'feature-albums' into next_minor_version

This commit is contained in:
krateng 2023-03-28 22:41:03 +02:00
commit 7eb2ae11aa
33 changed files with 1087 additions and 97 deletions

View File

@ -2,6 +2,7 @@ minor_release_name: "Momo"
3.2.0:
notes:
- "[Architecture] Switched to linuxserver.io container base image"
- "[Feature] Added basic support for albums"
- "[Performance] Improved image rendering"
- "[Bugfix] Fixed configuration of time format"
- "[Bugfix] Fixed search on manual scrobble page"

View File

@ -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": ""

View File

@ -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

View File

@ -470,7 +470,7 @@ def post_scrobble(
rawscrobble = {
'track_artists':(artist or []) + artists,
'track_title':title,
'album_name':album,
'album_title':album,
'album_artists':albumartists,
'scrobble_duration':duration,
'track_length':length,
@ -494,19 +494,23 @@ def post_scrobble(
'artists':result['track']['artists'],
'title':result['track']['title']
},
'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}"
'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}",
'warnings':[]
}
if extra_kwargs:
responsedict['warnings'] = [
responsedict['warnings'] += [
{'type':'invalid_keyword_ignored','value':k,
'desc':"This key was not recognized by the server and has been discarded."}
for k in extra_kwargs
]
if artist and artists:
responsedict['warnings'] = [
responsedict['warnings'] += [
{'type':'mixed_schema','value':['artist','artists'],
'desc':"These two fields are meant as alternative methods to submit information. Use of both is discouraged, but works at the moment."}
]
if len(responsedict['warnings']) == 0: del responsedict['warnings']
return responsedict
@ -515,7 +519,7 @@ def post_scrobble(
@api.post("addpicture")
@authenticated_function(alternate=api_key_correct,api=True)
@catch_exceptions
def add_picture(b64,artist:Multi=[],title=None):
def add_picture(b64,artist:Multi=[],title=None,albumtitle=None):
"""Uploads a new image for an artist or track.
param string b64: Base 64 representation of the image
@ -527,8 +531,10 @@ def add_picture(b64,artist:Multi=[],title=None):
for a in artist:
keys.append("artist",a)
if title is not None: keys.append("title",title)
elif albumtitle is not None: keys.append("albumtitle",albumtitle)
k_filter, _, _, _, _ = uri_to_internal(keys)
if "track" in k_filter: k_filter = k_filter["track"]
elif "album" in k_filter: k_filter = k_filter["album"]
url = images.set_image(b64,**k_filter)
return {

View File

@ -93,8 +93,10 @@ def incoming_scrobble(rawscrobble,fix=True,client=None,api=None,dbconn=None):
log(f"Incoming scrobble [Client: {client} | API: {api}]: {rawscrobble}")
scrobbledict = rawscrobble_to_scrobbledict(rawscrobble, fix, client)
albumupdate = (malojaconfig["ALBUM_INFORMATION_TRUST"] == 'last')
sqldb.add_scrobble(scrobbledict,dbconn=dbconn)
sqldb.add_scrobble(scrobbledict,update_album=albumupdate,dbconn=dbconn)
proxy_scrobble_all(scrobbledict['track']['artists'],scrobbledict['track']['title'],scrobbledict['time'])
dbcache.invalidate_caches(scrobbledict['time'])
@ -130,8 +132,11 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
scrobbleinfo = {**rawscrobble}
if fix:
scrobbleinfo['track_artists'],scrobbleinfo['track_title'] = cla.fullclean(scrobbleinfo['track_artists'],scrobbleinfo['track_title'])
if scrobbleinfo.get('album_artists'):
scrobbleinfo['album_artists'] = cla.parseArtists(scrobbleinfo['album_artists'])
scrobbleinfo['scrobble_time'] = scrobbleinfo.get('scrobble_time') or int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
# processed info to internal scrobble dict
scrobbledict = {
"time":scrobbleinfo.get('scrobble_time'),
@ -139,7 +144,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
"artists":scrobbleinfo.get('track_artists'),
"title":scrobbleinfo.get('track_title'),
"album":{
"name":scrobbleinfo.get('album_name'),
"albumtitle":scrobbleinfo.get('album_title'),
"artists":scrobbleinfo.get('album_artists')
},
"length":scrobbleinfo.get('track_length')
@ -148,7 +153,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
"origin":f"client:{client}" if client else "generic",
"extra":{
k:scrobbleinfo[k] for k in scrobbleinfo if k not in
['scrobble_time','track_artists','track_title','track_length','scrobble_duration','album_name','album_artists']
['scrobble_time','track_artists','track_title','track_length','scrobble_duration','album_title','album_artists']
},
"rawscrobble":rawscrobble
}
@ -216,6 +221,8 @@ def get_scrobbles(dbconn=None,**keys):
result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,dbconn=dbconn)
elif 'track' in keys:
result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,dbconn=dbconn)
elif 'album' in keys:
result = sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,dbconn=dbconn)
else:
result = sqldb.get_scrobbles(since=since,to=to,dbconn=dbconn)
#return result[keys['page']*keys['perpage']:(keys['page']+1)*keys['perpage']]
@ -229,6 +236,8 @@ def get_scrobbles_num(dbconn=None,**keys):
result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,resolve_references=False,dbconn=dbconn))
elif 'track' in keys:
result = len(sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,resolve_references=False,dbconn=dbconn))
elif 'album' in keys:
result = len(sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,resolve_references=False,dbconn=dbconn))
else:
result = sqldb.get_scrobbles_num(since=since,to=to,dbconn=dbconn)
return result
@ -259,10 +268,21 @@ def get_charts_tracks(dbconn=None,**keys):
(since,to) = keys.get('timerange').timestamps()
if 'artist' in keys:
result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],dbconn=dbconn)
elif 'album' in keys:
result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],dbconn=dbconn)
else:
result = sqldb.count_scrobbles_by_track(since=since,to=to,dbconn=dbconn)
return result
@waitfordb
def get_charts_albums(dbconn=None,**keys):
(since,to) = keys.get('timerange').timestamps()
if 'artist' in keys:
result = sqldb.count_scrobbles_by_album_of_artist(since=since,to=to,artist=keys['artist'],dbconn=dbconn)
else:
result = sqldb.count_scrobbles_by_album(since=since,to=to,dbconn=dbconn)
return result
@waitfordb
def get_pulse(dbconn=None,**keys):
@ -299,6 +319,14 @@ def get_performance(dbconn=None,**keys):
if c["artist"] == artist:
rank = c["rank"]
break
elif "album" in keys:
album = sqldb.get_album(sqldb.get_album_id(keys['album'],dbconn=dbconn),dbconn=dbconn)
charts = get_charts_albums(timerange=rng,dbconn=dbconn)
rank = None
for c in charts:
if c["album"] == album:
rank = c["rank"]
break
else:
raise exceptions.MissingEntityParameter()
results.append({"range":rng,"rank":rank})
@ -336,6 +364,21 @@ def get_top_tracks(dbconn=None,**keys):
return results
@waitfordb
def get_top_albums(dbconn=None,**keys):
rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]})
results = []
for rng in rngs:
try:
res = get_charts_albums(timerange=rng,dbconn=dbconn)[0]
results.append({"range":rng,"album":res["album"],"scrobbles":res["scrobbles"]})
except Exception:
results.append({"range":rng,"album":None,"scrobbles":0})
return results
@waitfordb
def artist_info(dbconn=None,**keys):
@ -417,6 +460,36 @@ def track_info(dbconn=None,**keys):
}
@waitfordb
def album_info(dbconn=None,**keys):
album = keys.get('album')
if album is None: raise exceptions.MissingEntityParameter()
album_id = sqldb.get_album_id(album,dbconn=dbconn)
album = sqldb.get_album(album_id,dbconn=dbconn)
alltimecharts = get_charts_albums(timerange=alltime(),dbconn=dbconn)
#scrobbles = get_scrobbles_num(track=track,timerange=alltime())
c = [e for e in alltimecharts if e["album"] == album][0]
scrobbles = c["scrobbles"]
position = c["rank"]
return {
"album":album,
"scrobbles":scrobbles,
"position":position,
"medals":{
"gold": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['gold']],
"silver": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['silver']],
"bronze": [year for year in cached.medals_albums if album_id in cached.medals_albums[year]['bronze']],
},
"topweeks":len([e for e in cached.weekly_topalbums if e == album_id]),
"id":album_id
}
def get_predefined_rulesets(dbconn=None):
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

View File

@ -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

View File

@ -21,44 +21,72 @@ DBTABLES = {
# name - type - foreign key - kwargs
'scrobbles':{
'columns':[
("timestamp", sql.Integer, {'primary_key':True}),
("rawscrobble", sql.String, {}),
("origin", sql.String, {}),
("duration", sql.Integer, {}),
("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}),
("extra", sql.String, {})
("timestamp", sql.Integer, {'primary_key':True}),
("rawscrobble", sql.String, {}),
("origin", sql.String, {}),
("duration", sql.Integer, {}),
("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {}),
("extra", sql.String, {})
],
'extraargs':(),'extrakwargs':{}
},
'tracks':{
'columns':[
("id", sql.Integer, {'primary_key':True}),
("title", sql.String, {}),
("title_normalized",sql.String, {}),
("length", sql.Integer, {})
("id", sql.Integer, {'primary_key':True}),
("title", sql.String, {}),
("title_normalized", sql.String, {}),
("length", sql.Integer, {}),
("album_id", sql.Integer, sql.ForeignKey('albums.id'), {})
],
'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True}
},
'artists':{
'columns':[
("id", sql.Integer, {'primary_key':True}),
("name", sql.String, {}),
("name_normalized", sql.String, {})
("id", sql.Integer, {'primary_key':True}),
("name", sql.String, {}),
("name_normalized", sql.String, {})
],
'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True}
},
'albums':{
'columns':[
("id", sql.Integer, {'primary_key':True}),
("albtitle", sql.String, {}),
("albtitle_normalized", sql.String, {})
#("albumartist", sql.String, {})
# when an album has no artists, always use 'Various Artists'
],
'extraargs':(),'extrakwargs':{'sqlite_autoincrement':True}
},
'trackartists':{
'columns':[
("id", sql.Integer, {'primary_key':True}),
("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}),
("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {})
("id", sql.Integer, {'primary_key':True}),
("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}),
("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {})
],
'extraargs':(sql.UniqueConstraint('artist_id', 'track_id'),),'extrakwargs':{}
},
'albumartists':{
'columns':[
("id", sql.Integer, {'primary_key':True}),
("artist_id", sql.Integer, sql.ForeignKey('artists.id'), {}),
("album_id", sql.Integer, sql.ForeignKey('albums.id'), {})
],
'extraargs':(sql.UniqueConstraint('artist_id', 'album_id'),),'extrakwargs':{}
},
# 'albumtracks':{
# # tracks can be in multiple albums
# 'columns':[
# ("id", sql.Integer, {'primary_key':True}),
# ("album_id", sql.Integer, sql.ForeignKey('albums.id'), {}),
# ("track_id", sql.Integer, sql.ForeignKey('tracks.id'), {})
# ],
# 'extraargs':(sql.UniqueConstraint('album_id', 'track_id'),),'extrakwargs':{}
# },
'associated_artists':{
'columns':[
("source_artist", sql.Integer, sql.ForeignKey('artists.id'), {}),
("target_artist", sql.Integer, sql.ForeignKey('artists.id'), {})
("source_artist", sql.Integer, sql.ForeignKey('artists.id'), {}),
("target_artist", sql.Integer, sql.ForeignKey('artists.id'), {})
],
'extraargs':(sql.UniqueConstraint('source_artist', 'target_artist'),),'extrakwargs':{}
}
@ -138,7 +166,7 @@ def connection_provider(func):
# "artists":list,
# "title":string,
# "album":{
# "name":string,
# "albumtitle":string,
# "artists":list
# },
# "length":None
@ -185,11 +213,12 @@ def scrobble_db_to_dict(row,dbconn=None):
def tracks_db_to_dict(rows,dbconn=None):
artists = get_artists_of_tracks(set(row.id for row in rows),dbconn=dbconn)
albums = get_albums_map(set(row.album_id for row in rows),dbconn=dbconn)
return [
{
"artists":artists[row.id],
"title":row.title,
#"album":
"album":albums.get(row.album_id),
"length":row.length
}
for row in rows
@ -207,18 +236,31 @@ def artists_db_to_dict(rows,dbconn=None):
def artist_db_to_dict(row,dbconn=None):
return artists_db_to_dict([row],dbconn=dbconn)[0]
def albums_db_to_dict(rows,dbconn=None):
artists = get_artists_of_albums(set(row.id for row in rows),dbconn=dbconn)
return [
{
"artists":artists.get(row.id),
"albumtitle":row.albtitle,
}
for row in rows
]
def album_db_to_dict(row,dbconn=None):
return albums_db_to_dict([row],dbconn=dbconn)[0]
### DICT -> DB
# These should return None when no data is in the dict so they can be used for update statements
def scrobble_dict_to_db(info,dbconn=None):
def scrobble_dict_to_db(info,update_album=False,dbconn=None):
return {
"timestamp":info.get('time'),
"origin":info.get('origin'),
"duration":info.get('duration'),
"track_id":get_track_id(info.get('track'),dbconn=dbconn),
"track_id":get_track_id(info.get('track'),update_album=update_album,dbconn=dbconn),
"extra":json.dumps(info.get('extra')) if info.get('extra') else None,
"rawscrobble":json.dumps(info.get('rawscrobble')) if info.get('rawscrobble') else None
}
@ -236,6 +278,12 @@ def artist_dict_to_db(info,dbconn=None):
"name_normalized":normalize_name(info)
}
def album_dict_to_db(info,dbconn=None):
return {
"albtitle":info.get('albumtitle'),
"albtitle_normalized":normalize_name(info.get('albumtitle'))
}
@ -244,17 +292,17 @@ def artist_dict_to_db(info,dbconn=None):
@connection_provider
def add_scrobble(scrobbledict,dbconn=None):
add_scrobbles([scrobbledict],dbconn=dbconn)
def add_scrobble(scrobbledict,update_album=False,dbconn=None):
add_scrobbles([scrobbledict],update_album=update_album,dbconn=dbconn)
@connection_provider
def add_scrobbles(scrobbleslist,dbconn=None):
def add_scrobbles(scrobbleslist,update_album=False,dbconn=None):
with SCROBBLE_LOCK:
ops = [
DB['scrobbles'].insert().values(
**scrobble_dict_to_db(s,dbconn=dbconn)
**scrobble_dict_to_db(s,update_album=update_album,dbconn=dbconn)
) for s in scrobbleslist
]
@ -285,11 +333,34 @@ def delete_scrobble(scrobble_id,dbconn=None):
return True
@connection_provider
def add_track_to_album(track_id,album_id,replace=False,dbconn=None):
conditions = [
DB['tracks'].c.id == track_id
]
if not replace:
# if we dont want replacement, just update if there is no album yet
conditions.append(
DB['tracks'].c.album_id == None
)
op = DB['tracks'].update().where(
*conditions
).values(
album_id=album_id
)
result = dbconn.execute(op)
return True
### these will 'get' the ID of an entity, creating it if necessary
@cached_wrapper
@connection_provider
def get_track_id(trackdict,create_new=True,dbconn=None):
def get_track_id(trackdict,create_new=True,update_album=False,dbconn=None):
ntitle = normalize_name(trackdict['title'])
artist_ids = [get_artist_id(a,dbconn=dbconn) for a in trackdict['artists']]
artist_ids = list(set(artist_ids))
@ -310,18 +381,19 @@ def get_track_id(trackdict,create_new=True,dbconn=None):
op = DB['trackartists'].select(
# DB['trackartists'].c.artist_id
).where(
DB['trackartists'].c.track_id==row[0]
DB['trackartists'].c.track_id==row.id
)
result = dbconn.execute(op).all()
match_artist_ids = [r.artist_id for r in result]
#print("required artists",artist_ids,"this match",match_artist_ids)
if set(artist_ids) == set(match_artist_ids):
#print("ID for",trackdict['title'],"was",row[0])
if trackdict.get('album'):
add_track_to_album(row.id,get_album_id(trackdict['album'],dbconn=dbconn),replace=update_album,dbconn=dbconn)
return row.id
if not create_new: return None
op = DB['tracks'].insert().values(
**track_dict_to_db(trackdict,dbconn=dbconn)
)
@ -335,6 +407,9 @@ def get_track_id(trackdict,create_new=True,dbconn=None):
)
result = dbconn.execute(op)
#print("Created",trackdict['title'],track_id)
if trackdict.get('album'):
add_track_to_album(track_id,get_album_id(trackdict['album'],dbconn=dbconn),dbconn=dbconn)
return track_id
@cached_wrapper
@ -364,6 +439,59 @@ def get_artist_id(artistname,create_new=True,dbconn=None):
return result.inserted_primary_key[0]
@cached_wrapper
@connection_provider
def get_album_id(albumdict,create_new=True,dbconn=None):
ntitle = normalize_name(albumdict['albumtitle'])
artist_ids = [get_artist_id(a,dbconn=dbconn) for a in albumdict.get('artists') or []]
artist_ids = list(set(artist_ids))
op = DB['albums'].select(
# DB['albums'].c.id
).where(
DB['albums'].c.albtitle_normalized==ntitle
)
result = dbconn.execute(op).all()
for row in result:
# check if the artists are the same
foundtrackartists = []
op = DB['albumartists'].select(
# DB['albumartists'].c.artist_id
).where(
DB['albumartists'].c.album_id==row.id
)
result = dbconn.execute(op).all()
match_artist_ids = [r.artist_id for r in result]
#print("required artists",artist_ids,"this match",match_artist_ids)
if set(artist_ids) == set(match_artist_ids):
#print("ID for",albumdict['title'],"was",row[0])
return row.id
if not create_new: return None
op = DB['albums'].insert().values(
**album_dict_to_db(albumdict,dbconn=dbconn)
)
result = dbconn.execute(op)
album_id = result.inserted_primary_key[0]
for artist_id in artist_ids:
op = DB['albumartists'].insert().values(
album_id=album_id,
artist_id=artist_id
)
result = dbconn.execute(op)
#print("Created",trackdict['title'],track_id)
return album_id
### Edit existing
@ -544,6 +672,29 @@ def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbco
#result = [scrobble_db_to_dict(row) for row in result]
return result
@cached_wrapper
@connection_provider
def get_scrobbles_of_album(album,since=None,to=None,resolve_references=True,dbconn=None):
if since is None: since=0
if to is None: to=now()
album_id = get_album_id(album,dbconn=dbconn)
jointable = sql.join(DB['scrobbles'],DB['tracks'],DB['scrobbles'].c.track_id == DB['tracks'].c.id)
op = jointable.select().where(
DB['scrobbles'].c.timestamp<=to,
DB['scrobbles'].c.timestamp>=since,
DB['tracks'].c.album_id==album_id
).order_by(sql.asc('timestamp'))
result = dbconn.execute(op).all()
if resolve_references:
result = scrobbles_db_to_dict(result)
#result = [scrobble_db_to_dict(row) for row in result]
return result
@cached_wrapper
@connection_provider
def get_scrobbles(since=None,to=None,resolve_references=True,dbconn=None):
@ -687,6 +838,112 @@ def count_scrobbles_by_track(since,to,resolve_ids=True,dbconn=None):
result = rank(result,key='scrobbles')
return result
@cached_wrapper
@connection_provider
def count_scrobbles_by_album(since,to,resolve_ids=True,dbconn=None):
jointable = sql.join(
DB['scrobbles'],
DB['tracks'],
DB['scrobbles'].c.track_id == DB['tracks'].c.id
)
op = sql.select(
sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'),
DB['tracks'].c.album_id
).select_from(jointable).where(
DB['scrobbles'].c.timestamp<=to,
DB['scrobbles'].c.timestamp>=since,
DB['tracks'].c.album_id != None
).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count'))
result = dbconn.execute(op).all()
if resolve_ids:
counts = [row.count for row in result]
albums = get_albums_map([row.album_id for row in result],dbconn=dbconn)
result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result]
else:
result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result]
result = rank(result,key='scrobbles')
return result
@cached_wrapper
@connection_provider
# this ranks the albums of that artist, not albums the artist appears on - even scrobbles
# of tracks the artist is not part of!
def count_scrobbles_by_album_of_artist(since,to,artist,resolve_ids=True,dbconn=None):
artist_id = get_artist_id(artist,dbconn=dbconn)
jointable = sql.join(
DB['scrobbles'],
DB['tracks'],
DB['scrobbles'].c.track_id == DB['tracks'].c.id
)
jointable2 = sql.join(
jointable,
DB['albumartists'],
DB['tracks'].c.album_id == DB['albumartists'].c.album_id
)
op = sql.select(
sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'),
DB['tracks'].c.album_id
).select_from(jointable2).where(
DB['scrobbles'].c.timestamp<=to,
DB['scrobbles'].c.timestamp>=since,
DB['albumartists'].c.artist_id == artist_id
).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count'))
result = dbconn.execute(op).all()
if resolve_ids:
counts = [row.count for row in result]
albums = get_albums_map([row.album_id for row in result],dbconn=dbconn)
result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result]
else:
result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result]
result = rank(result,key='scrobbles')
return result
@cached_wrapper
@connection_provider
# this ranks the tracks of that artist by the album they appear on - even when the album
# is not the artist's
def count_scrobbles_of_artist_by_album(since,to,artist,resolve_ids=True,dbconn=None):
artist_id = get_artist_id(artist,dbconn=dbconn)
jointable = sql.join(
DB['scrobbles'],
DB['trackartists'],
DB['scrobbles'].c.track_id == DB['trackartists'].c.track_id
)
jointable2 = sql.join(
jointable,
DB['tracks'],
DB['scrobbles'].c.track_id == DB['tracks'].c.id
)
op = sql.select(
sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'),
DB['tracks'].c.album_id
).select_from(jointable2).where(
DB['scrobbles'].c.timestamp<=to,
DB['scrobbles'].c.timestamp>=since,
DB['trackartists'].c.artist_id == artist_id
).group_by(DB['tracks'].c.album_id).order_by(sql.desc('count'))
result = dbconn.execute(op).all()
if resolve_ids:
counts = [row.count for row in result]
albums = get_albums_map([row.album_id for row in result],dbconn=dbconn)
result = [{'scrobbles':row.count,'album':albums[row.album_id]} for row in result]
else:
result = [{'scrobbles':row.count,'album_id':row.album_id} for row in result]
result = rank(result,key='scrobbles')
return result
@cached_wrapper
@connection_provider
def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None):
@ -717,6 +974,35 @@ def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None):
return result
@cached_wrapper
@connection_provider
def count_scrobbles_by_track_of_album(since,to,album,dbconn=None):
album_id = get_album_id(album,dbconn=dbconn)
jointable = sql.join(
DB['scrobbles'],
DB['tracks'],
DB['scrobbles'].c.track_id == DB['tracks'].c.id
)
op = sql.select(
sql.func.count(sql.func.distinct(DB['scrobbles'].c.timestamp)).label('count'),
DB['scrobbles'].c.track_id
).select_from(jointable).filter(
DB['scrobbles'].c.timestamp<=to,
DB['scrobbles'].c.timestamp>=since,
DB['tracks'].c.album_id==album_id
).group_by(DB['scrobbles'].c.track_id).order_by(sql.desc('count'))
result = dbconn.execute(op).all()
counts = [row.count for row in result]
tracks = get_tracks_map([row.track_id for row in result],dbconn=dbconn)
result = [{'scrobbles':row.count,'track':tracks[row.track_id]} for row in result]
result = rank(result,key='scrobbles')
return result
### functions that get mappings for several entities -> rows
@ -734,6 +1020,19 @@ def get_artists_of_tracks(track_ids,dbconn=None):
artists.setdefault(row.track_id,[]).append(artist_db_to_dict(row,dbconn=dbconn))
return artists
@cached_wrapper_individual
@connection_provider
def get_artists_of_albums(album_ids,dbconn=None):
op = sql.join(DB['albumartists'],DB['artists']).select().where(
DB['albumartists'].c.album_id.in_(album_ids)
)
result = dbconn.execute(op).all()
artists = {}
for row in result:
artists.setdefault(row.album_id,[]).append(artist_db_to_dict(row,dbconn=dbconn))
return artists
@cached_wrapper_individual
@connection_provider
@ -769,6 +1068,22 @@ def get_artists_map(artist_ids,dbconn=None):
return artists
@cached_wrapper_individual
@connection_provider
def get_albums_map(album_ids,dbconn=None):
op = DB['albums'].select().where(
DB['albums'].c.id.in_(album_ids)
)
result = dbconn.execute(op).all()
albums = {}
result = list(result)
# this will get a list of albumdicts in the correct order of our rows
albumdicts = albums_db_to_dict(result,dbconn=dbconn)
for row,albumdict in zip(result,albumdicts):
albums[row.id] = albumdict
return albums
### associations
@cached_wrapper
@ -835,6 +1150,16 @@ def get_artist(id,dbconn=None):
artistinfo = result[0]
return artist_db_to_dict(artistinfo,dbconn=dbconn)
@cached_wrapper
@connection_provider
def get_album(id,dbconn=None):
op = DB['albums'].select().where(
DB['albums'].c.id==id
)
result = dbconn.execute(op).all()
albuminfo = result[0]
return album_db_to_dict(albuminfo,dbconn=dbconn)
@cached_wrapper
@connection_provider

View File

@ -39,6 +39,13 @@ DB['tracks'] = sql.Table(
sql.Column('expire',sql.Integer),
sql.Column('raw',sql.String)
)
DB['albums'] = sql.Table(
'albums', meta,
sql.Column('id',sql.Integer,primary_key=True),
sql.Column('url',sql.String),
sql.Column('expire',sql.Integer),
sql.Column('raw',sql.String)
)
meta.create_all(engine)
@ -115,6 +122,11 @@ def get_artist_image(artist=None,artist_id=None):
return f"/image?type=artist&id={artist_id}"
def get_album_image(album=None,album_id=None):
if album_id is None:
album_id = database.sqldb.get_album_id(album)
return f"/image?type=album&id={album_id}"
resolve_semaphore = BoundedSemaphore(8)
@ -132,7 +144,7 @@ def resolve_track_image(track_id):
# local image
if malojaconfig["USE_LOCAL_IMAGES"]:
images = local_files(artists=track['artists'],title=track['title'])
images = local_files(track=track)
if len(images) != 0:
result = random.choice(images)
result = urllib.parse.quote(result)
@ -176,31 +188,56 @@ def resolve_artist_image(artist_id):
return result
def resolve_album_image(album_id):
with resolve_semaphore:
# check cache
result = get_image_from_cache(album_id,'albums')
if result is not None:
return result
album = database.sqldb.get_album(album_id)
# local image
if malojaconfig["USE_LOCAL_IMAGES"]:
images = local_files(album=album)
if len(images) != 0:
result = random.choice(images)
result = urllib.parse.quote(result)
result = {'type':'url','value':result}
set_image_in_cache(album_id,'tracks',result['value'])
return result
# third party
result = thirdparty.get_image_album_all((album['artists'],album['albumtitle']))
result = {'type':'url','value':result}
set_image_in_cache(album_id,'albums',result['value'])
return result
# removes emojis and weird shit from names
def clean(name):
return "".join(c for c in name if c.isalnum() or c in []).strip()
def get_all_possible_filenames(artist=None,artists=None,title=None):
# check if we're dealing with a track or artist, then clean up names
# (only remove non-alphanumeric, allow korean and stuff)
if title is not None and artists is not None:
track = True
title, artists = clean(title), [clean(a) for a in artists]
elif artist is not None:
track = False
# new and improved
def get_all_possible_filenames(artist=None,track=None,album=None):
if track:
title, artists = clean(track['title']), [clean(a) for a in track['artists']]
superfolder = "tracks/"
elif album:
title, artists = clean(album['albumtitle']), [clean(a) for a in album.get('artists') or []]
superfolder = "albums/"
elif artist:
artist = clean(artist)
else: return []
superfolder = "tracks/" if track else "artists/"
superfolder = "artists/"
else:
return []
filenames = []
if track:
#unsafeartists = [artist.translate(None,"-_./\\") for artist in artists]
if track or album:
safeartists = [re.sub("[^a-zA-Z0-9]","",artist) for artist in artists]
#unsafetitle = title.translate(None,"-_./\\")
safetitle = re.sub("[^a-zA-Z0-9]","",title)
if len(artists) < 4:
@ -210,7 +247,6 @@ def get_all_possible_filenames(artist=None,artists=None,title=None):
unsafeperms = [sorted(artists)]
safeperms = [sorted(safeartists)]
for unsafeartistlist in unsafeperms:
filename = "-".join(unsafeartistlist) + "_" + title
if filename != "":
@ -241,10 +277,11 @@ def get_all_possible_filenames(artist=None,artists=None,title=None):
return [superfolder + name for name in filenames]
def local_files(artist=None,artists=None,title=None):
def local_files(artist=None,album=None,track=None):
filenames = get_all_possible_filenames(artist,artists,title)
filenames = get_all_possible_filenames(artist=artist,album=album,track=track)
images = []
@ -271,13 +308,18 @@ class MalformedB64(Exception):
pass
def set_image(b64,**keys):
track = "title" in keys
if track:
entity = {'artists':keys['artists'],'title':keys['title']}
id = database.sqldb.get_track_id(entity)
else:
entity = keys['artist']
id = database.sqldb.get_artist_id(entity)
if "title" in keys:
entity = {"track":keys}
id = database.sqldb.get_track_id(entity['track'])
dbtable = "tracks"
elif "albumtitle" in keys:
entity = {"album":keys}
id = database.sqldb.get_album_id(entity['album'])
dbtable = "albums"
elif "artist" in keys:
entity = keys
id = database.sqldb.get_artist_id(entity['artist'])
dbtable = "artists"
log("Trying to set image, b64 string: " + str(b64[:30] + "..."),module="debug")
@ -288,13 +330,13 @@ def set_image(b64,**keys):
type,b64 = match.groups()
b64 = base64.b64decode(b64)
filename = "webupload" + str(int(datetime.datetime.now().timestamp())) + "." + type
for folder in get_all_possible_filenames(**keys):
for folder in get_all_possible_filenames(**entity):
if os.path.exists(data_dir['images'](folder)):
with open(data_dir['images'](folder,filename),"wb") as f:
f.write(b64)
break
else:
folder = get_all_possible_filenames(**keys)[0]
folder = get_all_possible_filenames(**entity)[0]
os.makedirs(data_dir['images'](folder))
with open(data_dir['images'](folder,filename),"wb") as f:
f.write(b64)
@ -303,7 +345,6 @@ def set_image(b64,**keys):
log("Saved image as " + data_dir['images'](folder,filename),module="debug")
# set as current picture in rotation
if track: set_image_in_cache(id,'tracks',os.path.join("/images",folder,filename))
else: set_image_in_cache(id,'artists',os.path.join("/images",folder,filename))
set_image_in_cache(id,dbtable,os.path.join("/images",folder,filename))
return os.path.join("/images",folder,filename)

View File

@ -4,7 +4,7 @@ import urllib
import math
# this also sets defaults!
def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False):
def uri_to_internal(keys,forceTrack=False,forceArtist=False,forceAlbum=False,api=False):
# output:
# 1 keys that define the filtered object like artist or track
@ -12,12 +12,23 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False):
# 3 keys that define interal time ranges
# 4 keys that define amount limits
type = None
if forceTrack: type = "track"
if forceArtist: type = "artist"
if forceAlbum: type = "album"
if not type and "title" in keys: type = "track"
if not type and "albumtitle" in keys: type = "album"
if not type and "artist" in keys: type = "artist"
# 1
if "title" in keys and not forceArtist:
if type == "track":
filterkeys = {"track":{"artists":keys.getall("artist"),"title":keys.get("title")}}
elif "artist" in keys and not forceTrack:
elif type == "artist":
filterkeys = {"artist":keys.get("artist")}
if "associated" in keys: filterkeys["associated"] = True
elif type == "album":
filterkeys = {"album":{"artists":keys.getall("artist"),"albumtitle":keys.get("albumtitle") or keys.get("title")}}
else:
filterkeys = {}
@ -84,6 +95,10 @@ def internal_to_uri(keys):
for a in keys["track"]["artists"]:
urikeys.append("artist",a)
urikeys.append("title",keys["track"]["title"])
elif "album" in keys:
for a in keys["album"].get("artists") or []:
urikeys.append("artist",a)
urikeys.append("albumtitle",keys["album"]["albumtitle"])
#time
if "timerange" in keys:

View File

@ -177,6 +177,7 @@ malojaconfig = Configuration(
},
"Database":{
"album_information_trust":(tp.Choice({'first':"First",'last':"Last",'majority':"Majority"}), "Album Information Authority","first", "Whether to trust the first album information that is sent with a track or update every time a different album is sent"),
"invalid_artists":(tp.Set(tp.String()), "Invalid Artists", ["[Unknown Artist]","Unknown Artist","Spotify"], "Artists that should be discarded immediately"),
"remove_from_title":(tp.Set(tp.String()), "Remove from Title", ["(Original Mix)","(Radio Edit)","(Album Version)","(Explicit Version)","(Bonus Track)"], "Phrases that should be removed from song titles"),
"delimiters_feat":(tp.Set(tp.String()), "Featuring Delimiters", ["ft.","ft","feat.","feat","featuring"], "Delimiters used for extra artists, even when in the title field"),
@ -191,6 +192,7 @@ malojaconfig = Configuration(
"default_step_pulse":(tp.Choice({'year':'Year','month':"Month",'week':'Week','day':'Day'}), "Default Pulse Step", "month"),
"charts_display_tiles":(tp.Boolean(), "Display Chart Tiles", False),
"display_art_icons":(tp.Boolean(), "Display Album/Artist Icons", True),
"default_album_artist":(tp.String(), "Default Albumartist", "Various Artists"),
"discourage_cpu_heavy_stats":(tp.Boolean(), "Discourage CPU-heavy stats", False, "Prevent visitors from mindlessly clicking on CPU-heavy options. Does not actually disable them for malicious actors!"),
"use_local_images":(tp.Boolean(), "Use Local Images", True),
#"local_image_rotate":(tp.Integer(), "Local Image Rotate", 3600),

View File

@ -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 ""

View File

@ -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]:

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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"],
}

View File

@ -0,0 +1,163 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - {{ info.album.albumtitle }}{% endblock %}
{% import 'snippets/links.jinja' as links %}
{% block scripts %}
<script src="/rangeselect.js"></script>
<script src="/edit.js"></script>
{% 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' %}
<script>showValidMergeIcons();</script>
{% endif %}
{% endblock %}
{% block content %}
<script>
const entity_id = {{ info.id }};
const entity_type = 'album';
const entity_name = {{ album.albumtitle | tojson }};
</script>
{% import 'partials/awards_album.jinja' as awards %}
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="changeable-image" data-uploader="b64=>upload('{{ encodedalbum }}',b64)"
style="background-image:url('{{ images.get_album_image(album) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div style="background-image:url('{{ images.get_album_image(album) }}');">
</div>
{% endif %}
</td>
<td class="text">
<span>{{ links.links(album.artists) }}</span><br/>
<h1 id="main_entity_name" class="headerwithextra">{{ info.album.albumtitle | e }}</h1>
{# awards.certs(album) #}
<span class="rank"><a href="/charts_albums?max=100">#{{ info.position }}</a></span>
<br/>
<p class="stats">
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
</td>
</tr>
</table>
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>
{% with amountkeys={"perpage":15,"page":0} %}
{% include 'partials/charts_tracks.jinja' %}
{% endwith %}
<br/>
<table class="twopart">
<tr>
<td>
<h2 class="headerwithextra"><a href='{{ mlj_uri.create_uri("/pulse",filterkeys) }}'>Pulse</a></h2>
<br/>
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
{% if not loop.last %}|{% endif %}
{% endfor %}
<br/><br/>
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
{% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %}
{% include 'partials/pulse.jinja' %}
{% endwith %}
</span>
{% endfor %}
</td>
<td>
<!-- We use the same classes / function calls here because we want it to switch together with pulse -->
<h2 class="headerwithextra"><a href='{{ mlj_uri.create_uri("/performance",filterkeys) }}'>Performance</a></h2>
<br/>
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
{% if not loop.last %}|{% endif %}
{% endfor %}
<br/><br/>
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
{% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %}
{% include 'partials/performance.jinja' %}
{% endwith %}
</span>
{% endfor %}
</td>
</tr>
</table>
<h2><a href='{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}'>Last Scrobbles</a></h2>
{% with amountkeys = {"perpage":15,"page":0} %}
{% include 'partials/scrobbles.jinja' %}
{% endwith %}
{% endblock %}

View File

@ -90,8 +90,14 @@
</table>
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>
<h2><a href='{{ mlj_uri.create_uri("/charts_albums",filterkeys) }}'>Top Albums</a></h2>
{% with amountkeys={"perpage":15,"page":0} %}
{% include 'partials/charts_albums.jinja' %}
{% endwith %}
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>
{% with amountkeys={"perpage":15,"page":0} %}
{% include 'partials/charts_tracks.jinja' %}

View File

@ -0,0 +1,52 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Album Charts{% endblock %}
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% block scripts %}
<script src="/datechange.js" async></script>
{% 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 %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('{{ img }}')"></div>
</td>
<td class="text">
<h1>Album Charts</h1><a href="/top_albums"><span>View #1 Albums</span></a><br/>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% with delimitkeys = {} %}
{% include 'snippets/timeselection.jinja' %}
{% endwith %}
</td>
</tr>
</table>
{% if settings['CHARTS_DISPLAY_TILES'] %}
{% include 'partials/charts_albums_tiles.jinja' %}
<br/><br/>
{% endif %}
{% with compare=true %}
{% include 'partials/charts_albums.jinja' %}
{% endwith %}
{% import 'snippets/pagination.jinja' as pagination %}
{{ pagination.pagination(filterkeys,limitkeys,delimitkeys,amountkeys,pages) }}
{% endblock %}

View File

@ -1,6 +1,8 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Artist Charts{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% block scripts %}
<script src="/datechange.js" async></script>
{% endblock %}
@ -25,7 +27,7 @@
</td>
<td class="text">
<h1>Artist Charts</h1><a href="/top_artists"><span>View #1 Artists</span></a><br/>
<span>{{ limitkeys.timerange.desc(prefix=True) }}</span>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% with delimitkeys = {} %}
{% include 'snippets/timeselection.jinja' %}

View File

@ -2,6 +2,7 @@
{% block title %}Maloja - Track Charts{% endblock %}
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% block scripts %}
<script src="/datechange.js" async></script>
@ -26,8 +27,7 @@
</td>
<td class="text">
<h1>Track Charts</h1><a href="/top_tracks"><span>View #1 Tracks</span></a><br/>
{% if filterkeys.get('artist') is not none %}by {{ links.link(filterkeys.get('artist')) }}{% endif %}
<span>{{ limitkeys.timerange.desc(prefix=True) }}</span>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% with delimitkeys = {} %}
{% include 'snippets/timeselection.jinja' %}

View File

@ -0,0 +1,42 @@
{% macro medals(info) %}
<!-- MEDALS -->
{% for year in info.medals.gold -%}
<a title="Best Album in {{ year }}" class="hidelink medal shiny gold" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.silver -%}
<a title="Second best Album in {{ year }}" class="hidelink medal shiny silver" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.bronze -%}
<a title="Third best Album in {{ year }}" class="hidelink medal shiny bronze" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{%- endmacro %}
{% macro topweeks(info) %}
{% set encodedtrack = mlj_uri.uriencode({'album':info.album}) %}
<!-- TOPWEEKS -->
<span>
{% if info.topweeks > 0 %}
<a title="{{ info.topweeks }} weeks on #1" href="/performance?{{ encodedalbum }}&step=week">
<img class="star" src="/media/star.png" />{{ info.topweeks }}
</a>
{% endif %}
</span>
{%- endmacro %}

View File

@ -0,0 +1,56 @@
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/entityrow.jinja' as entityrow %}
{% if charts is undefined %}
{% set charts = dbc.get_charts_albums(filterkeys,limitkeys) %}
{% endif %}
{% if compare %}
{% if compare is true %}
{% set compare = limitkeys.timerange.next(step=-1) %}
{% if compare is none %}{% set compare = False %}{% endif %}
{% endif %}
{% if compare %}
{% set prevalbums = dbc.get_charts_albums(filterkeys,{'timerange':compare}) %}
{% set lastranks = {} %}
{% for t in prevalbums %}
{% if lastranks.update({"|".join(t.album.artists or [])+"||"+t.album.albumtitle:t.rank}) %}{% endif %}
{% endfor %}
{% for t in charts %}
{% if "|".join(t.album.artists or [])+"||"+t.album.albumtitle in lastranks %}
{% if t.update({'last_rank':lastranks["|".join(t.album.artists or [])+"||"+t.album.albumtitle]}) %}{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% set firstindex = amountkeys.page * amountkeys.perpage %}
{% set lastindex = firstindex + amountkeys.perpage %}
{% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %}
<table class='list'>
{% for e in charts %}
{% if loop.index0 >= firstindex and loop.index0 < lastindex %}
<tr>
<!-- Rank -->
<td class="rank">{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}</td>
<!-- Rank change -->
{% if compare %}
{% if e.last_rank is undefined %}<td class='rankup' title='New'>🆕</td>
{% elif e.last_rank < e.rank %}<td class='rankdown' title='Down from #{{ e.last_rank }}'>↘</td>
{% elif e.last_rank > e.rank %}<td class='rankup' title='Up from #{{ e.last_rank }}'>↗</td>
{% elif e.last_rank == e.rank %}<td class='ranksame' title='Unchanged'>➡</td>
{% endif %}
{% endif %}
<!-- artist -->
{{ entityrow.row(e['album']) }}
<!-- scrobbles -->
<td class="amount">{{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}</td>
<td class="bar">{{ links.link_scrobbles([{'album':e.album,'timerange':limitkeys.timerange}],percent=e['scrobbles']*100/maxbar) }}</td>
</tr>
{% endif %}
{% endfor %}
</table>

View File

@ -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) %}
<table class="tiles_top"><tr>
{% for segment in range(3) %}
{% if charts_14[0] is none and loop.first %}
{% include 'icons/nodata.jinja' %}
{% else %}
<td>
{% set segmentsize = segment+1 %}
<table class="tiles_{{ segmentsize }}x{{ segmentsize }} tiles_sub">
{% for row in range(segmentsize) -%}
<tr>
{% for col in range(segmentsize) %}
{% set entry = charts_cycler.next() %}
{% if entry is not none %}
{% set album = entry.album %}
{% set rank = entry.rank %}
<td>
<a href="{{ links.url(album) }}">
<div class="lazy" data-bg="{{ images.get_album_image(album) }}"'>
<span class='stats'>#{{ rank }}</span> <span>{{ album.title }}</span>
</div>
</a>
</td>
{% else -%}
<td></td>
{%- endif -%}
{%- endfor -%}
</tr>
{%- endfor -%}
</table>
</td>
{% endif %}
{% endfor %}
</tr></table>

View File

@ -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 %}
<table class="list">
{% for e in ranges %}
{% set thisrange = e.range %}
{% set album = e.album %}
<tr>
<td>{{ thisrange.desc() }}</td>
{% if album is none %}
<td><div></div></td>
<td class='stats'>n/a</td>
<td class='amount'>0</td>
<td class='bar'></td>
{% else %}
{{ entityrow.row(album) }}
<td class='amount'>{{ links.link_scrobbles([{'album':album,'timerange':thisrange}],amount=e.scrobbles) }}</td>
<td class='bar'> {{ links.link_scrobbles([{'album':album,'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}</td>
{% endif %}
</tr>
{% endfor %}
</table>

View File

@ -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 @@
<td class='track'>
<span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> {{ links.link(entity) }}
</td>
{% elif entity is mapping and 'albumtitle' in entity %}
<td class='album'>
<span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> {{ links.link(entity) }}
</td>
{% else %}
<td class='artist'>{{ links.link(entity) }}
{% if counting != [] %}

View File

@ -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) }}

View File

@ -1,6 +1,6 @@
{% macro link(entity) -%}
{% if entity is mapping and 'artists' in entity %}
{% set name = entity.title %}
{% if entity is mapping and 'title' in entity or 'albumtitle' in entity %}
{% set name = entity.title or entity.albumtitle %}
{% else %}
{% set name = entity %}
{% endif %}
@ -9,15 +9,21 @@
{%- endmacro %}
{% macro links(entities) -%}
{% for entity in entities -%}
{{ link(entity) }}{{ ", " if not loop.last }}
{%- endfor %}
{% if entities is none or entities == [] %}
{{ settings["DEFAULT_ALBUM_ARTIST"] }}
{% else %}
{% for entity in entities -%}
{{ link(entity) }}{{ ", " if not loop.last }}
{%- endfor %}
{% endif %}
{%- endmacro %}
{% macro url(entity) %}
{% if entity is mapping and 'artists' in entity -%}
{% if entity is mapping and 'albumtitle' in entity -%}
{{ mlj_uri.create_uri("/album",{'album':entity}) }}
{% elif entity is mapping and 'artists' in entity -%}
{{ mlj_uri.create_uri("/track",{'track':entity}) }}
{%- else -%}
{{ mlj_uri.create_uri("/artist",{'artist':entity}) }}
@ -43,6 +49,8 @@
{% if 'track' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_tracks",{'timerange':timerange}) %}
{% elif 'album' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_albums",{'timerange':timerange}) %}
{% elif 'artist' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_artists",{'timerange':timerange}) %}
{% endif %}

View File

@ -0,0 +1,31 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - #1 Albums{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
<!-- find representative -->
{% 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 %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('{{ img }}')"></div>
</td>
<td class="text">
<h1>#1 Albums</h1><br/>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% include 'snippets/timeselection.jinja' %}
</td>
</tr>
</table>
{% include 'partials/top_albums.jinja' %}
{% endblock %}

View File

@ -1,6 +1,7 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - #1 Artists{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
<!-- find representative -->
@ -17,7 +18,7 @@
</td>
<td class="text">
<h1>#1 Artists</h1><br/>
<span>{{ limitkeys.timerange.desc(prefix=True) }}</span>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% include 'snippets/timeselection.jinja' %}

View File

@ -1,6 +1,7 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - #1 Tracks{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
<!-- find representative -->
@ -17,7 +18,7 @@
</td>
<td class="text">
<h1>#1 Tracks</h1><br/>
<span>{{ limitkeys.timerange.desc(prefix=True) }}</span>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% include 'snippets/timeselection.jinja' %}

View File

@ -64,6 +64,9 @@
{{ awards.certs(track) }}
<span class="rank"><a href="/charts_tracks?max=100">#{{ info.position }}</a></span>
<br/>
{% if info.track.album %}
from {{ links.link(info.track.album) }}<br/>
{% endif %}
<p class="stats">
{% if adminmode %}<button type="button" onclick="scrobble('{{ encodedtrack }}')">Scrobble now</button>{% endif %}