Merge branch 'backend'

This commit is contained in:
Krateng 2020-08-18 05:04:34 +02:00
commit 833048440c
31 changed files with 800 additions and 759 deletions

View File

@ -74,6 +74,8 @@ I can support you with issues best if you use **Alpine Linux**. In my experience
There is a Dockerfile in the repo that should work by itself. You can also use the unofficial [Dockerhub repository](https://hub.docker.com/r/foxxmd/maloja) kindly provided by FoxxMD.
You might want to set the environment variables `MALOJA_DEFAULT_PASSWORD`, `MALOJA_SKIP_SETUP` and `MALOJA_DATA_DIRECTORY`.
## How to use
@ -141,7 +143,7 @@ It is recommended to define a different API key for every scrobbler you use in `
### Manual
If you can't automatically scrobble your music, you can always do it manually on the `/manual` page of your Maloja server.
If you can't automatically scrobble your music, you can always do it manually on the `/admin_manual` page of your Maloja server.
## How to extend

View File

@ -15,7 +15,7 @@ links = {
requires = [
"bottle>=0.12.16",
"waitress>=1.3",
"doreah>=1.6.7",
"doreah>=1.6.9",
"nimrodel>=0.6.3",
"setproctitle>=1.1.10",
"wand>=0.5.4",

View File

View File

@ -7,6 +7,14 @@
WEB_PORT = 42010
HOST = "::" # You most likely want either :: for IPv6 or 0.0.0.0 for IPv4 here
[Login]
DEFAULT_PASSWORD = none
FORCE_PASSWORD = none
# these are only meant for Docker containers
# on first start, set the environment variable MALOJA_DEFAULT_PASSWORD
# if you forgot and already generated a random password, you can overwrite it with MALOJA_FORCE_PASSWORD
[Third Party Services]
# order in which to use the metadata providers

View File

@ -18,6 +18,7 @@ from doreah.logging import log
from doreah import tsv
from doreah import settings
from doreah.caching import Cache, DeepCache
from doreah.auth import authenticated_api, authenticated_api_with_alternate
try:
from doreah.persistence import DiskDict
except: pass
@ -242,6 +243,23 @@ def normalize_name(name):
########
########
# skip regular authentication if api key is present in request
# an api key now ONLY permits scrobbling tracks, no other admin tasks
def api_key_correct(request):
args = request.query
print(dict(args))
if "key" in args:
apikey = args["key"]
print(args)
del args["key"]
print(args)
elif "apikey" in args:
apikey = args["apikey"]
del args["apikey"]
else: return False
return checkAPIkey(apikey)
dbserver = API(delay=True,path="api")
@ -671,23 +689,19 @@ def trackInfo(track):
@dbserver.get("newscrobble")
@dbserver.post("newscrobble")
@authenticated_api_with_alternate(api_key_correct)
def post_scrobble(artist:Multi,**keys):
artists = "/".join(artist)
title = keys.get("title")
album = keys.get("album")
duration = keys.get("seconds")
apikey = keys.get("key")
client = checkAPIkey(apikey)
if client == False: # empty string allowed!
response.status = 403
return ""
try:
time = int(keys.get("time"))
except:
time = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
log("Incoming scrobble (native API): Client " + client + ", ARTISTS: " + str(artists) + ", TRACK: " + title,module="debug")
log("Incoming scrobble (native API): ARTISTS: " + str(artists) + ", TRACK: " + title,module="debug")
(artists,title) = cla.fullclean(artists,title)
## this is necessary for localhost testing
@ -720,13 +734,12 @@ def sapi(path:Multi,**keys):
@dbserver.post("newrule")
@authenticated_api
def newrule(**keys):
apikey = keys.pop("key",None)
if (checkAPIkey(apikey)):
tsv.add_entry(datadir("rules/webmade.tsv"),[k for k in keys])
#addEntry("rules/webmade.tsv",[k for k in keys])
global db_rulestate
db_rulestate = False
tsv.add_entry(datadir("rules/webmade.tsv"),[k for k in keys])
#addEntry("rules/webmade.tsv",[k for k in keys])
global db_rulestate
db_rulestate = False
def issues():
@ -822,40 +835,84 @@ def check_issues():
def get_predefined_rulesets():
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
rulesets = []
for f in os.listdir(datadir("rules/predefined")):
if f.endswith(".tsv"):
rawf = f.replace(".tsv","")
valid = True
for char in rawf:
if char not in validchars:
valid = False
break # don't even show up invalid filenames
if not valid: continue
if not "_" in rawf: continue
try:
with open(datadir("rules/predefined",f)) as tsvfile:
line1 = tsvfile.readline()
line2 = tsvfile.readline()
if "# NAME: " in line1:
name = line1.replace("# NAME: ","")
else: name = rawf.split("_")[1]
if "# DESC: " in line2:
desc = line2.replace("# DESC: ","")
else: desc = ""
author = rawf.split("_")[0]
except:
continue
ruleset = {"file":rawf}
rulesets.append(ruleset)
if os.path.exists(datadir("rules",f)):
ruleset["active"] = True
else:
ruleset["active"] = False
ruleset["name"] = name
ruleset["author"] = author
ruleset["desc"] = desc
return rulesets
@dbserver.post("importrules")
@authenticated_api
def import_rulemodule(**keys):
apikey = keys.pop("key",None)
filename = keys.get("filename")
remove = keys.get("remove") is not None
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
filename = "".join(c for c in filename if c in validchars)
if (checkAPIkey(apikey)):
filename = keys.get("filename")
remove = keys.get("remove") is not None
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
filename = "".join(c for c in filename if c in validchars)
if remove:
log("Deactivating predefined rulefile " + filename)
os.remove(datadir("rules/" + filename + ".tsv"))
else:
log("Importing predefined rulefile " + filename)
os.symlink(datadir("rules/predefined/" + filename + ".tsv"),datadir("rules/" + filename + ".tsv"))
if remove:
log("Deactivating predefined rulefile " + filename)
os.remove(datadir("rules/" + filename + ".tsv"))
else:
log("Importing predefined rulefile " + filename)
os.symlink(datadir("rules/predefined/" + filename + ".tsv"),datadir("rules/" + filename + ".tsv"))
@dbserver.post("rebuild")
@authenticated_api
def rebuild(**keys):
apikey = keys.pop("key",None)
if (checkAPIkey(apikey)):
log("Database rebuild initiated!")
global db_rulestate
db_rulestate = False
sync()
from .proccontrol.tasks.fixexisting import fix
fix()
global cla, coa
cla = CleanerAgent()
coa = CollectorAgent()
build_db()
invalidate_caches()
log("Database rebuild initiated!")
global db_rulestate
db_rulestate = False
sync()
from .proccontrol.tasks.fixexisting import fix
fix()
global cla, coa
cla = CleanerAgent()
coa = CollectorAgent()
build_db()
invalidate_caches()
@ -896,15 +953,15 @@ def search(**keys):
@dbserver.post("addpicture")
def add_picture(b64,key,artist:Multi=[],title=None):
if (checkAPIkey(key)):
keys = FormsDict()
for a in artist:
keys.append("artist",a)
if title is not None: keys.append("title",title)
k_filter, _, _, _ = uri_to_internal(keys)
if "track" in k_filter: k_filter = k_filter["track"]
utilities.set_image(b64,**k_filter)
@authenticated_api
def add_picture(b64,artist:Multi=[],title=None):
keys = FormsDict()
for a in artist:
keys.append("artist",a)
if title is not None: keys.append("title",title)
k_filter, _, _, _ = uri_to_internal(keys)
if "track" in k_filter: k_filter = k_filter["track"]
utilities.set_image(b64,**k_filter)
####
## Server operation

View File

@ -49,6 +49,12 @@ config(
},
regular={
"autostart": False
},
auth={
"multiuser":False,
"cookieprefix":"maloja",
"stylesheets":["/style.css"],
"dbfile":datadir("auth/auth.ddb")
}
)

View File

@ -43,7 +43,7 @@ def start():
print("Visit your server address (Port " + str(port) + ") to see your web interface. Visit /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) + "/setup"))
print("\t" + col["blue"]("http://localhost:" + str(port) + "/admin_setup"))
return True
except:
print("Error while starting Maloja.")

View File

@ -2,6 +2,7 @@ import pkg_resources
from distutils import dir_util
from doreah import settings
from doreah.io import col, ask, prompt
from doreah import auth
import os
from ..globalconf import datadir
@ -22,7 +23,13 @@ def copy_initial_local_files():
#shutil.copy(folder,DATA_DIR)
dir_util.copy_tree(folder,datadir(),update=False)
charset = list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
def randomstring(length=32):
import random
key = ""
for i in range(length):
key += str(random.choice(charset))
return key
def setup():
@ -48,10 +55,7 @@ def setup():
else:
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:
import random
key = ""
for i in range(64):
key += str(random.choice(list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")))
key = randomstring(64)
print("Your API Key: " + col["yellow"](key))
with open(datadir("clients/authenticated_machines.tsv"),"w") as keyfile:
keyfile.write(key + "\t" + "Default Generated Key")
@ -59,6 +63,32 @@ def setup():
pass
# PASSWORD
defaultpassword = settings.get_settings("DEFAULT_PASSWORD")
forcepassword = settings.get_settings("FORCE_PASSWORD")
# this is mainly meant for docker, supply password via environment variable
if forcepassword is not None:
# user has specified to force the pw, nothing else matters
auth.defaultuser.setpw(forcepassword)
print("Password has been set.")
elif auth.defaultuser.checkpw("admin"):
# if the actual pw is admin, it means we've never set this up properly (eg first start after update)
if defaultpassword is None:
# non-docker installation or user didn't set environment variable
defaultpassword = randomstring(32)
newpw = prompt("Please set a password for web backend access. Leave this empty to generate a random password.",skip=SKIP,secret=True)
if newpw is None:
newpw = defaultpassword
print("Generated password:",newpw)
auth.defaultuser.setpw(newpw)
else:
# docker installation (or settings file, but don't do that)
# we still 'ask' the user to set one, but for docker this will be skipped
newpw = prompt("Please set a password for web backend access. Leave this empty to use the default password.",skip=SKIP,default=defaultpassword,secret=True)
auth.defaultuser.setpw(newpw)
if settings.get_settings("NAME") is None:
name = prompt("Please enter your name. This will be displayed e.g. when comparing your charts to another user. Leave this empty if you would not like to specify a name right now.",default="Generic Maloja User",skip=SKIP)
settings.update_settings(datadir("settings/settings.ini"),{"NAME":name},create_new=True)

View File

@ -4,7 +4,7 @@ from .globalconf import datadir, DATA_DIR
# server stuff
from bottle import Bottle, route, get, post, error, run, template, static_file, request, response, FormsDict, redirect, template, HTTPResponse, BaseRequest
from bottle import Bottle, route, get, post, error, run, template, static_file, request, response, FormsDict, redirect, template, HTTPResponse, BaseRequest, abort
import waitress
# templating
from jinja2 import Environment, PackageLoader, select_autoescape
@ -25,6 +25,7 @@ from doreah import settings
from doreah.logging import log
from doreah.timing import Clock
from doreah.pyhp import file as pyhpfile
from doreah import auth
# technical
#from importlib.machinery import SourceFileLoader
import importlib
@ -55,6 +56,7 @@ STATICFOLDER = pkg_resources.resource_filename(__name__,"static")
DATAFOLDER = DATA_DIR
webserver = Bottle()
auth.authapi.mount(server=webserver)
pthjoin = os.path.join
@ -92,10 +94,9 @@ def mainpage():
def customerror(error):
code = int(str(error).split(",")[0][1:])
if os.path.exists(pthjoin(WEBFOLDER,"errors",str(code) + ".pyhp")):
return pyhpfile(pthjoin(WEBFOLDER,"errors",str(code) + ".pyhp"),{"errorcode":code})
else:
return pyhpfile(pthjoin(WEBFOLDER,"errors","generic.pyhp"),{"errorcode":code})
template = jinjaenv.get_template('error.jinja')
res = template.render(errorcode=code)
return res
@ -158,6 +159,10 @@ def get_css():
return css
@webserver.route("/login")
def login():
return auth.get_login_page()
@webserver.route("/<name>.<ext>")
def static(name,ext):
assert ext in ["txt","ico","jpeg","jpg","png","less","js"]
@ -173,6 +178,10 @@ def static(name,ext):
return response
aliases = {
"admin": "admin_overview",
"manual": "admin_manual"
}
@ -216,8 +225,17 @@ jinjaenv = Environment(
jinjaenv.globals.update(JINJA_CONTEXT)
@webserver.route("/<name:re:admin.*>")
@auth.authenticated
def static_html_private(name):
return static_html(name)
@webserver.route("/<name>")
def static_html_public(name):
return static_html(name)
def static_html(name):
if name in aliases: redirect(aliases[name])
linkheaders = ["</style.css>; rel=preload; as=style"]
keys = remove_identical(FormsDict.decode(request.query))
@ -228,7 +246,7 @@ def static_html(name):
pyhp_pref = settings.get_settings("USE_PYHP")
jinja_pref = settings.get_settings("USE_JINJA")
adminmode = request.cookies.get("adminmode") == "true" and database.checkAPIkey(request.cookies.get("apikey")) is not False
adminmode = request.cookies.get("adminmode") == "true" and auth.check(request)
clock = Clock()
clock.start()
@ -277,48 +295,50 @@ def static_html(name):
# if not, use the old way
else:
try:
with open(pthjoin(WEBFOLDER,name + ".html")) as htmlfile:
html = htmlfile.read()
with open(pthjoin(WEBFOLDER,name + ".html")) as htmlfile:
html = htmlfile.read()
# apply global substitutions
with open(pthjoin(WEBFOLDER,"common/footer.html")) as footerfile:
footerhtml = footerfile.read()
with open(pthjoin(WEBFOLDER,"common/header.html")) as headerfile:
headerhtml = headerfile.read()
html = html.replace("</body>",footerhtml + "</body>").replace("</head>",headerhtml + "</head>")
# apply global substitutions
with open(pthjoin(WEBFOLDER,"common/footer.html")) as footerfile:
footerhtml = footerfile.read()
with open(pthjoin(WEBFOLDER,"common/header.html")) as headerfile:
headerhtml = headerfile.read()
html = html.replace("</body>",footerhtml + "</body>").replace("</head>",headerhtml + "</head>")
# If a python file exists, it provides the replacement dict for the html file
if os.path.exists(pthjoin(WEBFOLDER,name + ".py")):
#txt_keys = SourceFileLoader(name,"web/" + name + ".py").load_module().replacedict(keys,DATABASE_PORT)
try:
module = importlib.import_module(".web." + name,package="maloja")
txt_keys,resources = module.instructions(keys)
except Exception as e:
log("Error in website generation: " + str(sys.exc_info()),module="error")
raise
# If a python file exists, it provides the replacement dict for the html file
if os.path.exists(pthjoin(WEBFOLDER,name + ".py")):
#txt_keys = SourceFileLoader(name,"web/" + name + ".py").load_module().replacedict(keys,DATABASE_PORT)
try:
module = importlib.import_module(".web." + name,package="maloja")
txt_keys,resources = module.instructions(keys)
except Exception as e:
log("Error in website generation: " + str(sys.exc_info()),module="error")
raise
# add headers for server push
for resource in resources:
if all(ord(c) < 128 for c in resource["file"]):
# we can only put ascii stuff in the http header
linkheaders.append("<" + resource["file"] + ">; rel=preload; as=" + resource["type"])
# add headers for server push
for resource in resources:
if all(ord(c) < 128 for c in resource["file"]):
# we can only put ascii stuff in the http header
linkheaders.append("<" + resource["file"] + ">; rel=preload; as=" + resource["type"])
# apply key substitutions
for k in txt_keys:
if isinstance(txt_keys[k],list):
# if list, we replace each occurence with the next item
for element in txt_keys[k]:
html = html.replace(k,element,1)
else:
html = html.replace(k,txt_keys[k])
# apply key substitutions
for k in txt_keys:
if isinstance(txt_keys[k],list):
# if list, we replace each occurence with the next item
for element in txt_keys[k]:
html = html.replace(k,element,1)
else:
html = html.replace(k,txt_keys[k])
response.set_header("Link",",".join(linkheaders))
log("Generated page {name} in {time:.5f}s (Python+HTML)".format(name=name,time=clock.stop()),module="debug")
return html
#return static_file("web/" + name + ".html",root="")
response.set_header("Link",",".join(linkheaders))
log("Generated page {name} in {time:.5f}s (Python+HTML)".format(name=name,time=clock.stop()),module="debug")
return html
except:
abort(404, "Page does not exist")
# Shortlinks

View File

@ -0,0 +1,149 @@
var lastArtists = []
var lastTrack = ""
function addArtist(artist) {
var newartistfield = document.getElementById("artists");
var artistelement = document.createElement("span");
artistelement.innerHTML = artist;
artistelement.style = "padding:5px;";
document.getElementById("artists_td").insertBefore(artistelement,newartistfield);
newartistfield.placeholder = "Backspace to remove last"
}
function keyDetect(event) {
if (event.key === "Enter" || event.key === "Tab") { addEnteredArtist() }
if (event.key === "Backspace" && document.getElementById("artists").value == "") { removeArtist() }
}
function addEnteredArtist() {
var newartistfield = document.getElementById("artists");
var newartist = newartistfield.value.trim();
newartistfield.value = "";
if (newartist != "") {
addArtist(newartist);
}
}
function removeArtist() {
var artists = document.getElementById("artists_td").getElementsByTagName("span")
var lastartist = artists[artists.length-1]
document.getElementById("artists_td").removeChild(lastartist);
if (artists.length < 1) {
document.getElementById("artists").placeholder = "Separate with Enter"
}
}
function clear() {
document.getElementById("title").value = "";
document.getElementById("artists").value = "";
var artists = document.getElementById("artists_td").getElementsByTagName("span")
while (artists.length > 0) {
removeArtist();
}
}
function scrobbleIfEnter(event) {
if (event.key === "Enter") {
scrobbleNew()
}
}
function scrobbleNew() {
var artistnodes = document.getElementById("artists_td").getElementsByTagName("span");
var artists = [];
for (let node of artistnodes) {
artists.push(node.innerHTML);
}
var title = document.getElementById("title").value;
scrobble(artists,title);
}
function scrobble(artists,title) {
lastArtists = artists;
lastTrack = title;
var artist = artists.join(";");
if (title != "" && artists.length > 0) {
xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = scrobbledone
xhttp.open("GET","/api/newscrobble?artist=" + encodeURIComponent(artist) +
"&title=" + encodeURIComponent(title), true);
xhttp.send();
}
document.getElementById("title").value = "";
document.getElementById("artists").value = "";
var artists = document.getElementById("artists_td").getElementsByTagName("span");
while (artists.length > 0) {
removeArtist();
}
}
function scrobbledone() {
if (this.readyState == 4 && this.status == 200) {
result = JSON.parse(this.responseText);
txt = result["track"]["title"] + " by " + result["track"]["artists"][0];
if (result["track"]["artists"].length > 1) {
txt += " et al.";
}
document.getElementById("notification").innerHTML = "Scrobbled " + txt + "!";
}
}
function repeatLast() {
clear();
for (let artist of lastArtists) {
addArtist(artist);
}
document.getElementById("title").value = lastTrack;
}
///
// SEARCH
///
function search_manualscrobbling(searchfield) {
var txt = searchfield.value;
if (txt == "") {
}
else {
xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = searchresult_manualscrobbling;
xhttp.open("GET","/api/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.send();
}
}
function searchresult_manualscrobbling() {
if (this.readyState == 4 && this.status == 200) {
document.getElementById("searchresults").innerHTML = "";
result = JSON.parse(this.responseText);
tracks = result["tracks"].slice(0,10);
console.log(tracks);
for (let t of tracks) {
track = document.createElement("span");
trackstr = t["artists"].join(", ") + " - " + t["title"];
tracklink = t["link"];
track.innerHTML = "<a href='" + tracklink + "'>" + trackstr + "</a>";
row = document.createElement("tr")
col1 = document.createElement("td")
col1.className = "button"
col1.innerHTML = "Scrobble!"
col1.onclick = function(){ scrobble(t["artists"],t["title"])};
col2 = document.createElement("td")
row.appendChild(col1)
row.appendChild(col2)
col2.appendChild(track)
document.getElementById("searchresults").appendChild(row);
}
}
}

View File

@ -1,3 +1,3 @@
function upload(encodedentity,apikey,b64) {
neo.xhttprequest("/api/addpicture?key=" + apikey + "&" + encodedentity,{"b64":b64},"POST")
function upload(encodedentity,b64) {
neo.xhttprequest("/api/addpicture?" + encodedentity,{"b64":b64},"POST")
}

View File

@ -101,11 +101,11 @@ div.footer div:nth-child(3) {
}
div.footer span a {
padding-left:20px;
//padding-left:20px;
background-repeat:no-repeat;
background-size:contain;
background-position:left;
background-image:url("https://github.com/favicon.ico");
//background-image:url("https://github.com/favicon.ico");
}
div.footer input {

View File

@ -22,6 +22,6 @@
</span>
</div>
<a href="/admin"><div title="Server Administration" id="settingsicon">
<a href="/admin_overview"><div title="Server Administration" id="settingsicon">
<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>

View File

@ -1,68 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Maloja - Issues</title>
<script src="/cookies.js"></script>
</head>
<body>
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Possible Issues</h1><br/>
<span>with your library</span>
<p class="stats">KEY_ISSUES Issues</p>
<p>Maloja can identify possible problems with consistency or redundancy in your library. After making any changes, you should <a class="textlink" onclick='fullrebuild()'>rebuild your library</a>.<br/>
Your API key is required to make any changes to the server: <input id='apikey' onchange='checkAPIkey()' style='width:300px;'/></p>
</td>
</tr>
</table>
KEY_ISSUESLIST
</body>
<script>
function newrule() {
if (apikeycorrect) {
keys = ""
for (var i = 1; i < arguments.length; i++) {
keys += encodeURIComponent(arguments[i]) + "&"
}
apikey = document.getElementById("apikey").value
keys += "key=" + encodeURIComponent(apikey)
console.log(keys)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/newrule?", true);
xhttp.send(keys);
e = arguments[0]
line = e.parentNode
line.parentNode.removeChild(line)
}
}
function fullrebuild() {
if (apikeycorrect) {
apikey = document.getElementById("apikey").value
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/rebuild", true);
xhttp.send("key=" + encodeURIComponent(apikey))
window.location = "/wait";
}
}
</script>
</html>

View File

@ -1,44 +0,0 @@
import urllib
from .. import database
from ..htmlgenerators import artistLink
def instructions(keys):
db_data = database.issues()
i = 0
html = "<table class='list'>"
# if db_data["inconsistent"]:
# html += "<tr>"
# html += "<td>The current database wasn't built with all current rules in effect. Any problem below might be a false alarm and fixing it could create redundant rules.</td>"
# html += """<td class='button important' onclick="fullrebuild()"><div>Rebuild the database</div></td>"""
# html += "</tr>"
# i += 1
for d in db_data["duplicates"]:
html += "<tr>"
html += "<td>'" + artistLink(d[0]) + "'"
html += " is a possible duplicate of "
html += "'" + artistLink(d[1]) + "'</td>"
html += """<td class='button' onclick="newrule(this,'replaceartist','""" + d[0] + """','""" + d[1] + """')"><div>""" + d[1] + """ is correct</div></td>"""
html += """<td class='button' onclick="newrule(this,'replaceartist','""" + d[1] + """','""" + d[0] + """')"><div>""" + d[0] + """ is correct</div></td>"""
html += "</tr>"
i += 1
for c in db_data["combined"]:
html += "<tr>"
html += "<td>'" + artistLink(c[0]) + "' sounds like the combination of " + str(len(c[1])) + " artists: "
for a in c[1]:
html += "'" + artistLink(a) + "' "
html += "</td>"
html += """<td class='button' onclick="newrule(this,'replaceartist','""" + c[0] + """','""" + "".join(c[1]) + """')"><div>Fix it</div></td>"""
html += "</tr>"
i += 1
for n in db_data["newartists"]:
html += "<tr>"
html += "<td>Is '" + n[0] + "' in '" + artistLink(n[1]) + "' an artist?</td>"
html += """<td class='button' onclick="newrule(this,'replaceartist','""" + n[1] + """','""" + "".join(n[2] + [n[0]]) + """')"><div>Yes</div></td>"""
html += "</tr>"
i += 1
html += "</table>"
return ({"KEY_ISSUESLIST":html,"KEY_ISSUES":str(i)},[])

View File

@ -0,0 +1,49 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Backend{% endblock %}
{% block content %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Admin Panel</h1>
<br/>
<span>
{% if page=='admin_overview' %}
<span style="opacity:0.5;">Overview</span>
{% else %}
<a href="/admin_overview">Overview</a>
{% endif %} |
{% if page=='admin_setup' %}
<span style="opacity:0.5;">Server Setup</span>
{% else %}
<a href="/admin_setup">Server Setup</a>
{% endif %} |
{% if page=='admin_manual' %}
<span style="opacity:0.5;">Manual Scrobbling</span>
{% else %}
<a href="/admin_manual">Manual Scrobbling</a>
{% endif %} |
{% if page=='admin_issues' %}
<span style="opacity:0.5;">Database Maintenance</span>
{% else %}
<a href="/admin_issues">Database Maintenance</a>
{% endif %}
</span>
<br/><br/>
<span id="notification"></span>
</td>
</tr>
</table>
{% block maincontent %}
{% endblock %}
{% endblock %}

View File

@ -20,6 +20,25 @@
{% block content %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('{% block imageurl %}/favicon.png{% endblock %}')"></div>
</td>
<td class="text">
<h1>{% block heading %}{% endblock %}</h1><br/>
{% block top_info %}
{% endblock %}
</td>
</tr>
</table>
{% block maincontent %}
{% endblock %}
{% endblock %}
@ -48,7 +67,7 @@
</span>
</div>
<a href="/admin"><div title="Server Administration" id="settingsicon">
<a href="/admin_overview"><div title="Server Administration" id="settingsicon">
<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>

View File

@ -0,0 +1,77 @@
{% set page ='admin_issues' %}
{% extends "abstracts/admin.jinja" %}
{% block title %}Maloja - Issues{% endblock %}
{% block scripts %}
<script>
function newrule() {
keys = ""
for (var i = 1; i < arguments.length; i++) {
keys += encodeURIComponent(arguments[i]) + "&"
}
console.log(keys)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/newrule?", true);
xhttp.send(keys);
e = arguments[0]
line = e.parentNode
line.parentNode.removeChild(line)
}
function fullrebuild() {
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/rebuild", true);
xhttp.send()
window.location = "/wait";
}
</script>
{% endblock %}
{% set issuedata = dbp.issues() %}
{% block maincontent %}
<p>Maloja can identify possible problems with consistency or redundancy in your library. After making any changes, you should <a class="textlink" onclick='fullrebuild()'>rebuild your library</a>.</p>
<table class="list">
{% if issuedata.inconsistent %}
<tr>
<td>The current database wasn't built with all current rules in effect. Any problem below might be a false alarm and fixing it could create redundant rules.</td>
<td class='button important' onclick="fullrebuild()"><div>Rebuild the database</div></td>
</tr>
{% endif %}
{% for issue in issuedata.duplicates %}
<tr>
<td>{{ htmlgenerators.artistLink(issue[0]) }} is a possible duplicate of {{ htmlgenerators.artistLink(issue[1]) }}</td>
<td class='button' onclick="newrule(this,'replaceartist','{{ issue[0] }}','{{ issue[1] }}')"><div>{{ issue[1] }} is correct</div></td>
<td class='button' onclick="newrule(this,'replaceartist','{{ issue[1] }}','{{ issue[0] }}')"><div>{{ issue[0] }} is correct</div></td>
</tr>
{% endfor %}
{% for issue in issuedata.combined %}
<tr>
<td>{{ artistLink(issue[0]) }} sounds like the combination of {{ len(issue[1]) }} artists:
{{ issue[1]|join(", ") }}
</td>
<td class='button' onclick="newrule(this,'replaceartist','{{ issue[0] }}','{{ issue[1] | join('␟') }}')"><div>Fix it</div></td>
</tr>
{% endfor %}
{% for issue in issuedata.newartists %}
<tr>
<td>Is '{{ issue[0] }}' in '{{ htmlgenerators.artistLink(issue[1]) }}' an artist?</td>
<td class='button' onclick="newrule(this,'replaceartist','{{ issue[1] }}','{{ (issue[2] + [issue[0]]) | join('␟') }}')"><div>Yes</div></td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% set page ='admin_manual' %}
{% extends "abstracts/admin.jinja" %}
{% block title %}Maloja - Manual Scrobbling{% endblock %}
{% block scripts %}
<script src="/manualscrobble.js"></script>
{% endblock %}
{% block maincontent %}
<h2>Scrobble new discovery</h2>
<table>
<tr>
<td style="padding-right:7px;">
Artists:
</td><td id="artists_td">
<input placeholder='Separate with Enter' class='simpleinput' id='artists' onKeydown='keyDetect(event)' />
</td>
</tr>
<tr>
<td style="padding-right:7px;">
Title:
</td><td>
<input placeholder='Enter to scrobble' class='simpleinput' id='title' onKeydown='scrobbleIfEnter(event)' />
</td>
</tr>
</table>
<br/>
<span class="button" onclick="scrobbleNew(event)">Scrobble!</span>
<span class="button" onclick="repeatLast()">Last Manual Scrobble</span>
<br/>
<h2>Search</h2>
<input class="simpleinput" placeholder='Search for a track' oninput='search_manualscrobbling(this)' />
<br/><br/>
<table id="searchresults"></table>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% set page ='admin_overview' %}
{% extends "abstracts/admin.jinja" %}
{% block title %}Maloja - Admin Panel{% endblock %}
{% block scripts %}
<script>
function versioncompare(a,b) {
for (var pos=0;pos<3;pos++) {
var v1 = parseInt(a[pos]) || 0;
var v2 = parseInt(b[pos]) || 0;
if (v1 > v2) { return 1;}
if (v1 < v2) { return -1;}
}
return 0;
}
neo.xhttprequest("https://pypi.org/pypi/malojaserver/json",{},"GET",json=true).then((response)=>{
result = JSON.parse(response.responseText);
latestvers = result.info.version.split(".");
neo.xhttprequest("/api/serverinfo",{},"GET",json=true).then((response)=>{
result = JSON.parse(response.responseText);
thisvers = result.version;
document.getElementById("latestversion").innerHTML = latestvers.join(".");
document.getElementById("currentversion").innerHTML = thisvers.join(".");
if (versioncompare(latestvers,thisvers) <= 0) {
document.getElementById("currentversion").style.color = "green";
}
else {
document.getElementById("currentversion").style.color = "red";
document.getElementById("updatestatus").innerHTML = "Consider updating to take advantage of new features";
}
});
});
function activate() {
neo.setCookie("adminmode","true");
window.location.reload(true);
}
function deactivate() {
neo.setCookie("adminmode","false");
window.location.reload(true);
}
</script>
{% endblock %}
{% block maincontent %}
<h2>Update</h2>
Currently installed Maloja version: <span id="currentversion">Loading...</span><br/>
Latest recommended Maloja version: <span id="latestversion">Loading...</span><br/>
<span id="updatestatus"></span>
<h2>Admin Mode</h2>
Admin Mode allows you to manually scrobble from various places on the web interface instead of just the dedicated page.<br/><br/>
{% if adminmode %}
<span id="adminmodebutton" class="button" onclick="deactivate()">Deactivate</span>
{% else %}
<span id="adminmodebutton" class="button" onclick="activate()">Activate</span>
{% endif %}
<h2>Links</h2>
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://github.com/krateng/maloja/issues/new">Report Issue</a><br/>
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://github.com/krateng/maloja/blob/master/README.md">Readme</a><br/>
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://pypi.org/project/malojaserver/">PyPi</a><br/>
{% endblock %}

View File

@ -0,0 +1,127 @@
{% set page ='admin_setup' %}
{% extends "abstracts/admin.jinja" %}
{% block title %}Maloja - Setup{% endblock %}
{% block scripts %}
<script>
function replaceurls() {
url = window.location.origin
s = document.getElementsByName("serverurl")
for (var i=0;i<s.length;i++) {
s[i].innerHTML = url
}
}
function replace() {
replaceurls();
}
function activateRuleModule(e,filename) {
keys = "filename=" + encodeURIComponent(filename)
console.log(keys)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Add","Remove")
e.getAttributeNode("onclick").value = e.getAttribute("onclick").replace("activate","deactivate")
/* Nobody ever look at this code please */
}
function deactivateRuleModule(e,filename) {
keys = "remove&filename=" + encodeURIComponent(filename)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Remove","Add")
e.getAttributeNode("onclick").value = e.getAttribute("onclick").replace("deactivate","activate")
}
document.addEventListener("DOMContentLoaded",replace);
</script>
{% endblock %}
{% set rulesets = dbp.get_predefined_rulesets() %}
{% block maincontent %}
<h2>Start Scrobbling</h2>
If you use Vivaldi, Brave, Iridium or any other Chromium-based browser and listen to music on Plex or YouTube Music, download the extension and simply enter the server URL as well as your API key in the relevant fields. They will turn green if the server is accessible.
<br/><br/>
You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter <span class="stats"><span name="serverurl">yourserver.tld</span>/api/s/audioscrobbler</span> as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use <span class="stats"><span name="serverurl">yourserver.tld</span>/api/s/listenbrainz</span> as the API URL and your API key as token.
<br/><br/>
If you use another browser or another music player, you could try to code your own extension. The API is super simple! Just send a POST HTTP request to
<span class="stats"><span name="serverurl">yourserver.tld</span>/api/newscrobble</span>
(make sure to use the public URL) with the key-value-pairs
<br/>
<br/>
<table class="misc">
<tr> <td>artist</td> <td><i>Artist String</i></td> </tr>
<tr> <td>title</td> <td><i>Title String</i></td> </tr>
<tr> <td>key</td> <td><i>API Key</i></td> </tr>
<tr> <td>seconds</td> <td><i>Duration of Scrobble - optional and currently not used</i></td> </tr>
</table>
<br/><br/>
Finally, you could always <a class="textlink" href="/admin_manual">manually scrobble</a>!
<br/><br/>
<h2>Import your Last.FM data</h2>
Switching from Last.fm? <a class="textlink" href="https://benjaminbenben.com/lastfm-to-csv/">Download all your data</a> and run the command <span class="stats">maloja import <i>(the file you just downloaded)</i></span>.
<br/><br/>
<h2>Set up some rules</h2>
After you've scrobbled for a bit, you might want to check the <a class="textlink" href="/admin_issues">Issues page</a> to see if you need to set up some rules. You can also manually add rules in your server's "rules" directory - just add your own .tsv file and read the instructions on how to declare a rule.
<br/><br/>
You can also set up some predefined rulesets right away!
<br/>
<br/><br/>
<table class='misc'>
<tr>
<th></th>
<th>Module</th>
<th>Author</th>
<th>Description</th>
</tr>
{% for rs in rulesets %}
<tr>
{% if rs.active %}
<td class='interaction' onclick=deactivateRuleModule(this,'{{ rs.file }}')><a class='textlink'>Remove:</a></td>
{% else %}
<td class='interaction' onclick=activateRuleModule(this,'{{ rs.file }}')><a class='textlink'>Add:</a></td>
{% endif %}
<td>{{ rs.name }}</td>
<td>{{ rs.author }}</td>
<td>{{ rs.desc }}</td>
</tr>
{% endfor %}
</table>
<br/><br/>
<h2>Say thanks</h2>
Donations are never required, but always appreciated. If you really like Maloja, you can fund my next Buttergipfel via
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://paypal.me/krateng">PayPal</a>, <a class="textlink" href="bitcoin:1krat8JMniJBTiHftMfR1LtF3Y1w5DAxx">Bitcoin</a> or <a class="textlink" target="_blank" rel="noopener noreferrer" href="https://flattr.com/@Krateng">Flattr</a>.
<br/><br/>
<h2>View your stats</h2>
Done! Visit <a class="textlink" href="/"><span name="serverurl">yourserver.tld</span></a> (or your public / proxy URL) to look at your overview page. Almost everything is clickable!
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "base.jinja" %}
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - {{ artist }}{% endblock %}
{% block scripts %}
@ -36,7 +36,7 @@
<td class="image">
{% if adminmode %}
<div
class="changeable-image" data-uploader="b64=>upload('{encodedartist}','{apikey}',b64)"
class="changeable-image" data-uploader="b64=>upload('{{ encodedartist }}',b64)"
style="background-image:url('{{ utilities.getArtistImage(artist=artist,fast=True) }}');"
></div>
{% else %}

View File

@ -1,4 +1,4 @@
{% extends "base.jinja" %}
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - {{ artist }}{% endblock %}
{% block scripts %}

View File

@ -1,4 +1,4 @@
{% extends "base.jinja" %}
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Track Charts{% endblock %}
{% block scripts %}

View File

@ -0,0 +1,18 @@
{% extends "abstracts/base.jinja" %}
{% block content %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Error {{ errorcode }}</h1><br/>
<p>That did not work. Don't ask me why.</p>
</td>
</tr>
</table>
{% endblock %}

View File

@ -1,11 +1,11 @@
{% extends "base.jinja" %}
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - {{ track.title }}{% endblock %}
{% block scripts %}
<script src="/rangeselect.js"></script>
<script>
function scrobble(encodedtrack,apikey) {
neo.xhttprequest('/api/newscrobble?' + encodedtrack + "key=" + apikey).then(response=>{window.location.reload()});
function scrobble(encodedtrack) {
neo.xhttprequest('/api/newscrobble?' + encodedtrack).then(response=>{window.location.reload()});
}
</script>
{% endblock %}
@ -30,7 +30,7 @@
<td class="image">
{% if adminmode %}
<div
class="changeable-image" data-uploader="b64=>upload('{encodedartist}','{apikey}',b64)"
class="changeable-image" data-uploader="b64=>upload('{{ encodedtrack }}',b64)"
style="background-image:url('{{ utilities.getTrackImage(artists=track.artists,title=track.title,fast=True) }}');"
></div>
{% else %}
@ -45,7 +45,10 @@
<span class="rank"><a href="/charts_tracks?max=100">#{{ info.position }}</a></span>
<br/>
<p class="stats"><a href="/scrobbles?{{ encodedtrack }}">{{ info['scrobbles'] }} Scrobbles</a></p>
<p class="stats">
{% if adminmode %}<span onclick="scrobble('{{ encodedtrack }}')" class="button">Scrobble now</span>{% endif %}
<a href="/scrobbles?{{ encodedtrack }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>

View File

@ -1,199 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Maloja</title>
<script src="/cookies.js"></script>
<script>
function keyDetect(event) {
if (event.key === "Enter" || event.key === "Tab") { addArtist() }
if (event.key === "Backspace" && document.getElementById("artists").value == "") { removeArtist() }
}
function addArtist() {
element = document.getElementById("artists");
newartist = element.value.trim();
element.value = "";
if (newartist != "") {
artist = document.createElement("span");
artist.innerHTML = newartist;
artist.style = "padding:5px;";
document.getElementById("artists_td").insertBefore(artist,element);
element.placeholder = "Backspace to remove last"
}
}
function removeArtist() {
artists = document.getElementById("artists_td").getElementsByTagName("span")
lastartist = artists[artists.length-1]
document.getElementById("artists_td").removeChild(lastartist);
if (artists.length < 1) {
document.getElementById("artists").placeholder = "Separate with Enter"
}
}
function scrobbleIfEnter(event) {
if (event.key === "Enter") {
scrobbleNew()
}
}
function scrobbleNew() {
artistnodes = document.getElementById("artists_td").getElementsByTagName("span");
artists = [];
for (let node of artistnodes) {
artists.push(node.innerHTML);
}
title = document.getElementById("title").value;
scrobble(artists,title);
}
function scrobble(artists,title) {
artist = artists.join(";");
key = APIkey();
if (title != "" && artists.length > 0) {
xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = scrobbledone
xhttp.open("GET","/api/newscrobble?artist=" + encodeURIComponent(artist) +
"&title=" + encodeURIComponent(title) +
"&key=" + encodeURIComponent(key), true);
xhttp.send();
}
document.getElementById("title").value = "";
document.getElementById("artists").value = "";
parent = document.getElementById("artists_td");
artists = document.getElementById("artists_td").getElementsByTagName("span")
while (artists.length > 0) {
removeArtist();
}
}
function scrobbledone() {
if (this.readyState == 4 && this.status == 200) {
result = JSON.parse(this.responseText);
txt = result["track"]["title"] + " by " + result["track"]["artists"][0];
if (result["track"]["artists"].length > 1) {
txt += " et al.";
}
document.getElementById("scrobbleresult").innerHTML = "Scrobbled " + txt + "!";
}
}
///
// SEARCH
///
function search_manualscrobbling(searchfield) {
txt = searchfield.value;
if (txt == "") {
}
else {
xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = searchresult_manualscrobbling;
xhttp.open("GET","/api/search?max=5&query=" + encodeURIComponent(txt), true);
xhttp.send();
}
}
function searchresult_manualscrobbling() {
if (this.readyState == 4 && this.status == 200) {
document.getElementById("searchresults").innerHTML = "";
result = JSON.parse(this.responseText);
tracks = result["tracks"].slice(0,10);
console.log(tracks);
for (let t of tracks) {
track = document.createElement("span");
trackstr = t["artists"].join(", ") + " - " + t["title"];
tracklink = t["link"];
track.innerHTML = "<a href='" + tracklink + "'>" + trackstr + "</a>";
row = document.createElement("tr")
col1 = document.createElement("td")
col1.className = "button"
col1.innerHTML = "Scrobble!"
col1.onclick = function(){ scrobble(t["artists"],t["title"])};
col2 = document.createElement("td")
row.appendChild(col1)
row.appendChild(col2)
col2.appendChild(track)
document.getElementById("searchresults").appendChild(row);
}
}
}
</script>
</head>
<body>
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Manual Scrobbling</h1><br/>
<br/><br/>
API Key: <input id='apikey' onchange='checkAPIkey()' style='width:300px;'/><br/><br/>
<span id="scrobbleresult"></span>
</td>
</tr>
</table>
<h1>Scrobble new discovery</h1>
<table>
<tr>
<td style="padding-right:7px;">
Artists:
</td><td id="artists_td">
<input placeholder='Separate with Enter' class='simpleinput' id='artists' onKeydown='keyDetect(event)' />
</td>
</tr>
<tr>
<td style="padding-right:7px;">
Title:
</td><td>
<input placeholder='Enter to scrobble' class='simpleinput' id='title' onKeydown='scrobbleIfEnter(event)' />
</td>
</tr>
</table>
<br/>
<span class="button" onclick="scrobbleNew(event)">Scrobble!</span>
<br/>
<h1>Search</h1>
<input class="simpleinput" placeholder='Search for a track' oninput='search_manualscrobbling(this)' />
<br/><br/>
<table id="searchresults"></table>
</body>
</html>

View File

@ -1,119 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Maloja</title>
<script src="/cookies.js"></script>
<pyhp include="common/header.html" />
<script>
function versioncompare(a,b) {
for (var pos=0;pos<3;pos++) {
var v1 = parseInt(a[pos]) || 0;
var v2 = parseInt(b[pos]) || 0;
if (v1 > v2) { return 1;}
if (v1 < v2) { return -1;}
}
return 0;
}
neo.xhttprequest("https://pypi.org/pypi/malojaserver/json",{},"GET",json=true).then((response)=>{
result = JSON.parse(response.responseText);
latestvers = result.info.version.split(".");
neo.xhttprequest("/api/serverinfo",{},"GET",json=true).then((response)=>{
result = JSON.parse(response.responseText);
thisvers = result.version;
document.getElementById("latestversion").innerHTML = latestvers.join(".");
document.getElementById("currentversion").innerHTML = thisvers.join(".");
if (versioncompare(latestvers,thisvers) <= 0) {
document.getElementById("currentversion").style.color = "green";
}
else {
document.getElementById("currentversion").style.color = "red";
document.getElementById("updatestatus").innerHTML = "Consider updating to take advantage of new features";
}
});
});
function activate() {
neo.setCookie("adminmode","true");
window.location.reload(true);
}
function deactivate() {
neo.setCookie("adminmode","false");
window.location.reload(true);
}
function buttonlock() {
button = document.getElementById("adminmodebutton")
if (apikeycorrect) {
button.classList.remove("locked");
if (button.innerHTML == "Activate") { button.onclick = activate; }
else { button.onclick = deactivate; }
// ugh
}
else {
button.classList.add("locked");
button.onclick = null;
}
}
window.addEventListener("load",function(){checkAPIkey(buttonlock)});
// we do this twice, but this one ensures that the button is correctly locked / unlocked after the api key has been checked
</script>
</head>
<body>
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Admin Panel</h1><br/>
<br/><br/>
API Key: <input id='apikey' onchange='checkAPIkey(buttonlock);' style='width:300px;'/><br/><br/>
</td>
</tr>
</table>
<h2>Update</h2>
Currently installed Maloja version: <span id="currentversion">Loading...</span><br/>
Latest recommended Maloja version: <span id="latestversion">Loading...</span><br/>
<span id="updatestatus"></span>
<h2>Admin Mode</h2>
Admin Mode allows you to manually scrobble from various places on the web interface instead of just the dedicated page.<br/><br/>
<pyhp if="adminmode"><span id="adminmodebutton" class="button locked">Deactivate</span></pyhp>
<pyhp if="not adminmode"><span id="adminmodebutton" class="button locked">Activate</span></pyhp>
<h2>Links</h2>
<a class="textlink" href="/setup">Server Setup</a><br/>
<a class="textlink" href="/manual">Manual Scrobbling</a><br/>
<a class="textlink" href="/issues">Database Maintenance</a>
<h2>External</h2>
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://github.com/krateng/maloja/issues/new">Report Issue</a><br/>
<pyhp include="common/footer.html" />
</body>
</html>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Maloja - Error <pyhp echo="errorcode" /></title>
<pyhp include="../common/header.html" />
</head>
<body>
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Error <pyhp echo="errorcode" /></h1><br/>
<p>That did not work. Don't ask me why.</p>
</td>
</tr>
</table>
</body>
</html>

View File

@ -1,133 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Maloja - Setup</title>
<script src="/cookies.js"></script>
<script>
function replaceurls() {
url = window.location.origin
s = document.getElementsByName("serverurl")
for (var i=0;i<s.length;i++) {
s[i].innerHTML = url
}
}
function replace() {
replaceurls();
}
function activateRuleModule(e,filename) {
if (apikeycorrect) {
keys = "filename=" + encodeURIComponent(filename)
apikey = document.getElementById("apikey").value
keys += "&key=" + encodeURIComponent(apikey)
console.log(keys)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Add","Remove")
e.getAttributeNode("onclick").value = e.getAttribute("onclick").replace("activate","deactivate")
/* Nobody ever look at this code please */
}
}
function deactivateRuleModule(e,filename) {
if (apikeycorrect) {
keys = "remove&filename=" + encodeURIComponent(filename)
apikey = document.getElementById("apikey").value
keys += "&key=" + encodeURIComponent(apikey)
var xhttp = new XMLHttpRequest();
xhttp.open("POST","/api/importrules", true);
xhttp.send(keys);
e.innerHTML = e.innerHTML.replace("Remove","Add")
e.getAttributeNode("onclick").value = e.getAttribute("onclick").replace("deactivate","activate")
}
}
</script>
</head>
<body onload="replace()">
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('/favicon.png')"></div>
</td>
<td class="text">
<h1>Maloja</h1><br/>
<p class="desc">Welcome to your own Maloja server!</p>
</td>
</tr>
</table>
<h2>Start Scrobbling</h2>
If you use Vivaldi, Brave, Iridium or any other Chromium-based browser and listen to music on Plex or YouTube Music, download the extension and simply enter the server URL as well as your API key in the relevant fields. They will turn green if the server is accessible.
<br/><br/>
You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter <span class="stats"><span name="serverurl">yourserver.tld</span>/api/s/audioscrobbler</span> as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use <span class="stats"><span name="serverurl">yourserver.tld</span>/api/s/listenbrainz</span> as the API URL and your API key as token.
<br/><br/>
If you use another browser or another music player, you could try to code your own extension. The API is super simple! Just send a POST HTTP request to
<span class="stats"><span name="serverurl">yourserver.tld</span>/api/newscrobble</span>
(make sure to use the public URL) with the key-value-pairs
<br/>
<br/>
<table class="misc">
<tr> <td>artist</td> <td><i>Artist String</i></td> </tr>
<tr> <td>title</td> <td><i>Title String</i></td> </tr>
<tr> <td>key</td> <td><i>API Key</i></td> </tr>
<tr> <td>seconds</td> <td><i>Duration of Scrobble - optional and currently not used</i></td> </tr>
</table>
<br/><br/>
Finally, you could always <a class="textlink" href="/manual">manually scrobble</a>!
<br/><br/>
<h2>Import your Last.FM data</h2>
Switching from Last.fm? <a class="textlink" href="https://benjaminbenben.com/lastfm-to-csv/">Download all your data</a> and run the command <span class="stats">maloja import <i>(the file you just downloaded)</i></span>.
<br/><br/>
<h2>Set up some rules</h2>
After you've scrobbled for a bit, you might want to check the <a class="textlink" href="/issues">Issues page</a> to see if you need to set up some rules. You can also manually add rules in your server's "rules" directory - just add your own .tsv file and read the instructions on how to declare a rule.
<br/><br/>
You can also set up some predefined rulesets right away! Enter your API key and click the buttons.
<br/>
API Key:
<input id='apikey' onchange='checkAPIkey()' style='width:300px;'/>
<br/><br/>
KEY_PREDEFINED_RULESETS
<br/><br/>
<h2>Say thanks</h2>
Coding open source projects is fun, but not really monetizable. If you like Maloja, I would appreciate a small donation via
<a class="textlink" target="_blank" rel="noopener noreferrer" href="https://paypal.me/krateng">PayPal</a>, <a class="textlink" href="bitcoin:1krat8JMniJBTiHftMfR1LtF3Y1w5DAxx">Bitcoin</a> or <a class="textlink" href="https://flattr.com/@Krateng">Flattr</a>.
<br/><br/>
<h2>View your stats</h2>
Done! Visit <a class="textlink" href="/"><span name="serverurl">yourserver.tld</span></a> (or your public / proxy URL) to look at your overview page. Almost everything is clickable!
</body>
</html>

View File

@ -1,57 +0,0 @@
import os
from ..globalconf import datadir
def instructions(keys):
html = "<table class='misc'>"
html += "<tr><th></th><th>Module</th><th>Author</th><th>Description</th></tr>"
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for f in os.listdir(datadir("rules/predefined")):
if f.endswith(".tsv"):
rawf = f.replace(".tsv","")
valid = True
for char in rawf:
if char not in validchars:
valid = False
break # don't even show up invalid filenames
if not valid: continue
if not "_" in rawf: continue
try:
with open(datadir("rules/predefined",f)) as tsvfile:
line1 = tsvfile.readline()
line2 = tsvfile.readline()
if "# NAME: " in line1:
name = line1.replace("# NAME: ","")
else: name = rawf.split("_")[1]
if "# DESC: " in line2:
desc = line2.replace("# DESC: ","")
else: desc = ""
author = rawf.split("_")[0]
except:
continue
html += "<tr>"
if os.path.exists(datadir("rules",f)):
html += "<td class='interaction' onclick=deactivateRuleModule(this,'" + rawf + "')><a class='textlink'>Remove:</a></td>"
else:
html += "<td class='interaction' onclick=activateRuleModule(this,'" + rawf + "')><a class='textlink'>Add:</a></td>"
html += "<td>" + name + "</td>"
html += "<td>" + author + "</td>"
html += "<td>" + desc + "</td>"
html += "</tr>"
html += "</table>"
pushresources = []
replace = {"KEY_PREDEFINED_RULESETS":html}
return (replace,pushresources)