1
0
mirror of https://github.com/krateng/maloja.git synced 2023-08-10 21:12:55 +03:00

Merge branch 'feature-webedit' into feature/reparse-scrobble

This commit is contained in:
alim4r 2022-04-21 19:04:32 +02:00
commit 495627f3f7
46 changed files with 826 additions and 328 deletions

4
dev/releases/branch.yml Normal file
View File

@ -0,0 +1,4 @@
- "[Architecture] Cleaned up legacy process control"
- "[Architecture] Added proper exception framework to native API"
- "[Feature] Implemented track title and artist name editing from web interface"
- "[Feature] Implemented track and artist merging from web interface"

View File

@ -1,4 +1,4 @@
# monkey patching
from . import monkey
from .pkg_global import monkey
# configuration before all else
from . import globalconf
from .pkg_global import conf

View File

@ -1,4 +1,163 @@
# make the package itself runnable with python -m maloja
import os
import signal
import subprocess
from .proccontrol.control import main
main()
from setproctitle import setproctitle
from ipaddress import ip_address
from doreah.control import mainfunction
from doreah.io import col
from doreah.logging import log
from . import __pkginfo__ as pkginfo
from .pkg_global import conf
from .proccontrol import tasks
from .setup import setup
from .dev import generate
def print_header_info():
print()
#print("#####")
print(col['yellow']("Maloja"),f"v{pkginfo.VERSION}")
print(pkginfo.HOMEPAGE)
#print("#####")
print()
def get_instance():
try:
return int(subprocess.check_output(["pidof","maloja"]))
except:
return None
def get_instance_supervisor():
try:
return int(subprocess.check_output(["pidof","maloja_supervisor"]))
except:
return None
def restart():
stop()
start()
def start():
if get_instance_supervisor() is not None:
print("Maloja is already running.")
else:
print_header_info()
setup()
try:
#p = subprocess.Popen(["python3","-m","maloja.server"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
sp = subprocess.Popen(["python3","-m","maloja","supervisor"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(col["green"]("Maloja started!"))
port = conf.malojaconfig["PORT"]
print("Visit your server address (Port " + str(port) + ") to see your web interface. Visit /admin_setup to get started.")
print("If you're installing this on your local machine, these links should get you there:")
print("\t" + col["blue"]("http://localhost:" + str(port)))
print("\t" + col["blue"]("http://localhost:" + str(port) + "/admin_setup"))
return True
except:
print("Error while starting Maloja.")
return False
def stop():
pid_sv = get_instance_supervisor()
if pid_sv is not None:
os.kill(pid_sv,signal.SIGTERM)
pid = get_instance()
if pid is not None:
os.kill(pid,signal.SIGTERM)
if pid is None and pid_sv is None:
return False
print("Maloja stopped!")
return True
def onlysetup():
print_header_info()
setup()
print("Setup complete!")
def run_server():
print_header_info()
setup()
setproctitle("maloja")
from . import server
server.run_server()
def run_supervisor():
setproctitle("maloja_supervisor")
while True:
log("Maloja is not running, starting...",module="supervisor")
try:
process = subprocess.Popen(
["python3", "-m", "maloja","run"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
log("Error starting Maloja: " + str(e),module="supervisor")
else:
try:
process.wait()
except Exception as e:
log("Maloja crashed: " + str(e),module="supervisor")
def debug():
os.environ["MALOJA_DEV_MODE"] = 'true'
conf.malojaconfig.load_environment()
direct()
def print_info():
print_header_info()
print(col['lightblue']("Configuration Directory:"),conf.dir_settings['config'])
print(col['lightblue']("Data Directory: "),conf.dir_settings['state'])
print(col['lightblue']("Log Directory: "),conf.dir_settings['logs'])
print(col['lightblue']("Network: "),f"IPv{ip_address(conf.malojaconfig['host']).version}, Port {conf.malojaconfig['port']}")
print(col['lightblue']("Timezone: "),f"UTC{conf.malojaconfig['timezone']:+d}")
print()
print()
@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images'],shield=True)
def main(*args,**kwargs):
actions = {
# server
"start":start,
"restart":restart,
"stop":stop,
"run":run_server,
"supervisor":run_supervisor,
"debug":debug,
"setup":onlysetup,
# admin scripts
"import":tasks.import_scrobbles, # maloja import /x/y.csv
"backup":tasks.backup, # maloja backup --targetfolder /x/y --include_images
"generate":generate.generate_scrobbles, # maloja generate 400
"export":tasks.export, # maloja export
# aux
"info":print_info
}
if "version" in kwargs:
print(info.VERSION)
return True
else:
try:
action, *args = args
action = actions[action]
except (ValueError, KeyError):
print("Valid commands: " + " ".join(a for a in actions))
return False
return action(*args,**kwargs)

View File

@ -4,7 +4,7 @@
from doreah.keystore import KeyStore
from doreah.logging import log
from ..globalconf import data_dir
from ..pkg_global.conf import data_dir
apikeystore = KeyStore(file=data_dir['clients']("apikeys.yml"),save_endpoint="/apis/mlj_1/apikeys")

View File

@ -4,7 +4,7 @@ from .. import database
import datetime
from ._apikeys import apikeystore
from ..globalconf import malojaconfig
from ..pkg_global.conf import malojaconfig
class Listenbrainz(APIHandler):

View File

@ -12,7 +12,7 @@ from nimrodel import Multi
from .. import database
from ..globalconf import malojaconfig, data_dir
from ..pkg_global.conf import malojaconfig, data_dir
@ -44,7 +44,15 @@ errors = {
"error":{
'type':'missing_scrobble_data',
'value':e.params,
'desc':"A scrobble requires these parameters."
'desc':"The scrobble is missing needed parameters."
}
}),
database.exceptions.EntityExists: lambda e: (409,{
"status":"failure",
"error":{
'type':'entity_exists',
'value':e.entitydict,
'desc':"This entity already exists in the database."
}
}),
Exception: lambda e: (500,{
@ -57,6 +65,20 @@ errors = {
})
}
def catch_exceptions(func):
def protector(*args,**kwargs):
try:
return func(*args,**kwargs)
except Exception as e:
for etype in errors:
if isinstance(e,etype):
errorhandling = errors[etype](e)
response.status = errorhandling[0]
return errorhandling[1]
protector.__doc__ = func.__doc__
protector.__annotations__ = func.__annotations__
return protector
def add_common_args_to_docstring(filterkeys=False,limitkeys=False,delimitkeys=False,amountkeys=False):
@ -94,6 +116,7 @@ def add_common_args_to_docstring(filterkeys=False,limitkeys=False,delimitkeys=Fa
@api.get("test")
@catch_exceptions
def test_server(key=None):
"""Pings the server. If an API key is supplied, the server will respond with 200
if the key is correct and 403 if it isn't. If no key is supplied, the server will
@ -119,6 +142,7 @@ def test_server(key=None):
@api.get("serverinfo")
@catch_exceptions
def server_info():
"""Returns basic information about the server.
@ -141,6 +165,7 @@ def server_info():
@api.get("scrobbles")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,amountkeys=True)
def get_scrobbles_external(**keys):
"""Returns a list of scrobbles.
@ -163,6 +188,7 @@ def get_scrobbles_external(**keys):
@api.get("numscrobbles")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,amountkeys=True)
def get_scrobbles_num_external(**keys):
"""Returns amount of scrobbles.
@ -182,6 +208,7 @@ def get_scrobbles_num_external(**keys):
@api.get("tracks")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
def get_tracks_external(**keys):
"""Returns all tracks (optionally of an artist).
@ -201,6 +228,7 @@ def get_tracks_external(**keys):
@api.get("artists")
@catch_exceptions
@add_common_args_to_docstring()
def get_artists_external():
"""Returns all artists.
@ -218,6 +246,7 @@ def get_artists_external():
@api.get("charts/artists")
@catch_exceptions
@add_common_args_to_docstring(limitkeys=True)
def get_charts_artists_external(**keys):
"""Returns artist charts
@ -236,6 +265,7 @@ def get_charts_artists_external(**keys):
@api.get("charts/tracks")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True)
def get_charts_tracks_external(**keys):
"""Returns track charts
@ -255,6 +285,7 @@ def get_charts_tracks_external(**keys):
@api.get("pulse")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,delimitkeys=True,amountkeys=True)
def get_pulse_external(**keys):
"""Returns amounts of scrobbles in specified time frames
@ -274,6 +305,7 @@ def get_pulse_external(**keys):
@api.get("performance")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,delimitkeys=True,amountkeys=True)
def get_performance_external(**keys):
"""Returns artist's or track's rank in specified time frames
@ -293,6 +325,7 @@ def get_performance_external(**keys):
@api.get("top/artists")
@catch_exceptions
@add_common_args_to_docstring(limitkeys=True,delimitkeys=True)
def get_top_artists_external(**keys):
"""Returns respective number 1 artists in specified time frames
@ -312,6 +345,7 @@ def get_top_artists_external(**keys):
@api.get("top/tracks")
@catch_exceptions
@add_common_args_to_docstring(limitkeys=True,delimitkeys=True)
def get_top_tracks_external(**keys):
"""Returns respective number 1 tracks in specified time frames
@ -333,6 +367,7 @@ def get_top_tracks_external(**keys):
@api.get("artistinfo")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
def artist_info_external(**keys):
"""Returns information about an artist
@ -347,6 +382,7 @@ def artist_info_external(**keys):
@api.get("trackinfo")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
def track_info_external(artist:Multi[str],**keys):
"""Returns information about a track
@ -365,6 +401,7 @@ def track_info_external(artist:Multi[str],**keys):
@api.post("newscrobble")
@authenticated_function(alternate=api_key_correct,api=True,pass_auth_result_as='auth_result')
@catch_exceptions
def post_scrobble(
artist:Multi=None,
artists:list=[],
@ -406,46 +443,40 @@ 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]}
try:
result = database.incoming_scrobble(
rawscrobble,
client='browser' if auth_result.get('doreah_native_auth_check') else auth_result.get('client'),
api='native/v1',
fix=(nofix is None)
)
responsedict = {
'status': 'success',
'track': {
'artists':result['track']['artists'],
'title':result['track']['title']
}
result = database.incoming_scrobble(
rawscrobble,
client='browser' if auth_result.get('doreah_native_auth_check') else auth_result.get('client'),
api='native/v1',
fix=(nofix is None)
)
responsedict = {
'status': 'success',
'track': {
'artists':result['track']['artists'],
'title':result['track']['title']
}
if extra_kwargs:
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'] = [
{'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."}
]
return responsedict
except Exception as e:
for etype in errors:
if isinstance(e,etype):
errorhandling = errors[etype](e)
response.status = errorhandling[0]
return errorhandling[1]
}
if extra_kwargs:
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'] = [
{'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."}
]
return responsedict
@api.post("importrules")
@authenticated_function(api=True)
@catch_exceptions
def import_rulemodule(**keys):
"""Internal Use Only"""
filename = keys.get("filename")
@ -464,6 +495,7 @@ def import_rulemodule(**keys):
@api.post("rebuild")
@authenticated_function(api=True)
@catch_exceptions
def rebuild(**keys):
"""Internal Use Only"""
log("Database rebuild initiated!")
@ -480,6 +512,7 @@ def rebuild(**keys):
@api.get("search")
@catch_exceptions
def search(**keys):
"""Internal Use Only"""
query = keys.get("query")
@ -519,6 +552,7 @@ def search(**keys):
@api.post("addpicture")
@authenticated_function(api=True)
@catch_exceptions
def add_picture(b64,artist:Multi=[],title=None):
"""Internal Use Only"""
keys = FormsDict()
@ -532,6 +566,7 @@ def add_picture(b64,artist:Multi=[],title=None):
@api.post("newrule")
@authenticated_function(api=True)
@catch_exceptions
def newrule(**keys):
"""Internal Use Only"""
pass
@ -542,18 +577,21 @@ def newrule(**keys):
@api.post("settings")
@authenticated_function(api=True)
@catch_exceptions
def set_settings(**keys):
"""Internal Use Only"""
malojaconfig.update(keys)
@api.post("apikeys")
@authenticated_function(api=True)
@catch_exceptions
def set_apikeys(**keys):
"""Internal Use Only"""
apikeystore.update(keys)
@api.post("import")
@authenticated_function(api=True)
@catch_exceptions
def import_scrobbles(identifier):
"""Internal Use Only"""
from ..thirdparty import import_scrobbles
@ -561,6 +599,7 @@ def import_scrobbles(identifier):
@api.get("backup")
@authenticated_function(api=True)
@catch_exceptions
def get_backup(**keys):
"""Internal Use Only"""
from ..proccontrol.tasks.backup import backup
@ -573,6 +612,7 @@ def get_backup(**keys):
@api.get("export")
@authenticated_function(api=True)
@catch_exceptions
def get_export(**keys):
"""Internal Use Only"""
from ..proccontrol.tasks.export import export
@ -586,12 +626,58 @@ def get_export(**keys):
@api.post("delete_scrobble")
@authenticated_function(api=True)
@catch_exceptions
def delete_scrobble(timestamp):
"""Internal Use Only"""
database.remove_scrobble(timestamp)
result = database.remove_scrobble(timestamp)
return {
"status":"success"
}
@api.post("edit_artist")
@authenticated_function(api=True)
@catch_exceptions
def edit_artist(id,name):
"""Internal Use Only"""
result = database.edit_artist(id,name)
return {
"status":"success"
}
@api.post("edit_track")
@authenticated_function(api=True)
@catch_exceptions
def edit_track(id,title):
"""Internal Use Only"""
result = database.edit_track(id,{'title':title})
return {
"status":"success"
}
@api.post("merge_tracks")
@authenticated_function(api=True)
@catch_exceptions
def merge_tracks(target_id,source_ids):
"""Internal Use Only"""
result = database.merge_tracks(target_id,source_ids)
return {
"status":"success"
}
@api.post("merge_artists")
@authenticated_function(api=True)
@catch_exceptions
def merge_artists(target_id,source_ids):
"""Internal Use Only"""
result = database.merge_artists(target_id,source_ids)
return {
"status":"success"
}
@api.post("reparse_scrobble")
@authenticated_function(api=True)
def reparse_scrobble(timestamp):
"""Internal Use Only"""
database.reparse_scrobble(timestamp)
database.reparse_scrobble(timestamp)

View File

@ -2,7 +2,7 @@ import re
import os
import csv
from .globalconf import data_dir, malojaconfig
from .pkg_global.conf import data_dir, malojaconfig
# need to do this as a class so it can retain loaded settings from file
# apparently this is not true

View File

@ -7,7 +7,7 @@ from .. import images
from ..malojatime import register_scrobbletime, time_stamps, ranges, alltime
from ..malojauri import uri_to_internal, internal_to_uri, compose_querystring
from ..thirdparty import proxy_scrobble_all
from ..globalconf import data_dir, malojaconfig
from ..pkg_global.conf import data_dir, malojaconfig
from ..apis import apikeystore
#db
from . import sqldb
@ -167,9 +167,47 @@ def remove_scrobble(timestamp):
result = sqldb.delete_scrobble(timestamp)
dbcache.invalidate_caches(timestamp)
return result
@waitfordb
def edit_artist(id,artistinfo):
artist = sqldb.get_artist(id)
log(f"Renaming {artist} to {artistinfo}")
result = sqldb.edit_artist(id,artistinfo)
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def edit_track(id,trackinfo):
track = sqldb.get_track(id)
log(f"Renaming {track['title']} to {trackinfo['title']}")
result = sqldb.edit_track(id,trackinfo)
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]
target = sqldb.get_artist(target_id)
log(f"Merging {sources} into {target}")
result = sqldb.merge_artists(target_id,source_ids)
dbcache.invalidate_entity_cache()
return result
@waitfordb
def merge_tracks(target_id,source_ids):
sources = [sqldb.get_track(id) for id in source_ids]
target = sqldb.get_track(target_id)
log(f"Merging {sources} into {target}")
result = sqldb.merge_tracks(target_id,source_ids)
dbcache.invalidate_entity_cache()
return result
@ -304,7 +342,8 @@ def artist_info(dbconn=None,**keys):
artist = keys.get('artist')
artist = sqldb.get_artist(sqldb.get_artist_id(artist,dbconn=dbconn),dbconn=dbconn)
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
@ -318,11 +357,12 @@ def artist_info(dbconn=None,**keys):
"position":position,
"associated":others,
"medals":{
"gold": [year for year in cached.medals_artists if artist in cached.medals_artists[year]['gold']],
"silver": [year for year in cached.medals_artists if artist in cached.medals_artists[year]['silver']],
"bronze": [year for year in cached.medals_artists if artist in cached.medals_artists[year]['bronze']],
"gold": [year for year in cached.medals_artists if artist_id in cached.medals_artists[year]['gold']],
"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])
"topweeks":len([e for e in cached.weekly_topartists if e == artist_id]),
"id":artist_id
}
except:
# if the artist isnt in the charts, they are not being credited and we
@ -340,7 +380,8 @@ def track_info(dbconn=None,**keys):
track = keys.get('track')
track = sqldb.get_track(sqldb.get_track_id(track,dbconn=dbconn),dbconn=dbconn)
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)
#scrobbles = get_scrobbles_num(track=track,timerange=alltime())
@ -359,12 +400,13 @@ def track_info(dbconn=None,**keys):
"scrobbles":scrobbles,
"position":position,
"medals":{
"gold": [year for year in cached.medals_tracks if track in cached.medals_tracks[year]['gold']],
"silver": [year for year in cached.medals_tracks if track in cached.medals_tracks[year]['silver']],
"bronze": [year for year in cached.medals_tracks if track in cached.medals_tracks[year]['bronze']],
"gold": [year for year in cached.medals_tracks if track_id in cached.medals_tracks[year]['gold']],
"silver": [year for year in cached.medals_tracks if track_id in cached.medals_tracks[year]['silver']],
"bronze": [year for year in cached.medals_tracks if track_id in cached.medals_tracks[year]['bronze']],
},
"certification":cert,
"topweeks":len([e for e in cached.weekly_toptracks if e == track])
"topweeks":len([e for e in cached.weekly_toptracks if e == track_id]),
"id":track_id
}

View File

@ -8,7 +8,7 @@ import csv
import os
from . import sqldb
from ..globalconf import data_dir
from ..pkg_global.conf import data_dir
def load_associated_rules():

View File

@ -3,6 +3,7 @@
from doreah.regular import runyearly, rundaily
from .. import database
from . import sqldb
from .. import malojatime as mjt
@ -24,27 +25,29 @@ def update_medals():
medals_artists.clear()
medals_tracks.clear()
for year in mjt.ranges(step="year"):
if year == mjt.thisyear(): break
with sqldb.engine.begin() as conn:
for year in mjt.ranges(step="year"):
if year == mjt.thisyear(): break
charts_artists = database.get_charts_artists(timerange=year)
charts_tracks = database.get_charts_tracks(timerange=year)
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)
entry_artists = {'gold':[],'silver':[],'bronze':[]}
entry_tracks = {'gold':[],'silver':[],'bronze':[]}
medals_artists[year.desc()] = entry_artists
medals_tracks[year.desc()] = entry_tracks
entry_artists = {'gold':[],'silver':[],'bronze':[]}
entry_tracks = {'gold':[],'silver':[],'bronze':[]}
medals_artists[year.desc()] = entry_artists
medals_tracks[year.desc()] = entry_tracks
for entry in charts_artists:
if entry['rank'] == 1: entry_artists['gold'].append(entry['artist_id'])
elif entry['rank'] == 2: entry_artists['silver'].append(entry['artist_id'])
elif entry['rank'] == 3: entry_artists['bronze'].append(entry['artist_id'])
else: break
for entry in charts_tracks:
if entry['rank'] == 1: entry_tracks['gold'].append(entry['track_id'])
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_artists:
if entry['rank'] == 1: entry_artists['gold'].append(entry['artist'])
elif entry['rank'] == 2: entry_artists['silver'].append(entry['artist'])
elif entry['rank'] == 3: entry_artists['bronze'].append(entry['artist'])
else: break
for entry in charts_tracks:
if entry['rank'] == 1: entry_tracks['gold'].append(entry['track'])
elif entry['rank'] == 2: entry_tracks['silver'].append(entry['track'])
elif entry['rank'] == 3: entry_tracks['bronze'].append(entry['track'])
else: break
@ -55,15 +58,17 @@ def update_weekly():
weekly_topartists.clear()
weekly_toptracks.clear()
for week in mjt.ranges(step="week"):
if week == mjt.thisweek(): break
with sqldb.engine.begin() as conn:
for week in mjt.ranges(step="week"):
if week == mjt.thisweek(): break
charts_artists = database.get_charts_artists(timerange=week)
charts_tracks = database.get_charts_tracks(timerange=week)
for entry in charts_artists:
if entry['rank'] == 1: weekly_topartists.append(entry['artist'])
else: break
for entry in charts_tracks:
if entry['rank'] == 1: weekly_toptracks.append(entry['track'])
else: break
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)
for entry in charts_artists:
if entry['rank'] == 1: weekly_topartists.append(entry['artist_id'])
else: break
for entry in charts_tracks:
if entry['rank'] == 1: weekly_toptracks.append(entry['track_id'])
else: break

View File

@ -8,7 +8,7 @@ import json
from doreah.regular import runhourly
from doreah.logging import log
from ..globalconf import malojaconfig
from ..pkg_global.conf import malojaconfig
HIGH_NUMBER = 1000000
CACHE_SIZE = 10000
@ -94,12 +94,12 @@ def cached_wrapper_individual(inner_func):
return outer_func
def invalidate_caches(scrobbletime):
def invalidate_caches(scrobbletime=None):
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 (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:

View File

@ -0,0 +1,10 @@
class EntityExists(Exception):
def __init__(self,entitydict):
self.entitydict = entitydict
class TrackExists(EntityExists):
pass
class ArtistExists(EntityExists):
pass

View File

@ -3,7 +3,7 @@ from . sqldb import engine
from .dbcache import serialize
from ..globalconf import malojaconfig
from ..pkg_global.conf import malojaconfig
from doreah.logging import log

View File

@ -5,8 +5,9 @@ import math
from datetime import datetime
from threading import Lock
from ..globalconf import data_dir
from ..pkg_global.conf import data_dir
from .dbcache import cached_wrapper, cached_wrapper_individual
from . import exceptions as exc
from doreah.logging import log
from doreah.regular import runhourly, runmonthly
@ -275,7 +276,9 @@ def delete_scrobble(scrobble_id,dbconn=None):
DB['scrobbles'].c.timestamp == scrobble_id
)
dbconn.execute(op)
result = dbconn.execute(op)
return True
@connection_provider
def update_scrobble_track_id(scrobble_id, track_id, dbconn=None):
@ -292,7 +295,7 @@ def update_scrobble_track_id(scrobble_id, track_id, dbconn=None):
@cached_wrapper
@connection_provider
def get_track_id(trackdict,dbconn=None):
def get_track_id(trackdict,create_new=True,dbconn=None):
ntitle = normalize_name(trackdict['title'])
artist_ids = [get_artist_id(a) for a in trackdict['artists']]
artist_ids = list(set(artist_ids))
@ -322,6 +325,8 @@ def get_track_id(trackdict,dbconn=None):
#print("ID for",trackdict['title'],"was",row[0])
return row.id
if not create_new: return None
op = DB['tracks'].insert().values(
**track_dict_to_db(trackdict)
@ -365,6 +370,78 @@ def get_artist_id(artistname,create_new=True,dbconn=None):
return result.inserted_primary_key[0]
### Edit existing
@connection_provider
def edit_artist(id,artistupdatedict,dbconn=None):
artist = get_artist(id)
changedartist = artistupdatedict # well
dbentry = artist_dict_to_db(artistupdatedict)
existing_artist_id = get_artist_id(changedartist,create_new=False,dbconn=dbconn)
if existing_artist_id not in (None,id):
raise exc.ArtistExists(changedartist)
op = DB['artists'].update().where(
DB['artists'].c.id==id
).values(
**dbentry
)
result = dbconn.execute(op)
return True
@connection_provider
def edit_track(id,trackupdatedict,dbconn=None):
track = get_track(id)
changedtrack = {**track,**trackupdatedict}
dbentry = track_dict_to_db(trackupdatedict)
existing_track_id = get_track_id(changedtrack,create_new=False,dbconn=dbconn)
if existing_track_id not in (None,id):
raise exc.TrackExists(changedtrack)
op = DB['tracks'].update().where(
DB['tracks'].c.id==id
).values(
**dbentry
)
result = dbconn.execute(op)
return True
### Merge
@connection_provider
def merge_tracks(target_id,source_ids,dbconn=None):
op = DB['scrobbles'].update().where(
DB['scrobbles'].c.track_id.in_(source_ids)
).values(
track_id=target_id
)
result = dbconn.execute(op)
clean_db()
return True
@connection_provider
def merge_artists(target_id,source_ids,dbconn=None):
op = DB['trackartists'].update().where(
DB['trackartists'].c.artist_id.in_(source_ids)
).values(
artist_id=target_id
)
result = dbconn.execute(op)
clean_db()
return True
@ -377,7 +454,7 @@ def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,db
if since is None: since=0
if to is None: to=now()
artist_id = get_artist_id(artist)
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)
@ -400,7 +477,7 @@ def get_scrobbles_of_track(track,since=None,to=None,resolve_references=True,dbco
if since is None: since=0
if to is None: to=now()
track_id = get_track_id(track)
track_id = get_track_id(track,dbconn=dbconn)
op = DB['scrobbles'].select().where(
DB['scrobbles'].c.timestamp<=to,
@ -466,7 +543,7 @@ def get_artists_of_track(track_id,resolve_references=True,dbconn=None):
@connection_provider
def get_tracks_of_artist(artist,dbconn=None):
artist_id = get_artist_id(artist)
artist_id = get_artist_id(artist,dbconn=dbconn)
op = sql.join(DB['tracks'],DB['trackartists']).select().where(
DB['trackartists'].c.artist_id==artist_id
@ -497,7 +574,7 @@ def get_tracks(dbconn=None):
@cached_wrapper
@connection_provider
def count_scrobbles_by_artist(since,to,dbconn=None):
def count_scrobbles_by_artist(since,to,resolve_ids=True,dbconn=None):
jointable = sql.join(
DB['scrobbles'],
DB['trackartists'],
@ -525,16 +602,18 @@ def count_scrobbles_by_artist(since,to,dbconn=None):
).order_by(sql.desc('count'))
result = dbconn.execute(op).all()
counts = [row.count for row in result]
artists = get_artists_map([row.artist_id for row in result])
result = [{'scrobbles':row.count,'artist':artists[row.artist_id]} for row in result]
if resolve_ids:
counts = [row.count for row in result]
artists = get_artists_map([row.artist_id for row in result])
result = [{'scrobbles':row.count,'artist':artists[row.artist_id]} for row in result]
else:
result = [{'scrobbles':row.count,'artist_id':row.artist_id} for row in result]
result = rank(result,key='scrobbles')
return result
@cached_wrapper
@connection_provider
def count_scrobbles_by_track(since,to,dbconn=None):
def count_scrobbles_by_track(since,to,resolve_ids=True,dbconn=None):
op = sql.select(
@ -546,10 +625,12 @@ def count_scrobbles_by_track(since,to,dbconn=None):
).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])
result = [{'scrobbles':row.count,'track':tracks[row.track_id]} for row in result]
if resolve_ids:
counts = [row.count for row in result]
tracks = get_tracks_map([row.track_id for row in result])
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
@ -557,7 +638,7 @@ def count_scrobbles_by_track(since,to,dbconn=None):
@connection_provider
def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None):
artist_id = get_artist_id(artist)
artist_id = get_artist_id(artist,dbconn=dbconn)
jointable = sql.join(
DB['scrobbles'],
@ -577,7 +658,7 @@ def count_scrobbles_by_track_of_artist(since,to,artist,dbconn=None):
counts = [row.count for row in result]
tracks = get_tracks_map([row.track_id 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
@ -659,7 +740,7 @@ def get_associated_artists(*artists,dbconn=None):
@cached_wrapper
@connection_provider
def get_credited_artists(*artists,dbconn=None):
artist_ids = [get_artist_id(a) for a in artists]
artist_ids = [get_artist_id(a,dbconn=dbconn) for a in artists]
jointable = sql.join(
DB['associated_artists'],

2
maloja/dev/__init__.py Normal file
View File

@ -0,0 +1,2 @@
### Subpackage that takes care of all things that concern the server process itself,
### e.g. analytics

View File

@ -1,5 +1,6 @@
import random
import datetime
from doreah.io import ask
@ -66,10 +67,10 @@ def generate_track():
def generate(n=200):
def generate_scrobbles(n=200):
from ..database.sqldb import add_scrobbles
from ...database.sqldb import add_scrobbles
n = int(n)
if ask("Generate random scrobbles?",default=False):

View File

@ -2,11 +2,10 @@ import os
import cProfile, pstats
from doreah.logging import log
from doreah.timing import Clock
from ..globalconf import data_dir
from ..pkg_global.conf import data_dir
profiler = cProfile.Profile()

View File

@ -1,4 +1,4 @@
from .globalconf import data_dir, malojaconfig
from .pkg_global.conf import data_dir, malojaconfig
from . import thirdparty
from . import database

View File

@ -1,5 +1,5 @@
from . import filters
from ..globalconf import malojaconfig
from ..pkg_global.conf import malojaconfig
from .. import database, malojatime, images, malojauri, thirdparty, __pkginfo__
from ..database import jinjaview

View File

@ -3,7 +3,7 @@ from calendar import monthrange
from os.path import commonprefix
import math
from .globalconf import malojaconfig
from .pkg_global.conf import malojaconfig
OFFSET = malojaconfig["TIMEZONE"]

View File

@ -3,7 +3,7 @@ from doreah.configuration import Configuration
from doreah.configuration import types as tp
from .__pkginfo__ import VERSION
from ..__pkginfo__ import VERSION

View File

@ -1,140 +0,0 @@
import subprocess
from doreah import settings
from doreah.control import mainfunction
from doreah.io import col
import os
import signal
from ipaddress import ip_address
from .setup import setup
from . import tasks
from .. import __pkginfo__ as info
from .. import globalconf
def print_header_info():
print()
#print("#####")
print(col['yellow']("Maloja"),"v" + info.VERSION)
print(info.HOMEPAGE)
#print("#####")
print()
def getInstance():
try:
output = subprocess.check_output(["pidof","Maloja"])
return int(output)
except:
return None
def getInstanceSupervisor():
try:
output = subprocess.check_output(["pidof","maloja_supervisor"])
return int(output)
except:
return None
def restart():
stop()
start()
def start():
if getInstanceSupervisor() is not None:
print("Maloja is already running.")
else:
print_header_info()
setup()
try:
#p = subprocess.Popen(["python3","-m","maloja.server"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
sp = subprocess.Popen(["python3","-m","maloja.proccontrol.supervisor"],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(col["green"]("Maloja started!"))
port = globalconf.malojaconfig["PORT"]
print("Visit your server address (Port " + str(port) + ") to see your web interface. Visit /admin_setup to get started.")
print("If you're installing this on your local machine, these links should get you there:")
print("\t" + col["blue"]("http://localhost:" + str(port)))
print("\t" + col["blue"]("http://localhost:" + str(port) + "/admin_setup"))
return True
except:
print("Error while starting Maloja.")
return False
def stop():
pid_sv = getInstanceSupervisor()
if pid_sv is not None:
os.kill(pid_sv,signal.SIGTERM)
pid = getInstance()
if pid is not None:
os.kill(pid,signal.SIGTERM)
if pid is None and pid_sv is None:
return False
print("Maloja stopped!")
return True
def onlysetup():
print_header_info()
setup()
print("Setup complete!")
def direct():
print_header_info()
setup()
from .. import server
server.run_server()
def debug():
os.environ["MALOJA_DEV_MODE"] = 'true'
globalconf.malojaconfig.load_environment()
direct()
def print_info():
print_header_info()
print(col['lightblue']("Configuration Directory:"),globalconf.dir_settings['config'])
print(col['lightblue']("Data Directory: "),globalconf.dir_settings['state'])
print(col['lightblue']("Log Directory: "),globalconf.dir_settings['logs'])
print(col['lightblue']("Network: "),f"IPv{ip_address(globalconf.malojaconfig['host']).version}, Port {globalconf.malojaconfig['port']}")
print(col['lightblue']("Timezone: "),f"UTC{globalconf.malojaconfig['timezone']:+d}")
print()
print()
@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images'],shield=True)
def main(*args,**kwargs):
actions = {
# server
"start":start,
"restart":restart,
"stop":stop,
"run":direct,
"debug":debug,
"setup":onlysetup,
# admin scripts
"import":tasks.import_scrobbles, # maloja import /x/y.csv
"backup":tasks.backup, # maloja backup --targetfolder /x/y --include_images
"generate":tasks.generate, # maloja generate 400
"export":tasks.export, # maloja export
# aux
"info":print_info
}
if "version" in kwargs:
print(info.VERSION)
return True
else:
try:
action, *args = args
action = actions[action]
except (ValueError, KeyError):
print("Valid commands: " + " ".join(a for a in actions))
return False
return action(*args,**kwargs)

View File

@ -1,33 +0,0 @@
#!/usr/bin/env python3
import os
from ..globalconf import malojaconfig
import subprocess
import setproctitle
import signal
from doreah.logging import log
from .control import getInstance
setproctitle.setproctitle("maloja_supervisor")
def start():
try:
return subprocess.Popen(
["python3", "-m", "maloja","run"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except e:
log("Error starting Maloja: " + str(e),module="supervisor")
while True:
log("Maloja is not running, starting...",module="supervisor")
process = start()
process.wait()

View File

@ -1,4 +1,3 @@
from .import_scrobbles import import_scrobbles
from .backup import backup
from .generate import generate
from .export import export # read that line out loud

View File

@ -2,7 +2,7 @@ import tarfile
import time
import glob
import os
from ...globalconf import dir_settings
from ...pkg_global.conf import dir_settings
from pathlib import PurePath
from doreah.logging import log

View File

@ -4,7 +4,7 @@ import json, csv
from doreah.io import col, ask, prompt
from ...cleanup import *
from ...globalconf import data_dir
from ...pkg_global.conf import data_dir
c = CleanerAgent()

View File

@ -2,7 +2,6 @@
import sys
import os
from threading import Thread
import setproctitle
from importlib import resources
from css_html_js_minify import html_minify, css_minify
import datauri
@ -22,12 +21,12 @@ from . import database
from .database.jinjaview import JinjaDBConnection
from .images import resolve_track_image, resolve_artist_image
from .malojauri import uri_to_internal, remove_identical
from .globalconf import malojaconfig, data_dir
from .pkg_global.conf import malojaconfig, data_dir
from .jinjaenv.context import jinja_environment
from .apis import init_apis, apikeystore
from .proccontrol.profiler import profile
from .dev.profiler import profile
######
@ -43,8 +42,6 @@ BaseRequest.MEMFILE_MAX = 15 * 1024 * 1024
webserver = Bottle()
#rename process, this is now required for the daemon manager to work
setproctitle.setproctitle("Maloja")
######

View File

@ -1,10 +1,12 @@
from importlib import resources
from distutils import dir_util
from doreah.io import col, ask, prompt
from doreah import auth
import os
from ..globalconf import data_dir, dir_settings, malojaconfig
from importlib import resources
from distutils import dir_util
from doreah.io import col, ask, prompt
from doreah import auth
from .pkg_global.conf import data_dir, dir_settings, malojaconfig
@ -48,7 +50,7 @@ def setup():
# OWN API KEY
from ..apis import apikeystore
from .apis import apikeystore
if len(apikeystore) == 0:
answer = ask("Do you want to set up a key to enable scrobbling? Your scrobble extension needs that key so that only you can scrobble tracks to your database.",default=True,skip=SKIP)
if answer:

View File

@ -13,7 +13,7 @@ import base64
from doreah.logging import log
from threading import BoundedSemaphore
from ..globalconf import malojaconfig
from ..pkg_global.conf import malojaconfig
from .. import database

View File

@ -7,7 +7,7 @@ import csv
from doreah.logging import log
from doreah.io import col
from .globalconf import data_dir, dir_settings
from .pkg_global.conf import data_dir, dir_settings
from .apis import _apikeys

View File

@ -50,9 +50,7 @@
{% endblock %}
{% endblock %}
<div id="notification_area">
</div>
<div class="footer">
@ -84,9 +82,16 @@
</div>
</div>
<a href="/admin_overview"><div title="Server Administration" id="settingsicon" class="clickable_icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M17 12.645v-2.289c-1.17-.417-1.907-.533-2.28-1.431-.373-.9.07-1.512.6-2.625l-1.618-1.619c-1.105.525-1.723.974-2.626.6-.9-.374-1.017-1.117-1.431-2.281h-2.29c-.412 1.158-.53 1.907-1.431 2.28h-.001c-.9.374-1.51-.07-2.625-.6l-1.617 1.619c.527 1.11.973 1.724.6 2.625-.375.901-1.123 1.019-2.281 1.431v2.289c1.155.412 1.907.531 2.28 1.431.376.908-.081 1.534-.6 2.625l1.618 1.619c1.107-.525 1.724-.974 2.625-.6h.001c.9.373 1.018 1.118 1.431 2.28h2.289c.412-1.158.53-1.905 1.437-2.282h.001c.894-.372 1.501.071 2.619.602l1.618-1.619c-.525-1.107-.974-1.723-.601-2.625.374-.899 1.126-1.019 2.282-1.43zm-8.5 1.689c-1.564 0-2.833-1.269-2.833-2.834s1.269-2.834 2.833-2.834 2.833 1.269 2.833 2.834-1.269 2.834-2.833 2.834zm15.5 4.205v-1.077c-.55-.196-.897-.251-1.073-.673-.176-.424.033-.711.282-1.236l-.762-.762c-.52.248-.811.458-1.235.283-.424-.175-.479-.525-.674-1.073h-1.076c-.194.545-.25.897-.674 1.073-.424.176-.711-.033-1.235-.283l-.762.762c.248.523.458.812.282 1.236-.176.424-.528.479-1.073.673v1.077c.544.193.897.25 1.073.673.177.427-.038.722-.282 1.236l.762.762c.521-.248.812-.458 1.235-.283.424.175.479.526.674 1.073h1.076c.194-.545.25-.897.676-1.074h.001c.421-.175.706.034 1.232.284l.762-.762c-.247-.521-.458-.812-.282-1.235s.529-.481 1.073-.674zm-4 .794c-.736 0-1.333-.597-1.333-1.333s.597-1.333 1.333-1.333 1.333.597 1.333 1.333-.597 1.333-1.333 1.333z"/></svg>
</div></a>
<div id="icon_bar">
{% block icon_bar %}{% endblock %}
{% include 'icons/settings.jinja' %}
</div>
<div id="notification_area">
</div>
</body>
</html>

View File

@ -66,6 +66,8 @@
<ul>
<li>manually scrobble from track pages</li>
<li>delete scrobbles</li>
<li>edit tracks and artists</li>
<li>merge tracks and artists</li>
<li>upload artist and track art by dropping a file on the existing image on an artist or track page</li>
<li>see more detailed error pages</li>
</ul>

View File

@ -6,6 +6,7 @@
{% block scripts %}
<script src="/rangeselect.js"></script>
<script src="/edit.js"></script>
{% endblock %}
{% set artist = filterkeys.artist %}
@ -26,10 +27,23 @@
{% set encodedartist = mlj_uri.uriencode({'artist':artist}) %}
{% 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 = 'artist';
const entity_name = {{ artist | tojson }};
</script>
@ -47,7 +61,7 @@
{% endif %}
</td>
<td class="text">
<h1 class="headerwithextra">{{ info.artist }}</h1>
<h1 id="main_entity_name" class="headerwithextra">{{ info.artist }}</h1>
{% if competes %}<span class="rank"><a href="/charts_artists?max=100">#{{ info.position }}</a></span>{% endif %}
<br/>
{% if competes and included %}
@ -56,7 +70,9 @@
<span>Competing under {{ links.link(credited) }} (#{{ info.position }})</span>
{% endif %}
<p class="stats"><a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a></p>
<p class="stats">
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
@ -72,6 +88,7 @@
</tr>
</table>
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>

View File

@ -0,0 +1,11 @@
<div title="Edit" id="editicon" class="clickable_icon" onclick="editEntity()">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 59.985 59.985" style="enable-background:new 0 0 59.985 59.985;" xml:space="preserve">
<path d="M5.243,44.844L42.378,7.708l9.899,9.899L15.141,54.742L5.243,44.844z"/>
<path d="M56.521,13.364l1.414-1.414c1.322-1.322,2.05-3.079,2.05-4.949s-0.728-3.627-2.05-4.949S54.855,0,52.985,0
s-3.627,0.729-4.95,2.051l-1.414,1.414L56.521,13.364z"/>
<path d="M4.099,46.527L0.051,58.669c-0.12,0.359-0.026,0.756,0.242,1.023c0.19,0.19,0.446,0.293,0.707,0.293
c0.106,0,0.212-0.017,0.316-0.052l12.141-4.047L4.099,46.527z"/>
<path d="M43.793,6.294l1.415-1.415l9.899,9.899l-1.415,1.415L43.793,6.294z"/>
</svg>
</div>

View File

@ -0,0 +1,8 @@
<div title="Merge" id="mergeicon" class="clickable_icon hide" onclick="merge()">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M7.105 8.79A3.001 3.001 0 0 0 10 11h4a5.001 5.001 0 0 1 4.927 4.146A3.001 3.001 0 0 1 18 21a3 3 0 0 1-1.105-5.79A3.001 3.001 0 0 0 14 13h-4a4.978 4.978 0 0 1-3-1v3.17a3.001 3.001 0 1 1-2 0V8.83a3.001 3.001 0 1 1 2.105-.04z"/>
</g>
</svg>
</div>

View File

@ -0,0 +1,10 @@
<div title="Cancel merge" id="mergecancelicon" class="clickable_icon hide" onclick="cancelMerge()">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 47.095 47.095" style="enable-background:new 0 0 47.095 47.095;" xml:space="preserve">
<g>
<path d="M45.363,36.234l-13.158-13.16l12.21-12.21c2.31-2.307,2.31-6.049,0-8.358c-2.308-2.308-6.05-2.307-8.356,0l-12.212,12.21
L11.038,1.906c-2.309-2.308-6.051-2.308-8.358,0c-2.307,2.309-2.307,6.049,0,8.358l12.81,12.81L1.732,36.831
c-2.309,2.31-2.309,6.05,0,8.359c2.308,2.307,6.049,2.307,8.356,0l13.759-13.758l13.16,13.16c2.308,2.308,6.049,2.308,8.356,0
C47.673,42.282,47.672,38.54,45.363,36.234z"/>
</g>
</div>

View File

@ -0,0 +1,5 @@
<div title="Mark for merging" id="mergemarkicon" class="clickable_icon hide" onclick="markForMerge()">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10,0 L10,2.60002 C12.2108812,3.04881281 13.8920863,4.95644867 13.9950026,7.27443311 L14,7.5 L14,11.2676 C14.5978,11.6134 15,12.2597 15,13 C15,14.1046 14.1046,15 13,15 C11.8954,15 11,14.1046 11,13 C11,12.3166462 11.342703,11.713387 11.8656124,11.3526403 L12,11.2676 L12,7.5 C12,6.259091 11.246593,5.19415145 10.1722389,4.73766702 L10,4.67071 L10,7 L6,3.5 L10,0 Z M3,1 C4.10457,1 5,1.89543 5,3 C5,3.68333538 4.65729704,4.28663574 4.13438762,4.6473967 L4,4.73244 L4,11.2676 C4.5978,11.6134 5,12.2597 5,13 C5,14.1046 4.10457,15 3,15 C1.89543,15 1,14.1046 1,13 C1,12.3166462 1.34270296,11.713387 1.86561238,11.3526403 L2,11.2676 L2,4.73244 C1.4022,4.38663 1,3.74028 1,3 C1,1.89543 1.89543,1 3,1 Z"/>
</svg>
</div>

View File

@ -0,0 +1,7 @@
<a class='hidelink' href="/admin_overview">
<div title="Server Administration" id="settingsicon" class="clickable_icon" style="margin-left:25px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17 12.645v-2.289c-1.17-.417-1.907-.533-2.28-1.431-.373-.9.07-1.512.6-2.625l-1.618-1.619c-1.105.525-1.723.974-2.626.6-.9-.374-1.017-1.117-1.431-2.281h-2.29c-.412 1.158-.53 1.907-1.431 2.28h-.001c-.9.374-1.51-.07-2.625-.6l-1.617 1.619c.527 1.11.973 1.724.6 2.625-.375.901-1.123 1.019-2.281 1.431v2.289c1.155.412 1.907.531 2.28 1.431.376.908-.081 1.534-.6 2.625l1.618 1.619c1.107-.525 1.724-.974 2.625-.6h.001c.9.373 1.018 1.118 1.431 2.28h2.289c.412-1.158.53-1.905 1.437-2.282h.001c.894-.372 1.501.071 2.619.602l1.618-1.619c-.525-1.107-.974-1.723-.601-2.625.374-.899 1.126-1.019 2.282-1.43zm-8.5 1.689c-1.564 0-2.833-1.269-2.833-2.834s1.269-2.834 2.833-2.834 2.833 1.269 2.833 2.834-1.269 2.834-2.833 2.834zm15.5 4.205v-1.077c-.55-.196-.897-.251-1.073-.673-.176-.424.033-.711.282-1.236l-.762-.762c-.52.248-.811.458-1.235.283-.424-.175-.479-.525-.674-1.073h-1.076c-.194.545-.25.897-.674 1.073-.424.176-.711-.033-1.235-.283l-.762.762c.248.523.458.812.282 1.236-.176.424-.528.479-1.073.673v1.077c.544.193.897.25 1.073.673.177.427-.038.722-.282 1.236l.762.762c.521-.248.812-.458 1.235-.283.424.175.479.526.674 1.073h1.076c.194-.545.25-.897.676-1.074h.001c.421-.175.706.034 1.232.284l.762-.762c-.247-.521-.458-.812-.282-1.235s.529-.481 1.073-.674zm-4 .794c-.736 0-1.333-.597-1.333-1.333s.597-1.333 1.333-1.333 1.333.597 1.333 1.333-.597 1.333-1.333 1.333z"/>
</svg>
</div>
</a>

View File

@ -5,6 +5,7 @@
{% block scripts %}
<script src="/rangeselect.js"></script>
<script src="/edit.js"></script>
<script>
function scrobble(encodedtrack) {
neo.xhttprequest('/apis/mlj_1/newscrobble?nofix&' + encodedtrack,data={},method="POST").then(response=>{window.location.reload()});
@ -21,8 +22,24 @@
{% set encodedtrack = mlj_uri.uriencode({'track':track}) %}
{% 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 = 'track';
const entity_name = {{ track.title | tojson }};
</script>
{% import 'partials/awards_track.jinja' as awards %}
@ -42,7 +59,7 @@
</td>
<td class="text">
<span>{{ links.links(track.artists) }}</span><br/>
<h1 class="headerwithextra">{{ info.track.title }}</h1>
<h1 id="main_entity_name" class="headerwithextra">{{ info.track.title }}</h1>
{{ awards.certs(track) }}
<span class="rank"><a href="/charts_tracks?max=100">#{{ info.position }}</a></span>
<br/>

View File

@ -156,5 +156,5 @@ input:focus {
.hide {
display:none;
display:none !important;
}

View File

@ -55,8 +55,18 @@ div.header h1 {
settings icon
**/
div.clickable_icon {
div#icon_bar {
position:fixed;
right:30px;
top:30px;
}
div#icon_bar div.clickable_icon {
display: inline-block;
height:26px;
width:26px;
}
div.clickable_icon {
fill: var(--text-color);
cursor: pointer;
}
@ -67,11 +77,6 @@ div.clickable_icon.danger:hover {
fill: red;
}
div#settingsicon {
position:fixed;
right:30px;
top:30px;
}
/**

View File

@ -1,14 +1,191 @@
// JS for all web interface editing / deletion of scrobble data
// HELPERS
function selectAll(e) {
// https://stackoverflow.com/a/6150060/6651341
var range = document.createRange();
range.selectNodeContents(e);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// DELETION
function toggleDeleteConfirm(element) {
element.parentElement.parentElement.classList.toggle('active');
}
function deleteScrobble(id,element) {
element.parentElement.parentElement.parentElement.classList.add('removed');
var callback_func = function(req){
if (req.status == 200) {
element.parentElement.parentElement.parentElement.classList.add('removed');
notifyCallback(req);
}
else {
notifyCallback(req);
}
};
neo.xhttpreq("/apis/mlj_1/delete_scrobble",data={'timestamp':id},method="POST",callback=(()=>null),json=true);
neo.xhttpreq("/apis/mlj_1/delete_scrobble",data={'timestamp':id},method="POST",callback=callback_func,json=true);
}
// EDIT NAME
function editEntity() {
var namefield = document.getElementById('main_entity_name');
namefield.contentEditable = "plaintext-only";
namefield.addEventListener('keydown',function(e){
// dont allow new lines, done on enter
if (e.key === "Enter") {
e.preventDefault();
namefield.blur(); // this leads to below
}
// cancel on esc
else if (e.key === "Escape" || e.key === "Esc") {
e.preventDefault();
namefield.textContent = entity_name;
namefield.blur();
}
})
// emergency, not pretty because it will move cursor
namefield.addEventListener('input',function(e){
if (namefield.textContent.includes("\n")) {
namefield.textContent = namefield.textContent.replace("\n","");
}
})
// manually clicking away OR enter
namefield.addEventListener('blur',function(e){
doneEditing();
})
namefield.focus();
selectAll(namefield);
}
function doneEditing() {
window.getSelection().removeAllRanges();
var namefield = document.getElementById('main_entity_name');
namefield.contentEditable = "false";
newname = namefield.textContent;
if (newname != entity_name) {
var searchParams = new URLSearchParams(window.location.search);
if (entity_type == 'artist') {
var endpoint = "/apis/mlj_1/edit_artist";
searchParams.set("artist", newname);
var payload = {'id':entity_id,'name':newname};
}
else if (entity_type == 'track') {
var endpoint = "/apis/mlj_1/edit_track";
searchParams.set("title", newname);
var payload = {'id':entity_id,'title':newname}
}
callback_func = function(req){
if (req.status == 200) {
window.location = "?" + searchParams.toString();
}
else {
notifyCallback(req);
namefield.textContent = entity_name;
}
};
neo.xhttpreq(
endpoint,
data=payload,
method="POST",
callback=callback_func,
json=true
);
}
}
// MERGING
function showValidMergeIcons() {
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');
}
}
}
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));
current_stored.push(entity_id);
current_stored = [...new Set(current_stored)];
lcst.setItem(key,current_stored); //this already formats it correctly
notify("Success","Marked " + entity_name + " for merge, currently " + current_stored.length + " marked!")
showValidMergeIcons();
}
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));
callback_func = function(req){
if (req.status == 200) {
window.location.reload();
}
else {
notifyCallback(req);
}
};
neo.xhttpreq(
"/apis/mlj_1/merge_" + entity_type + "s",
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;
lcst.setItem(key,[]);
showValidMergeIcons();
}
function toggleReparseConfirm(element) {

View File

@ -69,8 +69,9 @@ function scrobble(artists,title) {
"title":title
}
if (title != "" && artists.length > 0) {
neo.xhttpreq("/apis/mlj_1/newscrobble",data=payload,method="POST",callback=scrobbledone,json=true)
neo.xhttpreq("/apis/mlj_1/newscrobble",data=payload,method="POST",callback=notifyCallback,json=true)
}
document.getElementById("title").value = "";

View File

@ -6,7 +6,7 @@ const colors = {
}
const notification_template = info => `
<div class="notification" style="background-color:${colors[type]};">
<div class="notification" style="background-color:${colors[info.notification_type]};">
<b>${info.title}</b><br/>
<span>${info.body}</span>
@ -20,11 +20,11 @@ function htmlToElement(html) {
return template.content.firstChild;
}
function notify(title,msg,type='info',reload=false) {
function notify(title,msg,notification_type='info',reload=false) {
info = {
'title':title,
'body':msg,
'type':type
'notification_type':notification_type
}
var element = htmlToElement(notification_template(info));
@ -33,3 +33,22 @@ function notify(title,msg,type='info',reload=false) {
setTimeout(function(e){e.remove();},7000,element);
}
function notifyCallback(request) {
var body = request.response;
var status = request.status;
if (status == 200) {
var notification_type = 'info';
var title = "Success!";
var msg = "Scrobbled " + body.track.title + " by " + body.track.artists.join(", ");
}
else {
var notification_type = 'warning';
var title = "Error: " + body.error.type;
var msg = body.error.desc || "";
}
notify(title,msg,notification_type);
}

View File

@ -40,7 +40,7 @@ full = [
]
[project.scripts]
maloja = "maloja.proccontrol.control:main"
maloja = "maloja.__main__:main"
[build-system]
requires = ["flit_core >=3.2,<4"]