diff --git a/dev/releases/branch.yml b/dev/releases/branch.yml new file mode 100644 index 0000000..8d3ef64 --- /dev/null +++ b/dev/releases/branch.yml @@ -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" diff --git a/maloja/__init__.py b/maloja/__init__.py index ec1fd37..f43dd84 100644 --- a/maloja/__init__.py +++ b/maloja/__init__.py @@ -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 diff --git a/maloja/__main__.py b/maloja/__main__.py index 4fcd284..ff24c60 100644 --- a/maloja/__main__.py +++ b/maloja/__main__.py @@ -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) diff --git a/maloja/apis/_apikeys.py b/maloja/apis/_apikeys.py index cc49c92..fd4773e 100644 --- a/maloja/apis/_apikeys.py +++ b/maloja/apis/_apikeys.py @@ -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") diff --git a/maloja/apis/listenbrainz.py b/maloja/apis/listenbrainz.py index 457573f..3ba5fa9 100644 --- a/maloja/apis/listenbrainz.py +++ b/maloja/apis/listenbrainz.py @@ -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): diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 7b56a05..3a095c2 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -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) \ No newline at end of file diff --git a/maloja/cleanup.py b/maloja/cleanup.py index de47920..eb97528 100644 --- a/maloja/cleanup.py +++ b/maloja/cleanup.py @@ -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 diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index e50eaed..98b1ba1 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -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 } diff --git a/maloja/database/associated.py b/maloja/database/associated.py index 00f6a5f..69ccc61 100644 --- a/maloja/database/associated.py +++ b/maloja/database/associated.py @@ -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(): diff --git a/maloja/database/cached.py b/maloja/database/cached.py index 7967665..ea39a29 100644 --- a/maloja/database/cached.py +++ b/maloja/database/cached.py @@ -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 diff --git a/maloja/database/dbcache.py b/maloja/database/dbcache.py index b3d4ce3..c9e53e0 100644 --- a/maloja/database/dbcache.py +++ b/maloja/database/dbcache.py @@ -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: diff --git a/maloja/database/exceptions.py b/maloja/database/exceptions.py new file mode 100644 index 0000000..7273643 --- /dev/null +++ b/maloja/database/exceptions.py @@ -0,0 +1,10 @@ +class EntityExists(Exception): + def __init__(self,entitydict): + self.entitydict = entitydict + + +class TrackExists(EntityExists): + pass + +class ArtistExists(EntityExists): + pass diff --git a/maloja/database/jinjaview.py b/maloja/database/jinjaview.py index ab19e73..37518ca 100644 --- a/maloja/database/jinjaview.py +++ b/maloja/database/jinjaview.py @@ -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 diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index be27292..38a980a 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -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'], diff --git a/maloja/dev/__init__.py b/maloja/dev/__init__.py new file mode 100644 index 0000000..1220cb9 --- /dev/null +++ b/maloja/dev/__init__.py @@ -0,0 +1,2 @@ +### Subpackage that takes care of all things that concern the server process itself, +### e.g. analytics diff --git a/maloja/proccontrol/tasks/generate.py b/maloja/dev/generate.py similarity index 97% rename from maloja/proccontrol/tasks/generate.py rename to maloja/dev/generate.py index 225b6ce..0083f67 100644 --- a/maloja/proccontrol/tasks/generate.py +++ b/maloja/dev/generate.py @@ -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): diff --git a/maloja/proccontrol/profiler.py b/maloja/dev/profiler.py similarity index 95% rename from maloja/proccontrol/profiler.py rename to maloja/dev/profiler.py index 068bf61..7fb3355 100644 --- a/maloja/proccontrol/profiler.py +++ b/maloja/dev/profiler.py @@ -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() diff --git a/maloja/images.py b/maloja/images.py index ba399f7..99c40a4 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -1,4 +1,4 @@ -from .globalconf import data_dir, malojaconfig +from .pkg_global.conf import data_dir, malojaconfig from . import thirdparty from . import database diff --git a/maloja/jinjaenv/context.py b/maloja/jinjaenv/context.py index 48356e7..55e2f4e 100644 --- a/maloja/jinjaenv/context.py +++ b/maloja/jinjaenv/context.py @@ -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 diff --git a/maloja/malojatime.py b/maloja/malojatime.py index 5be3c72..2a53527 100644 --- a/maloja/malojatime.py +++ b/maloja/malojatime.py @@ -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"] diff --git a/maloja/globalconf.py b/maloja/pkg_global/conf.py similarity index 99% rename from maloja/globalconf.py rename to maloja/pkg_global/conf.py index 16206a8..0588763 100644 --- a/maloja/globalconf.py +++ b/maloja/pkg_global/conf.py @@ -3,7 +3,7 @@ from doreah.configuration import Configuration from doreah.configuration import types as tp -from .__pkginfo__ import VERSION +from ..__pkginfo__ import VERSION diff --git a/maloja/monkey.py b/maloja/pkg_global/monkey.py similarity index 100% rename from maloja/monkey.py rename to maloja/pkg_global/monkey.py diff --git a/maloja/proccontrol/control.py b/maloja/proccontrol/control.py deleted file mode 100644 index 691e3c0..0000000 --- a/maloja/proccontrol/control.py +++ /dev/null @@ -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) diff --git a/maloja/proccontrol/supervisor.py b/maloja/proccontrol/supervisor.py deleted file mode 100644 index f1ab668..0000000 --- a/maloja/proccontrol/supervisor.py +++ /dev/null @@ -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() diff --git a/maloja/proccontrol/tasks/__init__.py b/maloja/proccontrol/tasks/__init__.py index eda907d..cf2cd85 100644 --- a/maloja/proccontrol/tasks/__init__.py +++ b/maloja/proccontrol/tasks/__init__.py @@ -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 diff --git a/maloja/proccontrol/tasks/backup.py b/maloja/proccontrol/tasks/backup.py index 73f797b..dadacfc 100644 --- a/maloja/proccontrol/tasks/backup.py +++ b/maloja/proccontrol/tasks/backup.py @@ -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 diff --git a/maloja/proccontrol/tasks/import_scrobbles.py b/maloja/proccontrol/tasks/import_scrobbles.py index c9d98a6..fd5a567 100644 --- a/maloja/proccontrol/tasks/import_scrobbles.py +++ b/maloja/proccontrol/tasks/import_scrobbles.py @@ -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() diff --git a/maloja/server.py b/maloja/server.py index 63359eb..69f126c 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -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") ###### diff --git a/maloja/proccontrol/setup.py b/maloja/setup.py similarity index 96% rename from maloja/proccontrol/setup.py rename to maloja/setup.py index 8872357..b74dbab 100644 --- a/maloja/proccontrol/setup.py +++ b/maloja/setup.py @@ -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: diff --git a/maloja/thirdparty/__init__.py b/maloja/thirdparty/__init__.py index e38518e..311b076 100644 --- a/maloja/thirdparty/__init__.py +++ b/maloja/thirdparty/__init__.py @@ -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 diff --git a/maloja/upgrade.py b/maloja/upgrade.py index ca84001..75213da 100644 --- a/maloja/upgrade.py +++ b/maloja/upgrade.py @@ -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 diff --git a/maloja/web/jinja/abstracts/base.jinja b/maloja/web/jinja/abstracts/base.jinja index aa9cf33..f0ff337 100644 --- a/maloja/web/jinja/abstracts/base.jinja +++ b/maloja/web/jinja/abstracts/base.jinja @@ -50,9 +50,7 @@ {% endblock %} {% endblock %} -
-
-
- -
+
+ {% block icon_bar %}{% endblock %} + {% include 'icons/settings.jinja' %} +
+ + +
+ +
+ diff --git a/maloja/web/jinja/admin_overview.jinja b/maloja/web/jinja/admin_overview.jinja index 36993a6..cd6e2b9 100644 --- a/maloja/web/jinja/admin_overview.jinja +++ b/maloja/web/jinja/admin_overview.jinja @@ -66,6 +66,8 @@ diff --git a/maloja/web/jinja/artist.jinja b/maloja/web/jinja/artist.jinja index f738c8b..eb09c77 100644 --- a/maloja/web/jinja/artist.jinja +++ b/maloja/web/jinja/artist.jinja @@ -6,6 +6,7 @@ {% block scripts %} + {% 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' %} + + {% endif %} +{% endblock %} {% block content %} - + @@ -47,7 +61,7 @@ {% endif %} -

{{ info.artist }}

+

{{ info.artist }}

{% if competes %}#{{ info.position }}{% endif %}
{% if competes and included %} @@ -56,7 +70,9 @@ Competing under {{ links.link(credited) }} (#{{ info.position }}) {% endif %} -

{{ info['scrobbles'] }} Scrobbles

+

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

@@ -72,6 +88,7 @@ +

Top Tracks

diff --git a/maloja/web/jinja/icons/edit.jinja b/maloja/web/jinja/icons/edit.jinja new file mode 100644 index 0000000..6c76a60 --- /dev/null +++ b/maloja/web/jinja/icons/edit.jinja @@ -0,0 +1,11 @@ +
+ + + + + + +
diff --git a/maloja/web/jinja/icons/merge.jinja b/maloja/web/jinja/icons/merge.jinja new file mode 100644 index 0000000..da65e8b --- /dev/null +++ b/maloja/web/jinja/icons/merge.jinja @@ -0,0 +1,8 @@ +
+ + + + + + +
diff --git a/maloja/web/jinja/icons/merge_cancel.jinja b/maloja/web/jinja/icons/merge_cancel.jinja new file mode 100644 index 0000000..161d007 --- /dev/null +++ b/maloja/web/jinja/icons/merge_cancel.jinja @@ -0,0 +1,10 @@ +
+ + + + +
diff --git a/maloja/web/jinja/icons/merge_mark.jinja b/maloja/web/jinja/icons/merge_mark.jinja new file mode 100644 index 0000000..62deac8 --- /dev/null +++ b/maloja/web/jinja/icons/merge_mark.jinja @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/maloja/web/jinja/icons/settings.jinja b/maloja/web/jinja/icons/settings.jinja new file mode 100644 index 0000000..62434cc --- /dev/null +++ b/maloja/web/jinja/icons/settings.jinja @@ -0,0 +1,7 @@ + +
+ + + +
+
diff --git a/maloja/web/jinja/track.jinja b/maloja/web/jinja/track.jinja index 03762c6..291add0 100644 --- a/maloja/web/jinja/track.jinja +++ b/maloja/web/jinja/track.jinja @@ -5,6 +5,7 @@ {% block scripts %} + + {% endif %} +{% endblock %} + {% block content %} + + {% import 'partials/awards_track.jinja' as awards %} @@ -42,7 +59,7 @@ {{ links.links(track.artists) }}
-

{{ info.track.title }}

+

{{ info.track.title }}

{{ awards.certs(track) }} #{{ info.position }}
diff --git a/maloja/web/static/css/grisons.css b/maloja/web/static/css/grisons.css index 625b877..797fced 100644 --- a/maloja/web/static/css/grisons.css +++ b/maloja/web/static/css/grisons.css @@ -156,5 +156,5 @@ input:focus { .hide { - display:none; + display:none !important; } diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index eaaf7f8..fd93273 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -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; -} /** diff --git a/maloja/web/static/js/edit.js b/maloja/web/static/js/edit.js index 6ce1c60..6111922 100644 --- a/maloja/web/static/js/edit.js +++ b/maloja/web/static/js/edit.js @@ -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) { diff --git a/maloja/web/static/js/manualscrobble.js b/maloja/web/static/js/manualscrobble.js index 1ae55de..4a58ee9 100644 --- a/maloja/web/static/js/manualscrobble.js +++ b/maloja/web/static/js/manualscrobble.js @@ -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 = ""; diff --git a/maloja/web/static/js/notifications.js b/maloja/web/static/js/notifications.js index 5dae55e..43e656f 100644 --- a/maloja/web/static/js/notifications.js +++ b/maloja/web/static/js/notifications.js @@ -6,7 +6,7 @@ const colors = { } const notification_template = info => ` -
+
${info.title}
${info.body} @@ -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); +} diff --git a/pyproject.toml b/pyproject.toml index ea61747..81ea943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ full = [ ] [project.scripts] -maloja = "maloja.proccontrol.control:main" +maloja = "maloja.__main__:main" [build-system] requires = ["flit_core >=3.2,<4"]