Merge remote-tracking branch 'origin/master'

This commit is contained in:
krateng 2023-10-29 19:13:54 +01:00
commit f23dde2057
119 changed files with 4823 additions and 1275 deletions

2
.github/FUNDING.yml vendored
View File

@ -1 +1 @@
custom: ["https://flattr.com/@Krateng", "https://paypal.me/krateng"]
custom: ["https://paypal.me/krateng"]

View File

@ -12,6 +12,7 @@ COPY --chown=abc:abc ./requirements.txt ./requirements.txt
# it may be possible to decrease image size slightly by using build stage and
# copying all site-packages to runtime stage but the image is already pretty small
RUN \
echo "" && \
echo "**** install build packages ****" && \
apk add --no-cache --virtual=build-deps \
gcc \
@ -23,18 +24,22 @@ RUN \
libc-dev \
py3-pip \
linux-headers && \
echo "" && \
echo "**** install runtime packages ****" && \
apk add --no-cache \
python3 \
py3-lxml \
tzdata && \
echo "" && \
echo "**** install pip dependencies ****" && \
python3 -m ensurepip && \
pip3 install -U --no-cache-dir \
pip \
wheel && \
echo "" && \
echo "**** install maloja requirements ****" && \
pip3 install --no-cache-dir -r requirements.txt && \
echo "" && \
echo "**** cleanup ****" && \
apk del --purge \
build-deps && \
@ -47,6 +52,7 @@ RUN \
COPY --chown=abc:abc . .
RUN \
echo "" && \
echo "**** install maloja ****" && \
apk add --no-cache --virtual=install-deps \
py3-pip && \

103
README.md
View File

@ -4,10 +4,7 @@
[![](https://img.shields.io/pypi/v/malojaserver?label=PyPI&style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/malojaserver/)
[![](https://img.shields.io/docker/v/krateng/maloja?label=Dockerhub&style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/krateng/maloja)
[![](https://img.shields.io/pypi/l/malojaserver?style=for-the-badge)](https://github.com/krateng/maloja/blob/master/LICENSE)
[![](https://img.shields.io/codeclimate/maintainability/krateng/maloja?style=for-the-badge)](https://codeclimate.com/github/krateng/maloja)
Simple self-hosted music scrobble database to create personal listening statistics. No recommendations, no social network, no nonsense.
Simple self-hosted music scrobble database to create personal listening statistics.
![screenshot](https://raw.githubusercontent.com/krateng/maloja/master/screenshot.png)
@ -18,9 +15,9 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst
* [Features](#features)
* [How to install](#how-to-install)
* [Requirements](#requirements)
* [Docker / Podman](#docker--podman)
* [PyPI](#pypi)
* [From Source](#from-source)
* [Docker / Podman](#docker--podman)
* [Extras](#extras)
* [How to use](#how-to-use)
* [Basic control](#basic-control)
@ -47,10 +44,46 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst
Maloja should run on any x86 or ARM machine that runs Python.
I can support you with issues best if you use **Alpine Linux**.
It is highly recommended to use **Docker** or **Podman**.
Your CPU should have a single core passmark score of at the very least 1500. 500 MB RAM should give you a decent experience, but performance will benefit greatly from up to 2 GB.
### Docker / Podman
Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile.
Of note are these settings which should be passed as environmental variables to the container:
* `MALOJA_SKIP_SETUP` -- Make the server setup process non-interactive. Maloja will not work properly in a container without this variable set. This is done by default in the provided Containerfile.
* `MALOJA_FORCE_PASSWORD` -- Set an admin password for Maloja. You only need this on the first run.
* `MALOJA_DATA_DIRECTORY` -- Set the directory in the container where configuration folders/files should be located
* Mount a [volume](https://docs.docker.com/engine/reference/builder/#volume) to the specified directory to access these files outside the container (and to make them persistent)
You must publish a port on your host machine to bind to the container's web port (default 42010). The container uses IPv4 per default.
An example of a minimum run configuration to access maloja via `localhost:42010`:
```console
docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
#### Linux Host
**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you.
If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID (EX `1000`)
* `id -g` -- prints GID (EX `1001`)
The modified run command with these variables would look like:
```console
docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
### PyPI
You can install Maloja with
@ -78,41 +111,6 @@ Then install all the requirements and build the package, e.g.:
pip install .
```
### Docker / Podman
Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile.
Of note are these settings which should be passed as environmental variables to the container:
* `MALOJA_SKIP_SETUP` -- Make the server setup process non-interactive. Maloja will not work properly in a container without this variable set. This is done by default in the provided Containerfile.
* `MALOJA_FORCE_PASSWORD` -- Set an admin password for Maloja. You only need this on the first run.
* `MALOJA_DATA_DIRECTORY` -- Set the directory in the container where configuration folders/files should be located
* Mount a [volume](https://docs.docker.com/engine/reference/builder/#volume) to the specified directory to access these files outside the container (and to make them persistent)
You must publish a port on your host machine to bind to the container's web port (default 42010). The container uses IPv4 per default.
An example of a minimum run configuration to access maloja via `localhost:42010`:
```console
docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
#### Linux Host
**NOTE:** If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) this DOES NOT apply to you.
If you are running Docker on a **Linux Host** you should specify `user:group` ids of the user who owns the folder on the host machine bound to `MALOJA_DATA_DIRECTORY` in order to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
To get the UID and GID for the current user run these commands from a terminal:
* `id -u` -- prints UID (EX `1000`)
* `id -g` -- prints GID (EX `1001`)
The modified run command with these variables would look like:
```console
docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
```
### Extras
@ -120,31 +118,13 @@ The modified run command with these variables would look like:
* Put your server behind a reverse proxy for SSL encryption. Make sure that you're proxying to the IPv6 or IPv4 address according to your settings.
* You can set up a cronjob to start your server on system boot, and potentially restart it on a regular basis:
```
@reboot sleep 15 && maloja start
42 0 7 * * maloja restart
```
## How to use
### Basic control
Start and stop the server in the background with
```console
maloja start
maloja stop
maloja restart
```
If you need to run the server in the foreground, use
```console
maloja run
```
When not running in a container, you can run the application with `maloja run`. You can also run it in the background with
`maloja start` and `maloja stop`, but this might not be supported in the future.
### Data
@ -164,7 +144,6 @@ If you would like to import your previous scrobbles, use the command `maloja imp
maloja import my_last_fm_export.csv
```
---
To backup your data, run `maloja backup`, optional with `--include_images`.

View File

@ -41,6 +41,3 @@ minor_release_name: "Soyeon"
- "[Bugfix] Fixed image display on Safari"
- "[Bugfix] Fixed entity editing on Firefox"
- "[Bugfix] Made compatibile with SQLAlchemy 2.0"
upcoming:
notes:
- "[Bugfix] Fixed configuration of time format"

19
dev/releases/3.2.yml Normal file
View File

@ -0,0 +1,19 @@
minor_release_name: "Nicole"
3.2.0:
notes:
- "[Architecture] Switched to linuxserver.io container base image"
- "[Architecture] Reworked image handling"
- "[Architecture] Removed pre-calculated stats"
- "[Feature] Added support for albums"
- "[Feature] New start page"
- "[Feature] Added UI for track-artist, track-album and album-artist association"
- "[Feature] Added inline UI for association and merging in chart lists"
- "[Feature] Added UI selector for including associated artists"
- "[Feature] Added UI distinction for associated scrobbles in chart bars"
- "[Performance] Improved image rendering"
- "[Performance] Optimized several database calls"
- "[Bugfix] Fixed configuration of time format"
- "[Bugfix] Fixed search on manual scrobble page"
- "[Bugfix] Disabled DB maintenance while not running main server"
- "[Bugfix] Removed some nonsensical ephemereal database entry creations"
- "[Bugfix] Fixed API endpoint for track charts with no artist provided"

View File

@ -189,7 +189,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\"\n}"
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artist\": \"{{data.artist1}}\",\n \"title\": \"{{data.title1}}\",\n \"album\": \"{{data.album}}\",\n \"albumartists\":[\n \"{{data.artist1}}\",\n \"{{data.artist3}}\"\n ]\n}"
},
"url": {
"raw": "{{url}}/apis/mlj_1/newscrobble",
@ -219,7 +219,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artists\": [\"{{data.artist1}}\",\"{{data.artist2}}\"],\n \"title\": \"{{data.title1}}\"\n}"
"raw": "{\n \"key\": \"{{api_key}}\",\n \"artists\": [\"{{data.artist1}}\",\"{{data.artist2}}\"],\n \"title\": \"{{data.title1}}\",\n \"album\": \"{{data.album}}\",\n \"albumartists\":[\n \"{{data.artist1}}\",\n \"{{data.artist3}}\"\n ]\n}"
},
"url": {
"raw": "{{url}}/apis/mlj_1/newscrobble",
@ -867,6 +867,11 @@
"key": "data.title3",
"value": "One in a Million"
},
{
"key": "data.album",
"value": "The Epic Collection",
"type": "default"
},
{
"key": "data.timestamp1",
"value": ""

View File

@ -0,0 +1,15 @@
#!/usr/bin/env sh
pacman -Syu
pacman -S --needed \
gcc \
python3 \
libxml2 \
libxslt \
libffi \
glibc \
python-pip \
linux-headers \
python \
python-lxml \
tzdata \
libvips

View File

@ -30,13 +30,13 @@ def print_header_info():
def get_instance():
try:
return int(subprocess.check_output(["pidof","maloja"]))
return int(subprocess.check_output(["pgrep","-x","maloja"]))
except Exception:
return None
def get_instance_supervisor():
try:
return int(subprocess.check_output(["pidof","maloja_supervisor"]))
return int(subprocess.check_output(["pgrep","-x","maloja_supervisor"]))
except Exception:
return None
@ -130,7 +130,7 @@ def run_supervisor():
def debug():
os.environ["MALOJA_DEV_MODE"] = 'true'
conf.malojaconfig.load_environment()
direct()
run_server()
def print_info():
print_header_info()
@ -141,14 +141,15 @@ def print_info():
print(col['lightblue']("Timezone: "),f"UTC{conf.malojaconfig['timezone']:+d}")
print()
try:
import pkg_resources
from importlib.metadata import distribution
for pkg in ("sqlalchemy","waitress","bottle","doreah","jinja2"):
print(col['cyan'] (f"{pkg}:".ljust(13)),pkg_resources.get_distribution(pkg).version)
print(col['cyan'] (f"{pkg}:".ljust(13)),distribution(pkg).version)
except ImportError:
raise
print("Could not determine dependency versions.")
print()
@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images'],shield=True)
@mainfunction({"l":"level","v":"version","V":"version"},flags=['version','include_images','prefer_existing'],shield=True)
def main(*args,**kwargs):
actions = {
@ -166,12 +167,13 @@ def main(*args,**kwargs):
"generate":generate.generate_scrobbles, # maloja generate 400
"export":tasks.export, # maloja export
"apidebug":apidebug.run, # maloja apidebug
"parsealbums":tasks.parse_albums, # maloja parsealbums --strategy majority
# aux
"info":print_info
}
if "version" in kwargs:
print(info.VERSION)
print(pkginfo.VERSION)
return True
else:
try:

View File

@ -75,7 +75,7 @@ class Listenbrainz(APIHandler):
self.scrobble({
'track_artists':[artiststr],
'track_title':titlestr,
'album_name':albumstr,
'album_title':albumstr,
'scrobble_time':timestamp,
'track_length': additional.get("duration"),
**extrafields

View File

@ -2,10 +2,12 @@ import os
import math
import traceback
from bottle import response, static_file, request, FormsDict
from bottle import response, static_file, FormsDict
from inspect import signature
from doreah.logging import log
from doreah.auth import authenticated_api, authenticated_api_with_alternate, authenticated_function
from doreah.auth import authenticated_function
# nimrodel API
from nimrodel import EAPI as API
@ -18,7 +20,7 @@ from ..pkg_global.conf import malojaconfig, data_dir
from ..__pkginfo__ import VERSION
from ..malojauri import uri_to_internal, compose_querystring, internal_to_uri
from ..malojauri import uri_to_internal, compose_querystring, internal_to_uri, create_uri
from .. import images
from ._apikeys import apikeystore, api_key_correct
@ -72,6 +74,14 @@ errors = {
'desc':"The database is being upgraded. Please try again later."
}
}),
database.exceptions.EntityDoesNotExist: lambda e: (404,{
"status":"error",
"error":{
'type':'entity_does_not_exist',
'value':e.entitydict,
'desc':"This entity does not exist in the database."
}
}),
images.MalformedB64: lambda e: (400,{
"status":"failure",
"error":{
@ -91,6 +101,8 @@ errors = {
})
}
# decorator to catch exceptions and return proper json responses
def catch_exceptions(func):
def protector(*args,**kwargs):
try:
@ -105,9 +117,11 @@ def catch_exceptions(func):
protector.__doc__ = func.__doc__
protector.__annotations__ = func.__annotations__
protector.__name__ = f"EXCPR_{func.__name__}"
return protector
# decorator to expand the docstring with common arguments for the API explorer. DOESNT WRAP
def add_common_args_to_docstring(filterkeys=False,limitkeys=False,delimitkeys=False,amountkeys=False):
def decorator(func):
timeformats = "Possible formats include '2022', '2022/08', '2022/08/01', '2022/W42', 'today', 'thismonth', 'monday', 'august'"
@ -141,6 +155,64 @@ def add_common_args_to_docstring(filterkeys=False,limitkeys=False,delimitkeys=Fa
return decorator
# decorator to take the URI keys and convert them into internal keys
def convert_kwargs(func):
#params = tuple(p for p in signature(func).parameters)
def wrapper(*args,albumartist:Multi[str]=[],trackartist:Multi[str]=[],**kwargs):
kwargs = FormsDict(kwargs)
for a in albumartist:
kwargs.append("albumartist",a)
for a in trackartist:
kwargs.append("trackartist",a)
k_filter, k_limit, k_delimit, k_amount, k_special = uri_to_internal(kwargs,api=True)
try:
return func(*args,k_filter=k_filter, k_limit=k_limit, k_delimit=k_delimit, k_amount=k_amount)
except TypeError:
return func(*args,k_filter=k_filter, k_limit=k_limit, k_delimit=k_delimit, k_amount=k_amount,k_special=k_special)
# TODO: ....really?
wrapper.__doc__ = func.__doc__
wrapper.__name__ = f"CVKWA_{func.__name__}"
return wrapper
# decorator to add pagination info to endpoints (like links to other pages)
# this expects already converted uri args!!!
def add_pagination(endpoint,filterkeys=False,limitkeys=False,delimitkeys=False):
def decorator(func):
def wrapper(*args,k_filter, k_limit, k_delimit, k_amount):
keydicts = []
if filterkeys: keydicts.append(k_filter)
if limitkeys: keydicts.append(k_limit)
if delimitkeys: keydicts.append(k_delimit)
keydicts.append(k_amount)
result = func(*args,k_filter=k_filter, k_limit=k_limit, k_delimit=k_delimit, k_amount=k_amount)
result['pagination'] = {
'page': k_amount['page'],
'perpage': k_amount['perpage'] if (k_amount['perpage'] is not math.inf) else None,
'next_page': create_uri(api.pathprefix + '/' + endpoint,*keydicts,{'page':k_amount['page']+1}) if len(result.get('list',[]))==k_amount['perpage'] else None,
'prev_page': create_uri(api.pathprefix + '/' + endpoint,*keydicts,{'page':k_amount['page']-1}) if k_amount['page'] > 0 else None
}
return result
wrapper.__doc__ = func.__doc__
wrapper.__annotations__ = func.__annotations__
wrapper.__name__ = f"PGNAT_{func.__name__}"
return wrapper
return decorator
@api.get("test")
@catch_exceptions
@ -194,20 +266,22 @@ def server_info():
@api.get("scrobbles")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,amountkeys=True)
def get_scrobbles_external(**keys):
@convert_kwargs
@add_pagination("scrobbles",filterkeys=True,limitkeys=True)
def get_scrobbles_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns a list of scrobbles.
:return: list (List)
:rtype: Dictionary
"""
k_filter, k_time, _, k_amount, _ = uri_to_internal(keys,api=True)
ckeys = {**k_filter, **k_time, **k_amount}
ckeys = {**k_filter, **k_limit, **k_amount}
result = database.get_scrobbles(**ckeys)
offset = (k_amount.get('page') * k_amount.get('perpage')) if k_amount.get('perpage') is not math.inf else 0
result = result[offset:]
if k_amount.get('perpage') is not math.inf: result = result[:k_amount.get('perpage')]
# this should now all be served by the inner function
#offset = (k_amount.get('page') * k_amount.get('perpage')) if k_amount.get('perpage') is not math.inf else 0
#result = result[offset:]
#if k_amount.get('perpage') is not math.inf: result = result[:k_amount.get('perpage')]
return {
"status":"ok",
@ -218,15 +292,15 @@ 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):
@convert_kwargs
def get_scrobbles_num_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns amount of scrobbles.
:return: amount (Integer)
:rtype: Dictionary
"""
k_filter, k_time, _, k_amount, _ = uri_to_internal(keys)
ckeys = {**k_filter, **k_time, **k_amount}
ckeys = {**k_filter, **k_limit, **k_amount}
result = database.get_scrobbles_num(**ckeys)
return {
@ -235,19 +309,18 @@ 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).
@convert_kwargs
def get_tracks_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns all tracks (optionally of an artist or on an album).
:return: list (List)
:rtype: Dictionary
"""
k_filter, _, _, _, _ = uri_to_internal(keys,forceArtist=True)
ckeys = {**k_filter}
ckeys = {**k_filter}
result = database.get_tracks(**ckeys)
return {
@ -256,15 +329,16 @@ def get_tracks_external(**keys):
}
@api.get("artists")
@catch_exceptions
@add_common_args_to_docstring()
def get_artists_external():
@convert_kwargs
def get_artists_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns all artists.
:return: list (List)
:rtype: Dictionary"""
result = database.get_artists()
return {
@ -273,20 +347,36 @@ def get_artists_external():
}
@api.get("albums")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
@convert_kwargs
def get_albums_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns all albums (optionally of an artist).
:return: list (List)
:rtype: Dictionary"""
ckeys = {**k_filter}
result = database.get_albums(**ckeys)
return {
"status":"ok",
"list":result
}
@api.get("charts/artists")
@catch_exceptions
@add_common_args_to_docstring(limitkeys=True)
def get_charts_artists_external(**keys):
@convert_kwargs
def get_charts_artists_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns artist charts
:return: list (List)
:rtype: Dictionary"""
_, k_time, _, _, _ = uri_to_internal(keys)
ckeys = {**k_time}
ckeys = {**k_limit}
result = database.get_charts_artists(**ckeys)
return {
@ -295,18 +385,17 @@ 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):
@convert_kwargs
def get_charts_tracks_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns track charts
:return: list (List)
:rtype: Dictionary"""
k_filter, k_time, _, _, _ = uri_to_internal(keys,forceArtist=True)
ckeys = {**k_filter, **k_time}
ckeys = {**k_filter, **k_limit}
result = database.get_charts_tracks(**ckeys)
return {
@ -315,19 +404,36 @@ def get_charts_tracks_external(**keys):
}
@api.get("charts/albums")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True)
@convert_kwargs
def get_charts_albums_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns album charts
:return: list (List)
:rtype: Dictionary"""
ckeys = {**k_filter, **k_limit}
result = database.get_charts_albums(**ckeys)
return {
"status":"ok",
"list":result
}
@api.get("pulse")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True,limitkeys=True,delimitkeys=True,amountkeys=True)
def get_pulse_external(**keys):
@convert_kwargs
def get_pulse_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns amounts of scrobbles in specified time frames
:return: list (List)
:rtype: Dictionary"""
k_filter, k_time, k_internal, k_amount, _ = uri_to_internal(keys)
ckeys = {**k_filter, **k_time, **k_internal, **k_amount}
ckeys = {**k_filter, **k_limit, **k_delimit, **k_amount}
results = database.get_pulse(**ckeys)
return {
@ -336,19 +442,17 @@ 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):
@convert_kwargs
def get_performance_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns artist's or track's rank in specified time frames
:return: list (List)
:rtype: Dictionary"""
k_filter, k_time, k_internal, k_amount, _ = uri_to_internal(keys)
ckeys = {**k_filter, **k_time, **k_internal, **k_amount}
ckeys = {**k_filter, **k_limit, **k_delimit, **k_amount}
results = database.get_performance(**ckeys)
return {
@ -362,14 +466,14 @@ 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):
@convert_kwargs
def get_top_artists_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns respective number 1 artists in specified time frames
:return: list (List)
:rtype: Dictionary"""
_, k_time, k_internal, _, _ = uri_to_internal(keys)
ckeys = {**k_time, **k_internal}
ckeys = {**k_limit, **k_delimit}
results = database.get_top_artists(**ckeys)
return {
@ -378,22 +482,19 @@ 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):
@convert_kwargs
def get_top_tracks_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns respective number 1 tracks in specified time frames
:return: list (List)
:rtype: Dictionary"""
_, k_time, k_internal, _, _ = uri_to_internal(keys)
ckeys = {**k_time, **k_internal}
# IMPLEMENT THIS FOR TOP TRACKS OF ARTIST AS WELL?
ckeys = {**k_limit, **k_delimit}
results = database.get_top_tracks(**ckeys)
# IMPLEMENT THIS FOR TOP TRACKS OF ARTIST/ALBUM AS WELL?
return {
"status":"ok",
@ -401,17 +502,36 @@ def get_top_tracks_external(**keys):
}
@api.get("top/albums")
@catch_exceptions
@add_common_args_to_docstring(limitkeys=True,delimitkeys=True)
@convert_kwargs
def get_top_albums_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns respective number 1 albums in specified time frames
:return: list (List)
:rtype: Dictionary"""
ckeys = {**k_limit, **k_delimit}
results = database.get_top_albums(**ckeys)
# IMPLEMENT THIS FOR TOP ALBUMS OF ARTIST AS WELL?
return {
"status":"ok",
"list":results
}
@api.get("artistinfo")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
def artist_info_external(**keys):
@convert_kwargs
def artist_info_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns information about an artist
:return: artist (String), scrobbles (Integer), position (Integer), associated (List), medals (Mapping), topweeks (Integer)
:rtype: Dictionary"""
k_filter, _, _, _, _ = uri_to_internal(keys,forceArtist=True)
ckeys = {**k_filter}
return database.artist_info(**ckeys)
@ -421,21 +541,31 @@ 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):
@convert_kwargs
def track_info_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns information about a track
:return: track (Mapping), scrobbles (Integer), position (Integer), medals (Mapping), certification (String), topweeks (Integer)
:rtype: Dictionary"""
# transform into a multidict so we can use our nomral uri_to_internal function
keys = FormsDict(keys)
for a in artist:
keys.append("artist",a)
k_filter, _, _, _, _ = uri_to_internal(keys,forceTrack=True)
ckeys = {**k_filter}
ckeys = {**k_filter}
return database.track_info(**ckeys)
@api.get("albuminfo")
@catch_exceptions
@add_common_args_to_docstring(filterkeys=True)
@convert_kwargs
def album_info_external(k_filter, k_limit, k_delimit, k_amount):
"""Returns information about an album
:return: album (Mapping), scrobbles (Integer), position (Integer), medals (Mapping), certification (String), topweeks (Integer)
:rtype: Dictionary"""
ckeys = {**k_filter}
return database.album_info(**ckeys)
@api.post("newscrobble")
@authenticated_function(alternate=api_key_correct,api=True,pass_auth_result_as='auth_result')
@catch_exceptions
@ -444,7 +574,7 @@ def post_scrobble(
artists:list=[],
title:str="",
album:str=None,
albumartists:list=[],
albumartists:list=None,
duration:int=None,
length:int=None,
time:int=None,
@ -470,7 +600,7 @@ def post_scrobble(
rawscrobble = {
'track_artists':(artist or []) + artists,
'track_title':title,
'album_name':album,
'album_title':album,
'album_artists':albumartists,
'scrobble_duration':duration,
'track_length':length,
@ -478,7 +608,7 @@ def post_scrobble(
}
# for logging purposes, don't pass values that we didn't actually supply
rawscrobble = {k:rawscrobble[k] for k in rawscrobble if rawscrobble[k]}
rawscrobble = {k:rawscrobble[k] for k in rawscrobble if rawscrobble[k] is not None} # [] should be passed
result = database.incoming_scrobble(
@ -494,19 +624,23 @@ def post_scrobble(
'artists':result['track']['artists'],
'title':result['track']['title']
},
'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}"
'desc':f"Scrobbled {result['track']['title']} by {', '.join(result['track']['artists'])}",
'warnings':[]
}
if extra_kwargs:
responsedict['warnings'] = [
responsedict['warnings'] += [
{'type':'invalid_keyword_ignored','value':k,
'desc':"This key was not recognized by the server and has been discarded."}
for k in extra_kwargs
]
if artist and artists:
responsedict['warnings'] = [
responsedict['warnings'] += [
{'type':'mixed_schema','value':['artist','artists'],
'desc':"These two fields are meant as alternative methods to submit information. Use of both is discouraged, but works at the moment."}
]
if len(responsedict['warnings']) == 0: del responsedict['warnings']
return responsedict
@ -515,21 +649,18 @@ def post_scrobble(
@api.post("addpicture")
@authenticated_function(alternate=api_key_correct,api=True)
@catch_exceptions
def add_picture(b64,artist:Multi=[],title=None):
"""Uploads a new image for an artist or track.
@convert_kwargs
def add_picture(k_filter, k_limit, k_delimit, k_amount, k_special):
"""Uploads a new image for an artist, album or track.
param string b64: Base 64 representation of the image
param string artist: Artist name. Can be supplied multiple times for tracks with multiple artists.
param string title: Title of the track. Optional.
"""
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 "associated" in k_filter: del k_filter["associated"]
if "track" in k_filter: k_filter = k_filter["track"]
url = images.set_image(b64,**k_filter)
elif "album" in k_filter: k_filter = k_filter["album"]
url = images.set_image(k_special['b64'],**k_filter)
return {
'status': 'success',
@ -586,6 +717,7 @@ def search(**keys):
artists = database.db_search(query,type="ARTIST")
tracks = database.db_search(query,type="TRACK")
albums = database.db_search(query,type="ALBUM")
@ -593,6 +725,7 @@ def search(**keys):
# also, shorter is better (because longer titles would be easier to further specify)
artists.sort(key=lambda x: ((0 if x.lower().startswith(query) else 1 if " " + query in x.lower() else 2),len(x)))
tracks.sort(key=lambda x: ((0 if x["title"].lower().startswith(query) else 1 if " " + query in x["title"].lower() else 2),len(x["title"])))
albums.sort(key=lambda x: ((0 if x["albumtitle"].lower().startswith(query) else 1 if " " + query in x["albumtitle"].lower() else 2),len(x["albumtitle"])))
# add links
artists_result = []
@ -613,7 +746,22 @@ def search(**keys):
}
tracks_result.append(result)
return {"artists":artists_result[:max_],"tracks":tracks_result[:max_]}
albums_result = []
for al in albums:
result = {
'album': al,
'link': "/album?" + compose_querystring(internal_to_uri({"album":al})),
'image': images.get_album_image(al)
}
mutable_result = result.copy()
mutable_result['album'] = result['album'].copy()
if not mutable_result['album']['artists']: mutable_result['album']['displayArtist'] = malojaconfig["DEFAULT_ALBUM_ARTIST"]
# we don't wanna actually mutate the dict here because this is in the cache
# TODO: This should be globally solved!!!!! immutable dicts with mutable overlays???
# this is a major flaw in the architecture!
albums_result.append(mutable_result)
return {"artists":artists_result[:max_],"tracks":tracks_result[:max_],"albums":albums_result[:max_]}
@api.post("newrule")
@ -708,6 +856,16 @@ def edit_track(id,title):
"status":"success"
}
@api.post("edit_album")
@authenticated_function(api=True)
@catch_exceptions
def edit_album(id,albumtitle):
"""Internal Use Only"""
result = database.edit_album(id,{'albumtitle':albumtitle})
return {
"status":"success"
}
@api.post("merge_tracks")
@authenticated_function(api=True)
@ -716,7 +874,8 @@ def merge_tracks(target_id,source_ids):
"""Internal Use Only"""
result = database.merge_tracks(target_id,source_ids)
return {
"status":"success"
"status":"success",
"desc":f"{', '.join(src['title'] for src in result['sources'])} were merged into {result['target']['title']}"
}
@api.post("merge_artists")
@ -726,9 +885,57 @@ def merge_artists(target_id,source_ids):
"""Internal Use Only"""
result = database.merge_artists(target_id,source_ids)
return {
"status":"success"
"status":"success",
"desc":f"{', '.join(src for src in result['sources'])} were merged into {result['target']}"
}
@api.post("merge_albums")
@authenticated_function(api=True)
@catch_exceptions
def merge_artists(target_id,source_ids):
"""Internal Use Only"""
result = database.merge_albums(target_id,source_ids)
return {
"status":"success",
"desc":f"{', '.join(src['albumtitle'] for src in result['sources'])} were merged into {result['target']['albumtitle']}"
}
@api.post("associate_albums_to_artist")
@authenticated_function(api=True)
@catch_exceptions
def associate_albums_to_artist(target_id,source_ids,remove=False):
result = database.associate_albums_to_artist(target_id,source_ids,remove=remove)
descword = "removed" if remove else "added"
if result:
return {
"status":"success",
"desc":f"{result['target']} was {descword} as album artist of {', '.join(src['albumtitle'] for src in result['sources'])}"
}
@api.post("associate_tracks_to_artist")
@authenticated_function(api=True)
@catch_exceptions
def associate_tracks_to_artist(target_id,source_ids,remove=False):
result = database.associate_tracks_to_artist(target_id,source_ids,remove=remove)
descword = "removed" if remove else "added"
if result:
return {
"status":"success",
"desc":f"{result['target']} was {descword} as artist for {', '.join(src['title'] for src in result['sources'])}"
}
@api.post("associate_tracks_to_album")
@authenticated_function(api=True)
@catch_exceptions
def associate_tracks_to_album(target_id,source_ids):
result = database.associate_tracks_to_album(target_id,source_ids)
if result:
return {
"status":"success",
"desc":f"{', '.join(src['title'] for src in result['sources'])} were " + f"added to {result['target']['albumtitle']}" if target_id else "removed from their album"
}
@api.post("reparse_scrobble")
@authenticated_function(api=True)
@catch_exceptions

View File

@ -26,6 +26,7 @@ class CleanerAgent:
self.rules_belongtogether = [r[1] for r in rawrules if r[0]=="belongtogether"]
self.rules_notanartist = [r[1] for r in rawrules if r[0]=="notanartist"]
self.rules_replacetitle = {r[1].lower():r[2] for r in rawrules if r[0]=="replacetitle"}
self.rules_replacealbumtitle = {r[1].lower():r[2] for r in rawrules if r[0]=="replacealbumtitle"}
self.rules_replaceartist = {r[1].lower():r[2] for r in rawrules if r[0]=="replaceartist"}
self.rules_ignoreartist = [r[1].lower() for r in rawrules if r[0]=="ignoreartist"]
self.rules_addartists = {r[2].lower():(r[1].lower(),r[3]) for r in rawrules if r[0]=="addartists"}
@ -188,9 +189,14 @@ class CleanerAgent:
if st in title.lower(): artists += self.rules_artistintitle[st].split("")
return (title,artists)
def parseAlbumtitle(self,t):
if t.strip().lower() in self.rules_replacealbumtitle:
return self.rules_replacealbumtitle[t.strip().lower()]
t = t.replace("[","(").replace("]",")")
t = t.strip()
return t
def flatten(lis):

0
maloja/data_files/cache/images/dummy vendored Normal file
View File

View File

@ -98,6 +98,7 @@ countas Jeongyeon TWICE
countas Chaeyoung TWICE
countas Nayeon TWICE
countas Sana TWICE
countas MISAMO TWICE
# AOA
countas AOA Black AOA
@ -182,6 +183,9 @@ countas Akali K/DA
# (G)I-DLE
countas Soyeon (G)I-DLE
countas Miyeon (G)I-DLE
countas Yuqi (G)I-DLE
countas Minnie (G)I-DLE
countas Shuhua (G)I-DLE
replaceartist Jeon Soyeon Soyeon
@ -208,6 +212,12 @@ countas Wonyoung IVE
countas Yujin IVE
countas Gaeul IVE
# Pristin
countas Pristin V Pristin
# CLC
countas Sorn CLC
# Popular Remixes
artistintitle Areia Remix Areia
artistintitle Areia Kpop Areia

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -1,17 +1,27 @@
# server
from bottle import request, response, FormsDict
# decorator that makes sure this function is only run in normal operation,
# not when we run a task that needs to access the database
def no_aux_mode(func):
def wrapper(*args,**kwargs):
from ..pkg_global import conf
if conf.AUX_MODE: return
return func(*args,**kwargs)
return wrapper
# rest of the project
from ..cleanup import CleanerAgent
from .. import images
from ..malojatime import register_scrobbletime, time_stamps, ranges, alltime
from ..malojatime import register_scrobbletime, ranges, alltime, today, thisweek, thisyear, MTRangeComposite
from ..malojauri import uri_to_internal, internal_to_uri, compose_querystring
from ..thirdparty import proxy_scrobble_all
from ..pkg_global.conf import data_dir, malojaconfig
from ..apis import apikeystore
#db
from . import sqldb
from . import cached
from . import dbcache
from . import exceptions
@ -46,10 +56,15 @@ dbstatus = {
def waitfordb(func):
def newfunc(*args,**kwargs):
if not dbstatus['healthy']: raise exceptions.DatabaseNotBuilt()
return func(*args,**kwargs)
newfunc.__name__ = func.__name__
return newfunc
@ -93,12 +108,15 @@ def incoming_scrobble(rawscrobble,fix=True,client=None,api=None,dbconn=None):
log(f"Incoming scrobble [Client: {client} | API: {api}]: {rawscrobble}")
scrobbledict = rawscrobble_to_scrobbledict(rawscrobble, fix, client)
albumupdate = (malojaconfig["ALBUM_INFORMATION_TRUST"] == 'last')
sqldb.add_scrobble(scrobbledict,dbconn=dbconn)
sqldb.add_scrobble(scrobbledict,update_album=albumupdate,dbconn=dbconn)
proxy_scrobble_all(scrobbledict['track']['artists'],scrobbledict['track']['title'],scrobbledict['time'])
dbcache.invalidate_caches(scrobbledict['time'])
#return {"status":"success","scrobble":scrobbledict}
return scrobbledict
@ -130,8 +148,23 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
scrobbleinfo = {**rawscrobble}
if fix:
scrobbleinfo['track_artists'],scrobbleinfo['track_title'] = cla.fullclean(scrobbleinfo['track_artists'],scrobbleinfo['track_title'])
if scrobbleinfo.get('album_artists'):
scrobbleinfo['album_artists'] = cla.parseArtists(scrobbleinfo['album_artists'])
if scrobbleinfo.get("album_title"):
scrobbleinfo['album_title'] = cla.parseAlbumtitle(scrobbleinfo['album_title'])
scrobbleinfo['scrobble_time'] = scrobbleinfo.get('scrobble_time') or int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
# if we send [] as albumartists, it means various
# if we send nothing, the scrobbler just doesnt support it and we assume track artists
if ('album_title' in scrobbleinfo) and ('album_artists' not in scrobbleinfo):
scrobbleinfo['album_artists'] = scrobbleinfo.get('track_artists')
# New plan, do this further down
# NONE always means there is simply no info, so make a guess or whatever the options say
# could use the track artists, but probably check if any album with the same name exists first
# various artists always needs to be specified via []
# TODO
# processed info to internal scrobble dict
scrobbledict = {
"time":scrobbleinfo.get('scrobble_time'),
@ -139,7 +172,7 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
"artists":scrobbleinfo.get('track_artists'),
"title":scrobbleinfo.get('track_title'),
"album":{
"name":scrobbleinfo.get('album_name'),
"albumtitle":scrobbleinfo.get('album_title'),
"artists":scrobbleinfo.get('album_artists')
},
"length":scrobbleinfo.get('track_length')
@ -148,11 +181,15 @@ def rawscrobble_to_scrobbledict(rawscrobble, fix=True, client=None):
"origin":f"client:{client}" if client else "generic",
"extra":{
k:scrobbleinfo[k] for k in scrobbleinfo if k not in
['scrobble_time','track_artists','track_title','track_length','scrobble_duration']#,'album_name','album_artists']
['scrobble_time','track_artists','track_title','track_length','scrobble_duration']#,'album_title','album_artists']
# we still save album info in extra because the user might select majority album authority
},
"rawscrobble":rawscrobble
}
if not scrobbledict["track"]["album"]["albumtitle"]:
del scrobbledict["track"]["album"]
return scrobbledict
@ -184,12 +221,23 @@ def edit_track(id,trackinfo):
return result
@waitfordb
def edit_album(id,albuminfo):
album = sqldb.get_album(id)
log(f"Renaming {album['albumtitle']} to {albuminfo['albumtitle']}")
result = sqldb.edit_album(id,albuminfo)
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def merge_artists(target_id,source_ids):
sources = [sqldb.get_artist(id) for id in source_ids]
target = sqldb.get_artist(target_id)
log(f"Merging {sources} into {target}")
result = sqldb.merge_artists(target_id,source_ids)
sqldb.merge_artists(target_id,source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
@ -200,7 +248,20 @@ 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)
sqldb.merge_tracks(target_id,source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def merge_albums(target_id,source_ids):
sources = [sqldb.get_album(id) for id in source_ids]
target = sqldb.get_album(target_id)
log(f"Merging {sources} into {target}")
sqldb.merge_albums(target_id,source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
@ -208,27 +269,95 @@ def merge_tracks(target_id,source_ids):
@waitfordb
def associate_albums_to_artist(target_id,source_ids,remove=False):
sources = [sqldb.get_album(id) for id in source_ids]
target = sqldb.get_artist(target_id)
if remove:
log(f"Removing {sources} from {target}")
sqldb.remove_artists_from_albums(artist_ids=[target_id],album_ids=source_ids)
else:
log(f"Adding {sources} into {target}")
sqldb.add_artists_to_albums(artist_ids=[target_id],album_ids=source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def associate_tracks_to_artist(target_id,source_ids,remove=False):
sources = [sqldb.get_track(id) for id in source_ids]
target = sqldb.get_artist(target_id)
if remove:
log(f"Removing {sources} from {target}")
sqldb.remove_artists_from_tracks(artist_ids=[target_id],track_ids=source_ids)
else:
log(f"Adding {sources} into {target}")
sqldb.add_artists_to_tracks(artist_ids=[target_id],track_ids=source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def associate_tracks_to_album(target_id,source_ids):
# target_id None means remove from current album!
sources = [sqldb.get_track(id) for id in source_ids]
if target_id:
target = sqldb.get_album(target_id)
log(f"Adding {sources} into {target}")
sqldb.add_tracks_to_albums({src:target_id for src in source_ids})
else:
sqldb.remove_album(source_ids)
result = {'sources':sources,'target':target}
dbcache.invalidate_entity_cache()
dbcache.invalidate_caches()
return result
@waitfordb
def get_scrobbles(dbconn=None,**keys):
(since,to) = keys.get('timerange').timestamps()
if 'artist' in keys:
result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,dbconn=dbconn)
elif 'track' in keys:
result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,dbconn=dbconn)
reverse = keys.get('reverse',True) # comaptibility with old calls
if keys.get('perpage',math.inf) is not math.inf:
limit = (keys.get('page',0)+1) * keys.get('perpage',100)
behead = keys.get('page',0) * keys.get('perpage',100)
else:
result = sqldb.get_scrobbles(since=since,to=to,dbconn=dbconn)
limit = None
behead = 0
associated = keys.get('associated',False)
if 'artist' in keys:
result = sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,limit=limit,reverse=reverse,dbconn=dbconn)
elif 'track' in keys:
result = sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn)
elif 'album' in keys:
result = sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn)
else:
result = sqldb.get_scrobbles(since=since,to=to,limit=limit,reverse=reverse,dbconn=dbconn)
#return result[keys['page']*keys['perpage']:(keys['page']+1)*keys['perpage']]
return list(reversed(result))
#print(result)
return list(result[behead:])
@waitfordb
def get_scrobbles_num(dbconn=None,**keys):
(since,to) = keys.get('timerange').timestamps()
associated = keys.get('associated',False)
if 'artist' in keys:
result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,resolve_references=False,dbconn=dbconn))
result = len(sqldb.get_scrobbles_of_artist(artist=keys['artist'],since=since,to=to,associated=associated,resolve_references=False,dbconn=dbconn))
elif 'track' in keys:
result = len(sqldb.get_scrobbles_of_track(track=keys['track'],since=since,to=to,resolve_references=False,dbconn=dbconn))
elif 'album' in keys:
result = len(sqldb.get_scrobbles_of_album(album=keys['album'],since=since,to=to,resolve_references=False,dbconn=dbconn))
else:
result = sqldb.get_scrobbles_num(since=since,to=to,dbconn=dbconn)
return result
@ -244,59 +373,184 @@ def get_tracks(dbconn=None,**keys):
return result
@waitfordb
def get_artists(dbconn=None):
return sqldb.get_artists(dbconn=dbconn)
@waitfordb
def get_charts_artists(dbconn=None,**keys):
(since,to) = keys.get('timerange').timestamps()
result = sqldb.count_scrobbles_by_artist(since=since,to=to,dbconn=dbconn)
def get_albums(dbconn=None,**keys):
if keys.get('artist') is None:
result = sqldb.get_albums(dbconn=dbconn)
else:
result = sqldb.get_albums_of_artists([sqldb.get_artist_id(keys.get('artist'),create_new=False)],dbconn=dbconn)
return result
@waitfordb
def get_charts_tracks(dbconn=None,**keys):
def get_artists(dbconn=None):
return sqldb.get_artists(dbconn=dbconn)
@waitfordb
def get_albums_artist_appears_on(dbconn=None,**keys):
artist_id = sqldb.get_artist_id(keys['artist'],dbconn=dbconn)
albums = sqldb.get_albums_artists_appear_on([artist_id],dbconn=dbconn).get(artist_id) or []
ownalbums = sqldb.get_albums_of_artists([artist_id],dbconn=dbconn).get(artist_id) or []
result = {
"own_albums":ownalbums,
"appears_on":[a for a in albums if a not in ownalbums]
}
return result
@waitfordb
def get_tracks_without_album(dbconn=None,resolve_ids=True):
return get_charts_tracks(album=None,timerange=alltime(),resolve_ids=resolve_ids,dbconn=dbconn)
@waitfordb
def get_charts_artists(dbconn=None,resolve_ids=True,**keys):
(since,to) = keys.get('timerange').timestamps()
separate = keys.get('separate',False)
result = sqldb.count_scrobbles_by_artist(since=since,to=to,resolve_ids=resolve_ids,associated=(not separate),dbconn=dbconn)
if resolve_ids:
# only add associated info if we resolve
map = sqldb.get_associated_artist_map(artist_ids=[entry['artist_id'] for entry in result if 'artist_id' in entry])
for entry in result:
if "artist_id" in entry:
entry['associated_artists'] = map[entry['artist_id']]
return result
@waitfordb
def get_charts_tracks(dbconn=None,resolve_ids=True,**keys):
(since,to) = keys.get('timerange').timestamps()
if 'artist' in keys:
result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],dbconn=dbconn)
result = sqldb.count_scrobbles_by_track_of_artist(since=since,to=to,artist=keys['artist'],associated=keys.get('associated',False),resolve_ids=resolve_ids,dbconn=dbconn)
elif 'album' in keys:
result = sqldb.count_scrobbles_by_track_of_album(since=since,to=to,album=keys['album'],resolve_ids=resolve_ids,dbconn=dbconn)
else:
result = sqldb.count_scrobbles_by_track(since=since,to=to,dbconn=dbconn)
result = sqldb.count_scrobbles_by_track(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn)
return result
@waitfordb
def get_charts_albums(dbconn=None,resolve_ids=True,only_own_albums=False,**keys):
# TODO: different scrobble numbers for only own tracks on own album etc?
(since,to) = keys.get('timerange').timestamps()
if 'artist' in keys:
result = sqldb.count_scrobbles_by_album_combined(since=since,to=to,artist=keys['artist'],associated=keys.get('associated',False),resolve_ids=resolve_ids,dbconn=dbconn)
if only_own_albums:
# TODO: this doesnt take associated into account and doesnt change ranks
result = [e for e in result if keys['artist'] in (e['album']['artists'] or [])]
else:
result = sqldb.count_scrobbles_by_album(since=since,to=to,resolve_ids=resolve_ids,dbconn=dbconn)
return result
@waitfordb
def get_pulse(dbconn=None,**keys):
# amountkeys for pulse and performance aren't really necessary
# since the amount of entries is completely determined by the time keys
# but lets just include it in case
reverse = keys.get('reverse',False)
if keys.get('perpage',math.inf) is not math.inf:
limit = (keys.get('page',0)+1) * keys.get('perpage',100)
behead = keys.get('page',0) * keys.get('perpage',100)
else:
limit = math.inf
behead = 0
rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]})
if reverse: rngs = reversed(list(rngs))
results = []
for rng in rngs:
# count down how many we need
if limit==0:
break
limit -= 1
# skip prev pages
if behead>0:
behead -= 1
continue
res = get_scrobbles_num(timerange=rng,**{k:keys[k] for k in keys if k != 'timerange'},dbconn=dbconn)
results.append({"range":rng,"scrobbles":res})
if keys.get('artist') and keys.get('associated',False):
res_real = get_scrobbles_num(timerange=rng,**{k:keys[k] for k in keys if k not in ['timerange','associated']},associated=False,dbconn=dbconn)
# this isnt really efficient, we could do that in one db call, but i dont wanna reorganize rn
else:
res_real = res
results.append({"range":rng,"scrobbles":res,"real_scrobbles":res_real})
return results
@waitfordb
def get_performance(dbconn=None,**keys):
# amountkeys for pulse and performance aren't really necessary
# since the amount of entries is completely determined by the time keys
# but lets just include it in case
reverse = keys.get('reverse',False)
if keys.get('perpage',math.inf) is not math.inf:
limit = (keys.get('page',0)+1) * keys.get('perpage',100)
behead = keys.get('page',0) * keys.get('perpage',100)
else:
limit = math.inf
behead = 0
separate = keys.get('separate')
rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]})
if reverse: rngs = reversed(list(rngs))
results = []
for rng in rngs:
# count down how many we need
if limit==0:
break
limit -= 1
# skip prev pages
if behead>0:
behead -= 1
continue
if "track" in keys:
track = sqldb.get_track(sqldb.get_track_id(keys['track'],dbconn=dbconn),dbconn=dbconn)
charts = get_charts_tracks(timerange=rng,dbconn=dbconn)
track_id = sqldb.get_track_id(keys['track'],create_new=False,dbconn=dbconn)
if not track_id:
raise exceptions.TrackDoesNotExist(keys['track'])
#track = sqldb.get_track(track_id,dbconn=dbconn)
charts = get_charts_tracks(timerange=rng,resolve_ids=False,dbconn=dbconn)
rank = None
for c in charts:
if c["track"] == track:
if c["track_id"] == track_id:
rank = c["rank"]
break
elif "artist" in keys:
artist = sqldb.get_artist(sqldb.get_artist_id(keys['artist'],dbconn=dbconn),dbconn=dbconn)
artist_id = sqldb.get_artist_id(keys['artist'],create_new=False,dbconn=dbconn)
if not artist_id:
raise exceptions.ArtistDoesNotExist(keys['artist'])
#artist = sqldb.get_artist(artist_id,dbconn=dbconn)
# ^this is the most useless line in programming history
# but I like consistency
charts = get_charts_artists(timerange=rng,dbconn=dbconn)
charts = get_charts_artists(timerange=rng,resolve_ids=False,separate=separate,dbconn=dbconn)
rank = None
for c in charts:
if c["artist"] == artist:
if c["artist_id"] == artist_id:
rank = c["rank"]
break
elif "album" in keys:
album_id = sqldb.get_album_id(keys['album'],create_new=False,dbconn=dbconn)
if not album_id:
raise exceptions.AlbumDoesNotExist(keys['album'])
#album = sqldb.get_album(album_id,dbconn=dbconn)
charts = get_charts_albums(timerange=rng,resolve_ids=False,dbconn=dbconn)
rank = None
for c in charts:
if c["album_id"] == album_id:
rank = c["rank"]
break
else:
@ -308,15 +562,17 @@ def get_performance(dbconn=None,**keys):
@waitfordb
def get_top_artists(dbconn=None,**keys):
separate = keys.get('separate')
rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]})
results = []
for rng in rngs:
try:
res = get_charts_artists(timerange=rng,dbconn=dbconn)[0]
results.append({"range":rng,"artist":res["artist"],"scrobbles":res["scrobbles"]})
res = get_charts_artists(timerange=rng,separate=separate,dbconn=dbconn)[0]
results.append({"range":rng,"artist":res["artist"],"scrobbles":res["scrobbles"],"real_scrobbles":res["real_scrobbles"],"associated_artists":sqldb.get_associated_artists(res["artist"])})
except Exception:
results.append({"range":rng,"artist":None,"scrobbles":0})
results.append({"range":rng,"artist":None,"scrobbles":0,"real_scrobbles":0})
return results
@ -336,48 +592,112 @@ def get_top_tracks(dbconn=None,**keys):
return results
@waitfordb
def get_top_albums(dbconn=None,**keys):
rngs = ranges(**{k:keys[k] for k in keys if k in ["since","to","within","timerange","step","stepn","trail"]})
results = []
for rng in rngs:
try:
res = get_charts_albums(timerange=rng,dbconn=dbconn)[0]
results.append({"range":rng,"album":res["album"],"scrobbles":res["scrobbles"]})
except Exception:
results.append({"range":rng,"album":None,"scrobbles":0})
return results
@waitfordb
def artist_info(dbconn=None,**keys):
artist = keys.get('artist')
if artist is None: raise exceptions.MissingEntityParameter()
artist_id = sqldb.get_artist_id(artist,dbconn=dbconn)
artist_id = sqldb.get_artist_id(artist,create_new=False,dbconn=dbconn)
if not artist_id: raise exceptions.ArtistDoesNotExist(artist)
artist = sqldb.get_artist(artist_id,dbconn=dbconn)
alltimecharts = get_charts_artists(timerange=alltime(),dbconn=dbconn)
scrobbles = get_scrobbles_num(artist=artist,timerange=alltime(),dbconn=dbconn)
#we cant take the scrobble number from the charts because that includes all countas scrobbles
try:
c = [e for e in alltimecharts if e["artist"] == artist][0]
scrobbles = get_scrobbles_num(artist=artist,timerange=alltime(),dbconn=dbconn)
albums = sqldb.get_albums_of_artists(set([artist_id]),dbconn=dbconn)
isalbumartist = len(albums.get(artist_id,[]))>0
cert = None
own_track_charts = get_charts_tracks(timerange=alltime(),resolve_ids=False,artist=artist,dbconn=dbconn)
own_album_charts = get_charts_albums(timerange=alltime(),resolve_ids=True,artist=artist,dbconn=dbconn)
# we resolve ids here which we don't need to. however, on the jinja page we make that same call
# later again with resolve ids, so its a cache miss and it doubles page load time
# TODO: find better solution
if own_track_charts:
c = own_track_charts[0]
tscrobbles = c["scrobbles"]
threshold_gold, threshold_platinum, threshold_diamond = malojaconfig["SCROBBLES_GOLD","SCROBBLES_PLATINUM","SCROBBLES_DIAMOND"]
if tscrobbles >= threshold_diamond: cert = "diamond"
elif tscrobbles >= threshold_platinum: cert = "platinum"
elif tscrobbles >= threshold_gold: cert = "gold"
if own_album_charts:
c = own_album_charts[0]
ascrobbles = c["scrobbles"]
threshold_gold, threshold_platinum, threshold_diamond = malojaconfig["SCROBBLES_GOLD_ALBUM","SCROBBLES_PLATINUM_ALBUM","SCROBBLES_DIAMOND_ALBUM"]
if ascrobbles >= threshold_diamond: cert = "diamond"
elif ascrobbles >= threshold_platinum and cert != "diamond": cert = "platinum"
elif ascrobbles >= threshold_gold and not cert: cert = "gold"
twk = thisweek()
tyr = thisyear()
# base info for everyone
result = {
"artist":artist,
"scrobbles":scrobbles,
"id":artist_id,
"isalbumartist":isalbumartist,
"certification":cert,
}
# check if credited to someone else
parent_artists = sqldb.get_credited_artists(artist)
if len(parent_artists) == 0:
c = [e for e in alltimecharts if e["artist"] == artist]
position = c[0]["rank"] if len(c) > 0 else None
others = sqldb.get_associated_artists(artist,dbconn=dbconn)
position = c["rank"]
return {
"artist":artist,
"scrobbles":scrobbles,
result.update({
"position":position,
"associated":others,
"medals":{
"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']],
"gold": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('artist_id') == artist_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_artist(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"silver": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('artist_id') == artist_id) and (e.get('rank') == 2) for e in
sqldb.count_scrobbles_by_artist(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"bronze": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('artist_id') == artist_id) and (e.get('rank') == 3) for e in
sqldb.count_scrobbles_by_artist(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)]
},
"topweeks":len([e for e in cached.weekly_topartists if e == artist_id]),
"id":artist_id
}
except Exception:
# if the artist isnt in the charts, they are not being credited and we
# need to show information about the credited one
replaceartist = sqldb.get_credited_artists(artist)[0]
"topweeks":len([
week for week in ranges(step="week") if (week != twk) and any(
(e.get('artist_id') == artist_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_artist(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,associated=True,dbconn=dbconn)
)
# we don't need to check the whole thing, just until rank is lower, but... well, its a list comprehension
])
})
else:
replaceartist = parent_artists[0]
c = [e for e in alltimecharts if e["artist"] == replaceartist][0]
position = c["rank"]
return {
"artist":artist,
result.update({
"replace":replaceartist,
"scrobbles":scrobbles,
"position":position,
"id":artist_id
}
"position":position
})
return result
@ -387,12 +707,14 @@ def track_info(dbconn=None,**keys):
track = keys.get('track')
if track is None: raise exceptions.MissingEntityParameter()
track_id = sqldb.get_track_id(track,dbconn=dbconn)
track_id = sqldb.get_track_id(track,create_new=False,dbconn=dbconn)
if not track_id: raise exceptions.TrackDoesNotExist(track)
track = sqldb.get_track(track_id,dbconn=dbconn)
alltimecharts = get_charts_tracks(timerange=alltime(),dbconn=dbconn)
alltimecharts = get_charts_tracks(timerange=alltime(),resolve_ids=False,dbconn=dbconn)
#scrobbles = get_scrobbles_num(track=track,timerange=alltime())
c = [e for e in alltimecharts if e["track"] == track][0]
c = [e for e in alltimecharts if e["track_id"] == track_id][0]
scrobbles = c["scrobbles"]
position = c["rank"]
cert = None
@ -401,22 +723,129 @@ def track_info(dbconn=None,**keys):
elif scrobbles >= threshold_platinum: cert = "platinum"
elif scrobbles >= threshold_gold: cert = "gold"
twk = thisweek()
tyr = thisyear()
return {
"track":track,
"scrobbles":scrobbles,
"position":position,
"medals":{
"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']],
"gold": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('track_id') == track_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_track(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"silver": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('track_id') == track_id) and (e.get('rank') == 2) for e in
sqldb.count_scrobbles_by_track(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"bronze": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('track_id') == track_id) and (e.get('rank') == 3) for e in
sqldb.count_scrobbles_by_track(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)]
},
"certification":cert,
"topweeks":len([e for e in cached.weekly_toptracks if e == track_id]),
"topweeks":len([
week for week in ranges(step="week") if (week != twk) and any(
(e.get('track_id') == track_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_track(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=dbconn)
)
]),
"id":track_id
}
@waitfordb
def album_info(dbconn=None,reduced=False,**keys):
album = keys.get('album')
if album is None: raise exceptions.MissingEntityParameter()
album_id = sqldb.get_album_id(album,create_new=False,dbconn=dbconn)
if not album_id: raise exceptions.AlbumDoesNotExist(album)
album = sqldb.get_album(album_id,dbconn=dbconn)
extrainfo = {}
if reduced:
scrobbles = get_scrobbles_num(album=album,timerange=alltime())
else:
alltimecharts = get_charts_albums(timerange=alltime(),dbconn=dbconn)
c = [e for e in alltimecharts if e["album"] == album][0]
scrobbles = c["scrobbles"]
position = c["rank"]
extrainfo['position'] = position
cert = None
threshold_gold, threshold_platinum, threshold_diamond = malojaconfig["SCROBBLES_GOLD_ALBUM","SCROBBLES_PLATINUM_ALBUM","SCROBBLES_DIAMOND_ALBUM"]
if scrobbles >= threshold_diamond: cert = "diamond"
elif scrobbles >= threshold_platinum: cert = "platinum"
elif scrobbles >= threshold_gold: cert = "gold"
if reduced:
pass
else:
twk = thisweek()
tyr = thisyear()
extrainfo.update({
"medals":{
"gold": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('album_id') == album_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_album(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"silver": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('album_id') == album_id) and (e.get('rank') == 2) for e in
sqldb.count_scrobbles_by_album(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)],
"bronze": [year.desc() for year in ranges(step='year') if (year != tyr) and any(
(e.get('album_id') == album_id) and (e.get('rank') == 3) for e in
sqldb.count_scrobbles_by_album(since=year.first_stamp(),to=year.last_stamp(),resolve_ids=False,dbconn=dbconn)
)]
},
"topweeks":len([
week for week in ranges(step="week") if (week != twk) and any(
(e.get('album_id') == album_id) and (e.get('rank') == 1) for e in
sqldb.count_scrobbles_by_album(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=dbconn)
)
])
})
return {
"album":album,
"scrobbles":scrobbles,
"certification":cert,
"id":album_id,
**extrainfo
}
### TODO: FIND COOL ALGORITHM TO SELECT FEATURED STUFF
@waitfordb
def get_featured(dbconn=None):
# temporary stand-in
ranges = [
MTRangeComposite(since=today().next(-14),to=today()),
MTRangeComposite(since=thisweek().next(-12),to=thisweek()),
MTRangeComposite(since=thisweek().next(-52),to=thisweek()),
alltime()
]
funcs = {
"artist": (get_charts_artists,{'associated':False}),
"album": (get_charts_albums,{}),
"track": (get_charts_tracks,{})
}
result = {t:None for t in funcs}
for entity_type in funcs:
for r in ranges:
func,kwargs = funcs[entity_type]
chart = func(timerange=r,**kwargs)
if chart:
result[entity_type] = chart[0][entity_type]
break
return result
def get_predefined_rulesets(dbconn=None):
validchars = "-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@ -464,6 +893,7 @@ def start_db():
# Upgrade database
from .. import upgrade
upgrade.upgrade_db(sqldb.add_scrobbles)
upgrade.parse_old_albums()
# Load temporary tables
from . import associated
@ -478,14 +908,15 @@ def start_db():
except IndexError:
register_scrobbletime(int(datetime.datetime.now().timestamp()))
# create cached information
cached.update_medals()
cached.update_weekly()
dbstatus['complete'] = True
# cache some stuff that we'll probably need
with sqldb.engine.connect() as dbconn:
with dbconn.begin():
for week in ranges(step='week'):
sqldb.count_scrobbles_by_artist(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,associated=True,dbconn=dbconn)
sqldb.count_scrobbles_by_track(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=dbconn)
sqldb.count_scrobbles_by_album(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=dbconn)
@ -497,4 +928,7 @@ def db_search(query,type=None):
results = sqldb.search_artist(query)
if type=="TRACK":
results = sqldb.search_track(query)
if type=="ALBUM":
results = sqldb.search_album(query)
return results

View File

@ -1,74 +0,0 @@
# for information that is not authorative, but should be saved anyway because it
# changes infrequently and DB access is expensive
from doreah.regular import runyearly, rundaily
from .. import database
from . import sqldb
from .. import malojatime as mjt
medals_artists = {
# year: {'gold':[],'silver':[],'bronze':[]}
}
medals_tracks = {
# year: {'gold':[],'silver':[],'bronze':[]}
}
weekly_topartists = []
weekly_toptracks = []
@runyearly
def update_medals():
global medals_artists, medals_tracks
medals_artists.clear()
medals_tracks.clear()
with sqldb.engine.begin() as conn:
for year in mjt.ranges(step="year"):
if year == mjt.thisyear(): break
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
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
@rundaily
def update_weekly():
global weekly_topartists, weekly_toptracks
weekly_topartists.clear()
weekly_toptracks.clear()
with sqldb.engine.begin() as conn:
for week in mjt.ranges(step="week"):
if week == mjt.thisweek(): break
charts_artists = sqldb.count_scrobbles_by_artist(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=conn)
charts_tracks = sqldb.count_scrobbles_by_track(since=week.first_stamp(),to=week.last_stamp(),resolve_ids=False,dbconn=conn)
for entry in charts_artists:
if entry['rank'] == 1: weekly_topartists.append(entry['artist_id'])
else: break
for entry in charts_tracks:
if entry['rank'] == 1: weekly_toptracks.append(entry['track_id'])
else: break

View File

@ -10,7 +10,7 @@ from doreah.regular import runhourly
from doreah.logging import log
from ..pkg_global.conf import malojaconfig
from . import no_aux_mode
if malojaconfig['USE_GLOBAL_CACHE']:
@ -21,6 +21,7 @@ if malojaconfig['USE_GLOBAL_CACHE']:
@runhourly
@no_aux_mode
def maintenance():
print_stats()
trim_cache()
@ -42,6 +43,7 @@ if malojaconfig['USE_GLOBAL_CACHE']:
conn = None
global hits, misses
key = (serialize(args),serialize(kwargs), inner_func, kwargs.get("since"), kwargs.get("to"))
# TODO: also factor in default values to get better chance of hits
try:
return cache[key]
@ -50,6 +52,8 @@ if malojaconfig['USE_GLOBAL_CACHE']:
cache[key] = result
return result
outer_func.__name__ = f"CACHD_{inner_func.__name__}"
return outer_func
@ -80,22 +84,23 @@ if malojaconfig['USE_GLOBAL_CACHE']:
return outer_func
@no_aux_mode
def invalidate_caches(scrobbletime=None):
cleared, kept = 0, 0
for k in cache.keys():
# VERY BIG TODO: differentiate between None as in 'unlimited timerange' and None as in 'time doesnt matter here'!
if scrobbletime is None or (k[3] is None or scrobbletime >= k[3]) and (k[4] is None or scrobbletime <= k[4]):
if scrobbletime is None or ((k[3] is None or scrobbletime >= k[3]) and (k[4] is None or scrobbletime <= k[4])):
cleared += 1
del cache[k]
else:
kept += 1
log(f"Invalidated {cleared} of {cleared+kept} DB cache entries")
@no_aux_mode
def invalidate_entity_cache():
entitycache.clear()
def trim_cache():
ramprct = psutil.virtual_memory().percent
if ramprct > malojaconfig["DB_MAX_MEMORY"]:
@ -132,7 +137,7 @@ else:
def serialize(obj):
try:
return serialize(obj.hashable())
except Exception:
except AttributeError:
try:
return json.dumps(obj)
except Exception:

View File

@ -11,12 +11,14 @@ class TrackExists(EntityExists):
class ArtistExists(EntityExists):
pass
class AlbumExists(EntityExists):
pass
class DatabaseNotBuilt(HTTPError):
def __init__(self):
super().__init__(
status=503,
body="The Maloja Database is being upgraded to Version 3. This could take quite a long time! (~ 2-5 minutes per 10 000 scrobbles)",
body="The Maloja Database is being upgraded to support new Maloja features. This could take a while.",
headers={"Retry-After":120}
)
@ -27,3 +29,19 @@ class MissingScrobbleParameters(Exception):
class MissingEntityParameter(Exception):
pass
class EntityDoesNotExist(HTTPError):
entitytype = 'Entity'
def __init__(self,entitydict):
self.entitydict = entitydict
super().__init__(
status=404,
body=f"The {self.entitytype} '{self.entitydict}' does not exist in the database."
)
class ArtistDoesNotExist(EntityDoesNotExist):
entitytype = 'Artist'
class AlbumDoesNotExist(EntityDoesNotExist):
entitytype = 'Album'
class TrackDoesNotExist(EntityDoesNotExist):
entitytype = 'Track'

File diff suppressed because it is too large Load Diff

View File

@ -8,32 +8,59 @@ from doreah.timing import Clock
from ..pkg_global.conf import data_dir
profiler = cProfile.Profile()
FULL_PROFILE = False
SINGLE_CALLS = False
# only save the last single call instead of adding up all calls
# of that function for more representative performance result
if not SINGLE_CALLS:
profilers = {}
times = {}
def profile(func):
def newfunc(*args,**kwargs):
if FULL_PROFILE:
benchmarkfolder = data_dir['logs']("benchmarks")
os.makedirs(benchmarkfolder,exist_ok=True)
realfunc = func
while hasattr(realfunc, '__innerfunc__'):
realfunc = realfunc.__innerfunc__
def newfunc(*args,**kwargs):
clock = Clock()
clock.start()
if FULL_PROFILE:
profiler.enable()
result = func(*args,**kwargs)
if FULL_PROFILE:
profiler.disable()
benchmarkfolder = data_dir['logs']("benchmarks")
os.makedirs(benchmarkfolder,exist_ok=True)
if SINGLE_CALLS:
localprofiler = cProfile.Profile()
else:
localprofiler = profilers.setdefault(realfunc,cProfile.Profile())
localprofiler.enable()
result = func(*args,**kwargs)
log(f"Executed {func.__name__} ({args}, {kwargs}) in {clock.stop():.2f}s",module="debug_performance")
if FULL_PROFILE:
localprofiler.disable()
seconds = clock.stop()
if not SINGLE_CALLS:
times.setdefault(realfunc,[]).append(seconds)
if SINGLE_CALLS:
log(f"Executed {realfunc.__name__} ({args}, {kwargs}) in {seconds:.3f}s",module="debug_performance")
else:
log(f"Executed {realfunc.__name__} ({args}, {kwargs}) in {seconds:.3f}s (Average: { sum(times[realfunc])/len(times[realfunc]):.3f}s)",module="debug_performance")
if FULL_PROFILE:
targetfilename = os.path.join(benchmarkfolder,f"{realfunc.__name__}.stats")
try:
pstats.Stats(profiler).dump_stats(os.path.join(benchmarkfolder,f"{func.__name__}.stats"))
pstats.Stats(localprofiler).dump_stats(targetfilename)
log(f"Saved benchmark as {targetfilename}")
except Exception:
pass
log(f"Failed to save benchmark as {targetfilename}")
return result

View File

@ -12,195 +12,324 @@ import base64
import requests
import datauri
import io
from threading import Thread, Timer, BoundedSemaphore
from threading import Lock
from concurrent.futures import ThreadPoolExecutor
import re
import datetime
import time
import sqlalchemy as sql
MAX_RESOLVE_THREADS = 5
MAX_SECONDS_TO_RESOLVE_REQUEST = 5
# remove old db file (columns missing)
try:
os.remove(data_dir['cache']('images.sqlite'))
except:
pass
DB = {}
engine = sql.create_engine(f"sqlite:///{data_dir['cache']('images.sqlite')}", echo = False)
engine = sql.create_engine(f"sqlite:///{data_dir['cache']('imagecache.sqlite')}", echo = False)
meta = sql.MetaData()
dblock = Lock()
DB['artists'] = sql.Table(
'artists', meta,
sql.Column('id',sql.Integer,primary_key=True),
sql.Column('url',sql.String),
sql.Column('expire',sql.Integer),
sql.Column('raw',sql.String)
# sql.Column('raw',sql.String)
sql.Column('local',sql.Boolean),
sql.Column('localproxyurl',sql.String)
)
DB['tracks'] = sql.Table(
'tracks', meta,
sql.Column('id',sql.Integer,primary_key=True),
sql.Column('url',sql.String),
sql.Column('expire',sql.Integer),
sql.Column('raw',sql.String)
# sql.Column('raw',sql.String)
sql.Column('local',sql.Boolean),
sql.Column('localproxyurl',sql.String)
)
DB['albums'] = sql.Table(
'albums', meta,
sql.Column('id',sql.Integer,primary_key=True),
sql.Column('url',sql.String),
sql.Column('expire',sql.Integer),
# sql.Column('raw',sql.String)
sql.Column('local',sql.Boolean),
sql.Column('localproxyurl',sql.String)
)
meta.create_all(engine)
def get_image_from_cache(id,table):
def get_id_and_table(track_id=None,artist_id=None,album_id=None):
if track_id:
return track_id,'tracks'
elif album_id:
return album_id,'albums'
elif artist_id:
return artist_id,'artists'
def get_image_from_cache(track_id=None,artist_id=None,album_id=None):
now = int(datetime.datetime.now().timestamp())
entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id)
with engine.begin() as conn:
op = DB[table].select().where(
DB[table].c.id==id,
DB[table].c.id==entity_id,
DB[table].c.expire>now
)
result = conn.execute(op).all()
for row in result:
if row.raw is not None:
return {'type':'raw','value':row.raw}
if row.local:
return {'type':'localurl','value':row.url}
elif row.localproxyurl:
return {'type':'localurl','value':row.localproxyurl}
else:
return {'type':'url','value':row.url} # returns None as value if nonexistence cached
return {'type':'url','value':row.url or None}
# value none means nonexistence is cached
# for some reason this can also be an empty string, so use or None here to unify
return None # no cache entry
def set_image_in_cache(id,table,url):
remove_image_from_cache(id,table)
now = int(datetime.datetime.now().timestamp())
if url is None:
expire = now + (malojaconfig["CACHE_EXPIRE_NEGATIVE"] * 24 * 3600)
else:
expire = now + (malojaconfig["CACHE_EXPIRE_POSITIVE"] * 24 * 3600)
def set_image_in_cache(url,track_id=None,artist_id=None,album_id=None,local=False):
remove_image_from_cache(track_id=track_id,artist_id=artist_id,album_id=album_id)
entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id)
raw = dl_image(url)
with dblock:
now = int(datetime.datetime.now().timestamp())
if url is None:
expire = now + (malojaconfig["CACHE_EXPIRE_NEGATIVE"] * 24 * 3600)
else:
expire = now + (malojaconfig["CACHE_EXPIRE_POSITIVE"] * 24 * 3600)
with engine.begin() as conn:
op = DB[table].insert().values(
id=id,
url=url,
expire=expire,
raw=raw
)
result = conn.execute(op)
if not local and malojaconfig["PROXY_IMAGES"] and url is not None:
localproxyurl = dl_image(url)
else:
localproxyurl = None
with engine.begin() as conn:
op = DB[table].insert().values(
id=entity_id,
url=url,
expire=expire,
local=local,
localproxyurl=localproxyurl
)
result = conn.execute(op)
def remove_image_from_cache(track_id=None,artist_id=None,album_id=None):
entity_id, table = get_id_and_table(track_id=track_id,artist_id=artist_id,album_id=album_id)
with dblock:
with engine.begin() as conn:
op = DB[table].delete().where(
DB[table].c.id==entity_id,
).returning(
DB[table].c.id,
DB[table].c.localproxyurl
)
result = conn.execute(op).all()
for row in result:
try:
targetpath = data_dir['cache']('images',row.localproxyurl.split('/')[-1])
os.remove(targetpath)
except:
pass
def remove_image_from_cache(id,table):
with engine.begin() as conn:
op = DB[table].delete().where(
DB[table].c.id==id,
)
result = conn.execute(op)
def dl_image(url):
if not malojaconfig["PROXY_IMAGES"]: return None
if url is None: return None
if url.startswith("/"): return None #local image
try:
r = requests.get(url)
mime = r.headers.get('content-type') or 'image/jpg'
data = io.BytesIO(r.content).read()
uri = datauri.DataURI.make(mime,charset='ascii',base64=True,data=data)
log(f"Downloaded {url} for local caching")
return uri
#uri = datauri.DataURI.make(mime,charset='ascii',base64=True,data=data)
targetname = '%030x' % random.getrandbits(128)
targetpath = data_dir['cache']('images',targetname)
with open(targetpath,'wb') as fd:
fd.write(data)
return os.path.join("/cacheimages",targetname)
except Exception:
log(f"Image {url} could not be downloaded for local caching")
return None
resolver = ThreadPoolExecutor(max_workers=MAX_RESOLVE_THREADS,thread_name_prefix='image_resolve')
### getting images for any website embedding now ALWAYS returns just the generic link
### even if we have already cached it, we will handle that on request
def get_track_image(track=None,track_id=None):
if track_id is None:
track_id = database.sqldb.get_track_id(track)
track_id = database.sqldb.get_track_id(track,create_new=False)
return f"/image?type=track&id={track_id}"
if malojaconfig["USE_ALBUM_ARTWORK_FOR_TRACKS"]:
if track is None:
track = database.sqldb.get_track(track_id)
if track.get("album"):
album_id = database.sqldb.get_album_id(track["album"])
return get_album_image(album_id=album_id)
resolver.submit(resolve_image,track_id=track_id)
return f"/image?track_id={track_id}"
def get_artist_image(artist=None,artist_id=None):
if artist_id is None:
artist_id = database.sqldb.get_artist_id(artist)
artist_id = database.sqldb.get_artist_id(artist,create_new=False)
return f"/image?type=artist&id={artist_id}"
resolver.submit(resolve_image,artist_id=artist_id)
return f"/image?artist_id={artist_id}"
def get_album_image(album=None,album_id=None):
if album_id is None:
album_id = database.sqldb.get_album_id(album,create_new=False)
resolver.submit(resolve_image,album_id=album_id)
return f"/image?album_id={album_id}"
# this is to keep track of what is currently being resolved
# so new requests know that they don't need to queue another resolve
image_resolve_controller_lock = Lock()
image_resolve_controller = {
'artists':set(),
'albums':set(),
'tracks':set()
}
# this function doesn't need to return any info
# it runs async to do all the work that takes time and only needs to write the result
# to the cache so the synchronous functions (http requests) can access it
def resolve_image(artist_id=None,track_id=None,album_id=None):
result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id)
if result is not None:
# No need to do anything
return
if artist_id:
entitytype = 'artist'
table = 'artists'
getfunc, entity_id = database.sqldb.get_artist, artist_id
elif track_id:
entitytype = 'track'
table = 'tracks'
getfunc, entity_id = database.sqldb.get_track, track_id
elif album_id:
entitytype = 'album'
table = 'albums'
getfunc, entity_id = database.sqldb.get_album, album_id
resolve_semaphore = BoundedSemaphore(8)
# is another thread already working on this?
with image_resolve_controller_lock:
if entity_id in image_resolve_controller[table]:
return
else:
image_resolve_controller[table].add(entity_id)
def resolve_track_image(track_id):
with resolve_semaphore:
# check cache
result = get_image_from_cache(track_id,'tracks')
if result is not None:
return result
track = database.sqldb.get_track(track_id)
try:
entity = getfunc(entity_id)
# local image
if malojaconfig["USE_LOCAL_IMAGES"]:
images = local_files(artists=track['artists'],title=track['title'])
images = local_files(**{entitytype: entity})
if len(images) != 0:
result = random.choice(images)
result = urllib.parse.quote(result)
result = {'type':'url','value':result}
set_image_in_cache(track_id,'tracks',result['value'])
result = {'type':'localurl','value':result}
set_image_in_cache(artist_id=artist_id,track_id=track_id,album_id=album_id,url=result['value'],local=True)
return result
# third party
result = thirdparty.get_image_track_all((track['artists'],track['title']))
result = {'type':'url','value':result}
set_image_in_cache(track_id,'tracks',result['value'])
if artist_id:
result = thirdparty.get_image_artist_all(entity)
elif track_id:
result = thirdparty.get_image_track_all((entity['artists'],entity['title']))
elif album_id:
result = thirdparty.get_image_album_all((entity['artists'],entity['albumtitle']))
return result
result = {'type':'url','value':result or None}
set_image_in_cache(artist_id=artist_id,track_id=track_id,album_id=album_id,url=result['value'])
finally:
with image_resolve_controller_lock:
image_resolve_controller[table].remove(entity_id)
def resolve_artist_image(artist_id):
with resolve_semaphore:
# the actual http request for the full image
def image_request(artist_id=None,track_id=None,album_id=None):
# because we use lazyload, we can allow our http requests to take a little while at least
# not the full backend request, but a few seconds to give us time to fetch some images
# because 503 retry-after doesn't seem to be honored
attempt = 0
while attempt < MAX_SECONDS_TO_RESOLVE_REQUEST:
attempt += 1
# check cache
result = get_image_from_cache(artist_id,'artists')
result = get_image_from_cache(artist_id=artist_id,track_id=track_id,album_id=album_id)
if result is not None:
# we got an entry, even if it's that there is no image (value None)
if result['value'] is None:
# use placeholder
if malojaconfig["FANCY_PLACEHOLDER_ART"]:
placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style="
if artist_id:
result['value'] = placeholder_url + f"tiles&colors={artist_id % 100}"
if track_id:
result['value'] = placeholder_url + f"triangles&colors={track_id % 100}"
if album_id:
result['value'] = placeholder_url + f"joy-division&colors={album_id % 100}"
else:
if artist_id:
result['value'] = "/static/svg/placeholder_artist.svg"
if track_id:
result['value'] = "/static/svg/placeholder_track.svg"
if album_id:
result['value'] = "/static/svg/placeholder_album.svg"
return result
time.sleep(1)
artist = database.sqldb.get_artist(artist_id)
# no entry, which means we're still working on it
return {'type':'noimage','value':'wait'}
# local image
if malojaconfig["USE_LOCAL_IMAGES"]:
images = local_files(artist=artist)
if len(images) != 0:
result = random.choice(images)
result = urllib.parse.quote(result)
result = {'type':'url','value':result}
set_image_in_cache(artist_id,'artists',result['value'])
return result
# third party
result = thirdparty.get_image_artist_all(artist)
result = {'type':'url','value':result}
set_image_in_cache(artist_id,'artists',result['value'])
return result
# removes emojis and weird shit from names
def clean(name):
return "".join(c for c in name if c.isalnum() or c in []).strip()
def get_all_possible_filenames(artist=None,artists=None,title=None):
# check if we're dealing with a track or artist, then clean up names
# (only remove non-alphanumeric, allow korean and stuff)
if title is not None and artists is not None:
track = True
title, artists = clean(title), [clean(a) for a in artists]
elif artist is not None:
track = False
# new and improved
def get_all_possible_filenames(artist=None,track=None,album=None):
if track:
title, artists = clean(track['title']), [clean(a) for a in track['artists']]
superfolder = "tracks/"
elif album:
title, artists = clean(album['albumtitle']), [clean(a) for a in album.get('artists') or []]
superfolder = "albums/"
elif artist:
artist = clean(artist)
else: return []
superfolder = "tracks/" if track else "artists/"
superfolder = "artists/"
else:
return []
filenames = []
if track:
#unsafeartists = [artist.translate(None,"-_./\\") for artist in artists]
if track or album:
safeartists = [re.sub("[^a-zA-Z0-9]","",artist) for artist in artists]
#unsafetitle = title.translate(None,"-_./\\")
safetitle = re.sub("[^a-zA-Z0-9]","",title)
if len(artists) < 4:
@ -210,7 +339,6 @@ def get_all_possible_filenames(artist=None,artists=None,title=None):
unsafeperms = [sorted(artists)]
safeperms = [sorted(safeartists)]
for unsafeartistlist in unsafeperms:
filename = "-".join(unsafeartistlist) + "_" + title
if filename != "":
@ -241,10 +369,11 @@ def get_all_possible_filenames(artist=None,artists=None,title=None):
return [superfolder + name for name in filenames]
def local_files(artist=None,artists=None,title=None):
def local_files(artist=None,album=None,track=None):
filenames = get_all_possible_filenames(artist,artists,title)
filenames = get_all_possible_filenames(artist=artist,album=album,track=track)
images = []
@ -271,13 +400,21 @@ class MalformedB64(Exception):
pass
def set_image(b64,**keys):
track = "title" in keys
if track:
entity = {'artists':keys['artists'],'title':keys['title']}
id = database.sqldb.get_track_id(entity)
else:
entity = keys['artist']
id = database.sqldb.get_artist_id(entity)
if "title" in keys:
entity = {"track":keys}
id = database.sqldb.get_track_id(entity['track'])
idkeys = {'track_id':id}
dbtable = "tracks"
elif "albumtitle" in keys:
entity = {"album":keys}
id = database.sqldb.get_album_id(entity['album'])
idkeys = {'album_id':id}
dbtable = "albums"
elif "artist" in keys:
entity = keys
id = database.sqldb.get_artist_id(entity['artist'])
idkeys = {'artist_id':id}
dbtable = "artists"
log("Trying to set image, b64 string: " + str(b64[:30] + "..."),module="debug")
@ -288,13 +425,13 @@ def set_image(b64,**keys):
type,b64 = match.groups()
b64 = base64.b64decode(b64)
filename = "webupload" + str(int(datetime.datetime.now().timestamp())) + "." + type
for folder in get_all_possible_filenames(**keys):
for folder in get_all_possible_filenames(**entity):
if os.path.exists(data_dir['images'](folder)):
with open(data_dir['images'](folder,filename),"wb") as f:
f.write(b64)
break
else:
folder = get_all_possible_filenames(**keys)[0]
folder = get_all_possible_filenames(**entity)[0]
os.makedirs(data_dir['images'](folder))
with open(data_dir['images'](folder,filename),"wb") as f:
f.write(b64)
@ -303,7 +440,6 @@ def set_image(b64,**keys):
log("Saved image as " + data_dir['images'](folder,filename),module="debug")
# set as current picture in rotation
if track: set_image_in_cache(id,'tracks',os.path.join("/images",folder,filename))
else: set_image_in_cache(id,'artists',os.path.join("/images",folder,filename))
set_image_in_cache(**idkeys,url=os.path.join("/images",folder,filename),local=True)
return os.path.join("/images",folder,filename)

View File

@ -26,8 +26,6 @@ def update_jinja_environment():
JINJA_CONTEXT = {
# maloja
"db": database, #TODO: move these to connection manager as well
#"dbp":dbp,
"malojatime": malojatime,
"images": images,
"mlj_uri": malojauri,
@ -72,6 +70,14 @@ def update_jinja_environment():
{"identifier":"longtrailing","replacekeys":{"trail":3},"localisation":"Long Trailing"},
{"identifier":"inert","replacekeys":{"trail":10},"localisation":"Inert","heavy":True},
{"identifier":"cumulative","replacekeys":{"trail":math.inf},"localisation":"Cumulative","heavy":True}
],
"xassociated": [
{"identifier":"include_associated","replacekeys":{"associated":True},"localisation":"Associated"},
{"identifier":"exclude_associated","replacekeys":{"associated":False},"localisation":"Exclusive"}
],
"xseparate": [
{"identifier":"count_combined","replacekeys":{"separate":False},"localisation":"Combined"},
{"identifier":"count_separate","replacekeys":{"separate":True},"localisation":"Separate"}
]
}

View File

@ -1,7 +1,7 @@
from datetime import timezone, timedelta, date, time, datetime
from calendar import monthrange
from os.path import commonprefix
import math
from abc import ABC, abstractmethod
from .pkg_global.conf import malojaconfig
@ -28,7 +28,7 @@ def register_scrobbletime(timestamp):
# Generic Time Range
class MTRangeGeneric:
class MTRangeGeneric(ABC):
# despite the above, ranges that refer to the exact same real time range should evaluate as equal
def __eq__(self,other):
@ -68,6 +68,15 @@ class MTRangeGeneric:
def __contains__(self,timestamp):
return timestamp >= self.first_stamp() and timestamp <= self.last_stamp()
@abstractmethod
def first_stamp(self):
pass
@abstractmethod
def last_stamp(self):
pass
# Any range that has one defining base unit, whether week, year, etc.
class MTRangeSingular(MTRangeGeneric):
def fromstr(self):

View File

@ -4,7 +4,7 @@ import urllib
import math
# this also sets defaults!
def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False):
def uri_to_internal(keys,accepted_entities=('artist','track','album'),forceTrack=False,forceArtist=False,forceAlbum=False,api=False):
# output:
# 1 keys that define the filtered object like artist or track
@ -12,14 +12,33 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False):
# 3 keys that define interal time ranges
# 4 keys that define amount limits
# if we force a type, that only means that the other types are not allowed
# it could still have no type at all (any call that isn't filtering by entity)
if forceTrack: accepted_entities = ('track',)
if forceArtist: accepted_entities = ('artist',)
if forceAlbum: accepted_entities = ('album',)
# API backwards compatibility
if "artist" in keys and "artist" not in accepted_entities:
if "track" in accepted_entities:
keys['trackartist'] = keys['artist']
elif "album" in accepted_entities:
keys['albumartist'] = keys['artist']
# 1
if "title" in keys and not forceArtist:
filterkeys = {"track":{"artists":keys.getall("artist"),"title":keys.get("title")}}
elif "artist" in keys and not forceTrack:
filterkeys = {"artist":keys.get("artist")}
if "associated" in keys: filterkeys["associated"] = True
else:
filterkeys = {}
filterkeys = {}
if "track" in accepted_entities and "title" in keys:
filterkeys.update({"track":{"artists":keys.getall("trackartist"),"title":keys.get("title")}})
if "artist" in accepted_entities and "artist" in keys:
filterkeys.update({"artist": keys.get("artist"), "associated": (keys.get('associated', 'no').lower() == 'yes')})
# associated is only used for filtering by artist, to indicate that we include associated artists
# for actual artist charts, to show that we want to count them, use 'unified'
if "album" in accepted_entities and "albumtitle" in keys:
filterkeys.update({"album":{"artists":keys.getall("albumartist"),"albumtitle":keys.get("albumtitle")}})
# 2
limitkeys = {}
@ -51,11 +70,20 @@ def uri_to_internal(keys,forceTrack=False,forceArtist=False,api=False):
#different max than the internal one! the user doesn't get to disable pagination
if "page" in keys: amountkeys["page"] = int(keys["page"])
if "perpage" in keys: amountkeys["perpage"] = int(keys["perpage"])
#amountkeys["reverse"] = (keys.get("reverse","no").lower() == "yes")
# we have different defaults for different things, so here we need to actually pass true false or nothing dependent
# on whether its specified
if keys.get("reverse","").lower() == 'yes': amountkeys['reverse'] = True
elif keys.get("reverse","").lower() == 'no': amountkeys['reverse'] = False
#5
specialkeys = {}
if "remote" in keys: specialkeys["remote"] = keys["remote"]
#if "remote" in keys: specialkeys["remote"] = keys["remote"]
specialkeys["separate"] = (keys.get('separate','no').lower() == 'yes')
for k in keys:
if k in ['remote','b64']:
# TODO: better solution!
specialkeys[k] = keys[k]
return filterkeys, limitkeys, delimitkeys, amountkeys, specialkeys
@ -80,10 +108,15 @@ def internal_to_uri(keys):
if "artist" in keys:
urikeys.append("artist",keys["artist"])
if keys.get("associated"): urikeys.append("associated","yes")
elif "track" in keys:
if "track" in keys:
for a in keys["track"]["artists"]:
urikeys.append("artist",a)
urikeys.append("trackartist",a)
urikeys.append("title",keys["track"]["title"])
if "album" in keys:
for a in keys["album"].get("artists") or []:
urikeys.append("albumartist",a)
urikeys.append("albumtitle",keys["album"]["albumtitle"])
#time
if "timerange" in keys:
@ -116,6 +149,11 @@ def internal_to_uri(keys):
urikeys.append("page",str(keys["page"]))
if "perpage" in keys:
urikeys.append("perpage",str(keys["perpage"]))
if "reverse" in keys:
urikeys.append("reverse","yes" if keys['reverse'] else "no")
if keys.get("separate",False):
urikeys.append("separate","yes")
return urikeys

View File

@ -6,6 +6,8 @@ from doreah.configuration import types as tp
from ..__pkginfo__ import VERSION
# this mode specifies whether we run some auxiliary task instead of the main server
AUX_MODE = True
# if DATA_DIRECTORY is specified, this is the directory to use for EVERYTHING, no matter what
@ -148,14 +150,17 @@ malojaconfig = Configuration(
"Technical":{
"cache_expire_positive":(tp.Integer(), "Image Cache Expiration", 60, "Days until images are refetched"),
"cache_expire_negative":(tp.Integer(), "Image Cache Negative Expiration", 5, "Days until failed image fetches are reattempted"),
"db_max_memory":(tp.Integer(min=0,max=100), "RAM Percentage soft limit", 50, "RAM Usage in percent at which Maloja should no longer increase its database cache."),
"db_max_memory":(tp.Integer(min=0,max=100), "RAM Percentage soft limit", 70, "RAM Usage in percent at which Maloja should no longer increase its database cache."),
"use_request_cache":(tp.Boolean(), "Use request-local DB Cache", False),
"use_global_cache":(tp.Boolean(), "Use global DB Cache", True)
"use_global_cache":(tp.Boolean(), "Use global DB Cache", True, "This is vital for Maloja's performance. Do not disable this unless you have a strong reason to.")
},
"Fluff":{
"scrobbles_gold":(tp.Integer(), "Scrobbles for Gold", 250, "How many scrobbles a track needs to be considered 'Gold' status"),
"scrobbles_platinum":(tp.Integer(), "Scrobbles for Platinum", 500, "How many scrobbles a track needs to be considered 'Platinum' status"),
"scrobbles_diamond":(tp.Integer(), "Scrobbles for Diamond", 1000, "How many scrobbles a track needs to be considered 'Diamond' status"),
"scrobbles_gold":(tp.Integer(), "Scrobbles for Gold (Track)", 250, "How many scrobbles a track needs to be considered 'Gold' status"),
"scrobbles_platinum":(tp.Integer(), "Scrobbles for Platinum (Track)",500, "How many scrobbles a track needs to be considered 'Platinum' status"),
"scrobbles_diamond":(tp.Integer(), "Scrobbles for Diamond (Track)",1000, "How many scrobbles a track needs to be considered 'Diamond' status"),
"scrobbles_gold_album":(tp.Integer(), "Scrobbles for Gold (Album)", 500, "How many scrobbles an album needs to be considered 'Gold' status"),
"scrobbles_platinum_album":(tp.Integer(), "Scrobbles for Platinum (Album)",750, "How many scrobbles an album needs to be considered 'Platinum' status"),
"scrobbles_diamond_album":(tp.Integer(), "Scrobbles for Diamond (Album)",1500, "How many scrobbles an album needs to be considered 'Diamond' status"),
"name":(tp.String(), "Name", "Generic Maloja User")
},
"Third Party Services":{
@ -177,6 +182,7 @@ malojaconfig = Configuration(
},
"Database":{
"album_information_trust":(tp.Choice({'first':"First",'last':"Last",'majority':"Majority"}), "Album Information Authority","first", "Whether to trust the first album information that is sent with a track or update every time a different album is sent"),
"invalid_artists":(tp.Set(tp.String()), "Invalid Artists", ["[Unknown Artist]","Unknown Artist","Spotify"], "Artists that should be discarded immediately"),
"remove_from_title":(tp.Set(tp.String()), "Remove from Title", ["(Original Mix)","(Radio Edit)","(Album Version)","(Explicit Version)","(Bonus Track)"], "Phrases that should be removed from song titles"),
"delimiters_feat":(tp.Set(tp.String()), "Featuring Delimiters", ["ft.","ft","feat.","feat","featuring"], "Delimiters used for extra artists, even when in the title field"),
@ -186,11 +192,14 @@ malojaconfig = Configuration(
"parse_remix_artists":(tp.Boolean(), "Parse Remix Artists", False)
},
"Web Interface":{
"default_range_charts_artists":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range Artist Charts", "year"),
"default_range_charts_tracks":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range Track Charts", "year"),
"default_range_startpage":(tp.Choice({'alltime':'All Time','year':'Year','month':"Month",'week':'Week'}), "Default Range for Startpage Stats", "year"),
"default_step_pulse":(tp.Choice({'year':'Year','month':"Month",'week':'Week','day':'Day'}), "Default Pulse Step", "month"),
"charts_display_tiles":(tp.Boolean(), "Display Chart Tiles", False),
"album_showcase":(tp.Boolean(), "Display Album Showcase", True, "Display a graphical album showcase for artist overview pages instead of a chart list"),
"display_art_icons":(tp.Boolean(), "Display Album/Artist Icons", True),
"default_album_artist":(tp.String(), "Default Albumartist", "Various Artists"),
"use_album_artwork_for_tracks":(tp.Boolean(), "Use Album Artwork for tracks", True),
"fancy_placeholder_art":(tp.Boolean(), "Use fancy placeholder artwork",True),
"discourage_cpu_heavy_stats":(tp.Boolean(), "Discourage CPU-heavy stats", False, "Prevent visitors from mindlessly clicking on CPU-heavy options. Does not actually disable them for malicious actors!"),
"use_local_images":(tp.Boolean(), "Use Local Images", True),
#"local_image_rotate":(tp.Integer(), "Local Image Rotate", 3600),
@ -297,15 +306,6 @@ data_dir = {
### write down the last ran version
with open(pthj(dir_settings['state'],".lastmalojaversion"),"w") as filed:
filed.write(VERSION)
filed.write("\n")
### DOREAH CONFIGURATION
from doreah import config
@ -331,7 +331,8 @@ config(
custom_css_files = [f for f in os.listdir(data_dir['css']()) if f.lower().endswith('.css')]
from ..database.sqldb import set_maloja_info
set_maloja_info({'last_run_version':VERSION})
# what the fuck did i just write
# this spaghetti file is proudly sponsored by the rice crackers i'm eating at the

View File

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

View File

@ -49,7 +49,7 @@ def import_scrobbles(inputf):
typeid,typedesc = "spotify","Spotify"
importfunc = parse_spotify_lite_legacy
elif re.match(r"maloja_export_[0-9]+\.json",filename):
elif re.match(r"maloja_export[_0-9]*\.json",filename):
typeid,typedesc = "maloja","Maloja"
importfunc = parse_maloja
@ -88,10 +88,6 @@ def import_scrobbles(inputf):
# extra info
extrainfo = {}
if scrobble.get('album_name'): extrainfo['album_name'] = scrobble['album_name']
if scrobble.get('album_artist'): extrainfo['album_artist'] = scrobble['album_artist']
# saving this in the scrobble instead of the track because for now it's not meant
# to be authorative information, just payload of the scrobble
scrobblebuffer.append({
"time":scrobble['scrobble_time'],
@ -99,6 +95,11 @@ def import_scrobbles(inputf):
"artists":scrobble['track_artists'],
"title":scrobble['track_title'],
"length":scrobble['track_length'],
"album":{
"albumtitle":scrobble.get('album_name') or None,
"artists":scrobble.get('album_artists') or scrobble['track_artists'] or None
# TODO: use same heuristics as with parsing to determine album?
} if scrobble.get('album_name') else None
},
"duration":scrobble['scrobble_duration'],
"origin":"import:" + typeid,
@ -441,7 +442,8 @@ def parse_maloja(inputf):
'track_title': s['track']['title'],
'track_artists': s['track']['artists'],
'track_length': s['track']['length'],
'album_name': s['track'].get('album',{}).get('name',''),
'album_name': s['track'].get('album',{}).get('albumtitle','') if s['track'].get('album') is not None else '',
'album_artists': s['track'].get('album',{}).get('artists',None) if s['track'].get('album') is not None else '',
'scrobble_time': s['time'],
'scrobble_duration': s['duration']
},'')

View File

@ -0,0 +1,108 @@
from doreah.io import col
def parse_albums(strategy=None,prefer_existing=False):
if strategy not in ("track","none","all","majority","most"):
print("""
Please specify your album parsing strategy:
--strategy Specify what strategy to use when the scrobble contains
no information about album artists.
track Take the track artists. This can lead to
separate albums being created for compilation
albums or albums that have collaboration tracks.
none Merge all albums with the same name and assign
'Various Artists' as the album artist.
all Merge all albums with the same name and assign
every artist that appears on the album as an album
artist.
majority Merge all albums with the same name and assign
artists that appear in at least half the tracks
of the album as album artists. [RECOMMENDED]
most Merge all albums with the same name and assign
the artist that appears most on the album as album
artist.
--prefer_existing If an album with the same name already exists, use it
without further examination of track artists.
""")
return
from ...database.sqldb import guess_albums, get_album_id, add_track_to_album
print("Parsing album information...")
result = guess_albums()
result = {track_id:result[track_id] for track_id in result if result[track_id]["assigned"]}
print("Found",col['yellow'](len(result)),"Tracks to assign albums to")
result_authorative = {track_id:result[track_id] for track_id in result if result[track_id]["assigned"]["artists"]}
result_guesswork = {track_id:result[track_id] for track_id in result if not result[track_id]["assigned"]["artists"]}
i = 0
def countup(i):
i+=1
if (i % 100) == 0:
print(f"Added album information for {i} of {len(result)} tracks...")
return i
for track_id in result_authorative:
albuminfo = result[track_id]['assigned']
album_id = get_album_id(albuminfo)
add_track_to_album(track_id,album_id)
i=countup(i)
albums = {}
for track_id in result_guesswork:
albuminfo = result[track_id]['assigned']
# check if already exists
if prefer_existing:
album_id = get_album_id(albuminfo,ignore_albumartists=True,create_new=False)
if album_id:
add_track_to_album(track_id,album_id)
i=countup(i)
continue
if strategy == 'track':
albuminfo['artists'] = result[track_id]['guess_artists']
album_id = get_album_id(albuminfo)
add_track_to_album(track_id,album_id)
i=countup(i)
continue
if strategy == 'none':
albuminfo['artists'] = []
album_id = get_album_id(albuminfo)
add_track_to_album(track_id,album_id)
i=countup(i)
continue
if strategy in ['all','majority','most']:
cleantitle = albuminfo['albumtitle'].lower()
albums.setdefault(cleantitle,{'track_ids':[],'artists':{},'title':albuminfo['albumtitle']})
albums[cleantitle]['track_ids'].append(track_id)
for a in result[track_id]['guess_artists']:
albums[cleantitle]['artists'].setdefault(a,0)
albums[cleantitle]['artists'][a] += 1
for cleantitle in albums:
artistoptions = albums[cleantitle]['artists']
track_ids = albums[cleantitle]['track_ids']
realtitle = albums[cleantitle]['title']
if strategy == 'all':
artists = [a for a in artistoptions]
elif strategy == 'majority':
artists = [a for a in artistoptions if artistoptions[a] >= (len(track_ids) / 2)]
elif strategy == 'most':
artists = [max(artistoptions,key=artistoptions.get)]
for track_id in track_ids:
album_id = get_album_id({'albumtitle':realtitle,'artists':artists})
add_track_to_album(track_id,album_id)
i=countup(i)
print(col['lawngreen']("Done!"))

View File

@ -1,9 +1,7 @@
# technical
import sys
import os
from threading import Thread
from importlib import resources
import datauri
import time
@ -19,9 +17,10 @@ from doreah import auth
# rest of the project
from . import database
from .database.jinjaview import JinjaDBConnection
from .images import resolve_track_image, resolve_artist_image
from .images import image_request
from .malojauri import uri_to_internal, remove_identical
from .pkg_global.conf import malojaconfig, data_dir
from .pkg_global import conf
from .jinjaenv.context import jinja_environment
from .apis import init_apis, apikeystore
@ -120,20 +119,14 @@ def deprecated_api(pth):
@webserver.route("/image")
def dynamic_image():
keys = FormsDict.decode(request.query)
if keys['type'] == 'track':
result = resolve_track_image(keys['id'])
elif keys['type'] == 'artist':
result = resolve_artist_image(keys['id'])
result = image_request(**{k:int(keys[k]) for k in keys})
if result is None or result['value'] in [None,'']:
return ""
if result['type'] == 'raw':
# data uris are directly served as image because a redirect to a data uri
# doesnt work
duri = datauri.DataURI(result['value'])
response.content_type = duri.mimetype
return duri.data
if result['type'] == 'url':
if result['type'] == 'noimage' and result['value'] == 'wait':
# still being worked on
response.status = 202
response.set_header('Retry-After',15)
return
if result['type'] in ('url','localurl'):
redirect(result['value'],307)
@webserver.route("/images/<pth:re:.*\\.jpeg>")
@ -160,6 +153,9 @@ def static_image(pth):
resp.set_header("Content-Type", "image/" + ext)
return resp
@webserver.route("/cacheimages/<uuid>")
def static_proxied_image(uuid):
return static_file(uuid,root=data_dir['cache']('images'))
@webserver.route("/login")
def login():
@ -218,8 +214,8 @@ def jinja_page(name):
res = template.render(**loc_context)
except TemplateNotFound:
abort(404,f"Not found: '{name}'")
except (ValueError, IndexError):
abort(404,"This Artist or Track does not exist")
#except (ValueError, IndexError):
# abort(404,"This Artist or Track does not exist")
if malojaconfig["DEV_MODE"]: jinja_environment.cache.clear()
@ -283,6 +279,8 @@ logging.getLogger().addHandler(WaitressLogHandler())
def run_server():
conf.AUX_MODE = False
log("Starting up Maloja server...")
## start database

View File

@ -1,8 +1,10 @@
import os
from importlib import resources
from distutils import dir_util
try:
from setuptools import distutils
except ImportError:
import distutils
from doreah.io import col, ask, prompt
from doreah import auth
@ -23,7 +25,7 @@ ext_apikeys = [
def copy_initial_local_files():
with resources.files("maloja") / 'data_files' as folder:
for cat in dir_settings:
dir_util.copy_tree(os.path.join(folder,cat),dir_settings[cat],update=False)
distutils.dir_util.copy_tree(os.path.join(folder,cat),dir_settings[cat],update=False)
charset = list(range(10)) + list("abcdefghijklmnopqrstuvwxyz") + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
def randomstring(length=32):

View File

@ -10,6 +10,7 @@ import xml.etree.ElementTree as ElementTree
import json
import urllib.parse, urllib.request
import base64
import time
from doreah.logging import log
from threading import BoundedSemaphore
@ -23,6 +24,14 @@ services = {
"metadata":[]
}
class InvalidResponse(Exception):
"""Invalid Response from Third Party"""
class RateLimitExceeded(Exception):
"""Rate Limit exceeded"""
# have a limited number of worker threads so we don't completely hog the cpu with
# these requests. they are mostly network bound, so python will happily open up 200 new
# requests and then when all the responses come in we suddenly can't load pages anymore
@ -44,26 +53,37 @@ def get_image_track_all(track):
for service in services["metadata"]:
try:
res = service.get_image_track(track)
if res is not None:
if res:
log("Got track image for " + str(track) + " from " + service.name)
return res
else:
log("Could not get track image for " + str(track) + " from " + service.name)
log(f"Could not get track image for {track} from {service.name}")
except Exception as e:
log("Error getting track image from " + service.name + ": " + repr(e))
log(f"Error getting track image from {service.name}: {e.__doc__}")
def get_image_artist_all(artist):
with thirdpartylock:
for service in services["metadata"]:
try:
res = service.get_image_artist(artist)
if res is not None:
if res:
log("Got artist image for " + str(artist) + " from " + service.name)
return res
else:
log("Could not get artist image for " + str(artist) + " from " + service.name)
log(f"Could not get artist image for {artist} from {service.name}")
except Exception as e:
log("Error getting artist image from " + service.name + ": " + repr(e))
log(f"Error getting artist image from {service.name}: {e.__doc__}")
def get_image_album_all(album):
with thirdpartylock:
for service in services["metadata"]:
try:
res = service.get_image_album(album)
if res:
log("Got album image for " + str(album) + " from " + service.name)
return res
else:
log(f"Could not get album image for {album} from {service.name}")
except Exception as e:
log(f"Error getting album image from {service.name}: {e.__doc__}")
class GenericInterface:
@ -177,6 +197,8 @@ class MetadataInterface(GenericInterface,abstract=True):
"activated_setting":None
}
delay = 0
# service provides this role only if the setting is active AND all
# necessary auth settings exist
def active_metadata(self):
@ -200,6 +222,7 @@ class MetadataInterface(GenericInterface,abstract=True):
else:
imgurl = None
if imgurl is not None: imgurl = self.postprocess_url(imgurl)
time.sleep(self.delay)
return imgurl
def get_image_artist(self,artist):
@ -215,6 +238,25 @@ class MetadataInterface(GenericInterface,abstract=True):
else:
imgurl = None
if imgurl is not None: imgurl = self.postprocess_url(imgurl)
time.sleep(self.delay)
return imgurl
def get_image_album(self,album):
artists, title = album
artiststring = urllib.parse.quote(", ".join(artists or []))
titlestring = urllib.parse.quote(title)
response = urllib.request.urlopen(
self.metadata["albumurl"].format(artist=artiststring,title=titlestring,**self.settings)
)
responsedata = response.read()
if self.metadata["response_type"] == "json":
data = json.loads(responsedata)
imgurl = self.metadata_parse_response_album(data)
else:
imgurl = None
if imgurl is not None: imgurl = self.postprocess_url(imgurl)
time.sleep(self.delay)
return imgurl
# default function to parse response by descending down nodes
@ -225,19 +267,30 @@ class MetadataInterface(GenericInterface,abstract=True):
def metadata_parse_response_track(self,data):
return self._parse_response("response_parse_tree_track", data)
def metadata_parse_response_album(self,data):
return self._parse_response("response_parse_tree_album", data)
def _parse_response(self, resp, data):
res = data
for node in self.metadata[resp]:
try:
res = res[node]
except Exception:
return None
handleresult = self.handle_json_result_error(data) #allow the handler to throw custom exceptions
# it can also return True to indicate that this is not an error, but simply an instance of 'this api doesnt have any info'
if handleresult is True:
return None
#throw the generic error if the handler refused to do anything
raise InvalidResponse()
return res
def postprocess_url(self,url):
url = url.replace("http:","https:",1)
return url
def handle_json_result_error(self,result):
raise InvalidResponse()

View File

@ -9,13 +9,17 @@ class AudioDB(MetadataInterface):
}
metadata = {
#"trackurl": "https://theaudiodb.com/api/v1/json/{api_key}/searchtrack.php?s={artist}&t={title}",
#"trackurl": "https://theaudiodb.com/api/v1/json/{api_key}/searchtrack.php?s={artist}&t={title}", #patreon
"artisturl": "https://www.theaudiodb.com/api/v1/json/{api_key}/search.php?s={artist}",
#"albumurl": "https://www.theaudiodb.com/api/v1/json/{api_key}/searchalbum.php?s={artist}&a={title}", #patreon
"response_type":"json",
#"response_parse_tree_track": ["tracks",0,"astrArtistThumb"],
"response_parse_tree_artist": ["artists",0,"strArtistThumb"],
"required_settings": ["api_key"],
}
def get_image_track(self,artist):
def get_image_track(self,track):
return None
def get_image_album(self,album):
return None

View File

@ -1,4 +1,5 @@
from . import MetadataInterface
from . import MetadataInterface, RateLimitExceeded
class Deezer(MetadataInterface):
name = "Deezer"
@ -8,10 +9,31 @@ class Deezer(MetadataInterface):
}
metadata = {
"trackurl": "https://api.deezer.com/search?q={artist}%20{title}",
#"trackurl": "https://api.deezer.com/search?q={artist}%20{title}",
"artisturl": "https://api.deezer.com/search?q={artist}",
"albumurl": "https://api.deezer.com/search?q={artist}%20{title}",
"response_type":"json",
"response_parse_tree_track": ["data",0,"album","cover_medium"],
#"response_parse_tree_track": ["data",0,"album","cover_medium"],
"response_parse_tree_artist": ["data",0,"artist","picture_medium"],
"response_parse_tree_album": ["data",0,"album","cover_medium"],
"required_settings": [],
}
delay = 1
def get_image_track(self,track):
return None
# we can use the album pic from the track search,
# but should do so via maloja logic
def handle_json_result_error(self,result):
if result.get('data') == []:
return True
if result.get('error',{}).get('code',None) == 4:
self.delay += 1
# this is permanent (for the lifetime of the process)
# but that's actually ok
# since hitting the rate limit means we are doing this too fast
# and these requests arent really time sensitive
raise RateLimitExceeded()

View File

@ -22,15 +22,22 @@ class LastFM(MetadataInterface, ProxyScrobbleInterface):
"activated_setting": "SCROBBLE_LASTFM"
}
metadata = {
#"artisturl": "https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist={artist}&api_key={apikey}&format=json"
"trackurl": "https://ws.audioscrobbler.com/2.0/?method=track.getinfo&track={title}&artist={artist}&api_key={apikey}&format=json",
"albumurl": "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={apikey}&artist={artist}&album={title}&format=json",
"response_type":"json",
"response_parse_tree_track": ["track","album","image",-1,"#text"],
# technically just the album artwork, but we use it for now
#"response_parse_tree_artist": ["artist","image",-1,"#text"],
"response_parse_tree_album": ["album","image",-1,"#text"],
"required_settings": ["apikey"],
}
def get_image_artist(self,artist):
return None
# lastfm doesn't provide artist images
# lastfm still provides that endpoint with data,
# but doesn't provide actual images
def proxyscrobble_parse_response(self,data):
return data.attrib.get("status") == "ok" and data.find("scrobbles").attrib.get("ignored") == "0"

View File

@ -26,6 +26,8 @@ class MusicBrainz(MetadataInterface):
return None
# not supported
def get_image_album(self,album):
return None
def get_image_track(self,track):
self.lock.acquire()

View File

@ -14,10 +14,12 @@ class Spotify(MetadataInterface):
}
metadata = {
"trackurl": "https://api.spotify.com/v1/search?q=artist:{artist}%20track:{title}&type=track&access_token={token}",
"artisturl": "https://api.spotify.com/v1/search?q=artist:{artist}&type=artist&access_token={token}",
"trackurl": "https://api.spotify.com/v1/search?q={title}%20artist:{artist}&type=track&access_token={token}",
"albumurl": "https://api.spotify.com/v1/search?q={title}%20artist:{artist}&type=album&access_token={token}",
"artisturl": "https://api.spotify.com/v1/search?q={artist}&type=artist&access_token={token}",
"response_type":"json",
"response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"],
"response_parse_tree_track": ["tracks","items",0,"album","images",0,"url"], # use album art
"response_parse_tree_album": ["albums","items",0,"images",0,"url"],
"response_parse_tree_artist": ["artists","items",0,"images",0,"url"],
"required_settings": ["apiid","secret"],
}
@ -44,7 +46,7 @@ class Spotify(MetadataInterface):
else:
expire = responsedata.get("expires_in",3600)
self.settings["token"] = responsedata["access_token"]
log("Successfully authenticated with Spotify")
#log("Successfully authenticated with Spotify")
Timer(expire,self.authorize).start()
except Exception as e:
log("Error while authenticating with Spotify: " + repr(e))

View File

@ -11,6 +11,9 @@ from .pkg_global.conf import data_dir, dir_settings
from .apis import _apikeys
from .database.sqldb import get_maloja_info, set_maloja_info
# Dealing with old style tsv files - these should be phased out everywhere
def read_tsvs(path,types):
result = []
@ -40,7 +43,7 @@ def upgrade_apikeys():
except Exception:
pass
# v2 to v3 iupgrade
def upgrade_db(callback_add_scrobbles):
oldfolder = os.path.join(dir_settings['state'],"scrobbles")
@ -88,3 +91,13 @@ def upgrade_db(callback_add_scrobbles):
callback_add_scrobbles(scrobblelist)
os.rename(os.path.join(oldfolder,sf),os.path.join(newfolder,sf))
log("Done!",color='yellow')
# 3.2 album support
def parse_old_albums():
setting_name = "db_upgrade_albums"
if get_maloja_info([setting_name]).get(setting_name):
pass
else:
pass
#set_maloja_info({setting_name:True})

View File

@ -21,7 +21,8 @@
['setup','Server Setup'],
['settings','Settings'],
['apikeys','API Keys'],
['manual','Manual Scrobbling']
['manual','Manual Scrobbling'],
['albumless','Tracks without Albums']
] %}
{# ['import','Scrobble Import'],

View File

@ -23,13 +23,24 @@
<script src="/neopolitan.js"></script>
<script src="/upload.js"></script>
<script src="/notifications.js"></script>
<script src="/edit.js"></script>
<script>
const defaultpicks = {
topartists: '{{ settings["DEFAULT_RANGE_STARTPAGE"] }}',
toptracks: '{{ settings["DEFAULT_RANGE_STARTPAGE"] }}',
topalbums: '{{ settings["DEFAULT_RANGE_STARTPAGE"] }}',
pulse: '{{ settings["DEFAULT_RANGE_STARTPAGE"] }}',
pulseperformancecombined: '{{ settings["DEFAULT_RANGE_STARTPAGE"] }}',
featured: 'artist'
}
</script>
<link rel="preload" href="/static/ttf/Ubuntu-Regular.ttf" as="font" type="font/woff2" crossorigin />
{% block scripts %}{% endblock %}
</head>
<body>
<body class="{% block custombodyclasses %}{% endblock %}">
{% block content %}
@ -56,22 +67,17 @@
<div id="footer">
<div id="left-side">
<a href="/about">About</a>
</div>
<div id="notch">
<a href="/"><img style="display:block;" src="/favicon.png" /></a>
</div>
<div id="right-side">
<span><input id="searchinput" placeholder="Search for an artist or track..." oninput="search(this)" onblur="clearresults()" /></span>
</div>
<div class="footer">
<div>
<!--<span>Get your own charts on
<a target="_blank" rel="noopener noreferrer" href="https://github.com/krateng/maloja">GitHub</a>,
<a target="_blank" rel="noopener noreferrer" href="https://pypi.org/project/malojaserver/">PyPI</a> or
<a target="_blank" rel="noopener noreferrer" href="https://hub.docker.com/r/krateng/maloja">Dockerhub</a>
</span>-->
<span><a href="/about">About</a></span>
</div>
<div>
<a href="/"><span style="font-weight:bold;">Maloja {% if settings["DEV_MODE"] %}[Developer Mode]{% endif %}</span></a>
</div>
<div>
<span><input id="searchinput" placeholder="Search for an artist or track..." oninput="search(this)" onblur="clearresults()" /></span>
</div>
<div id="resultwrap" class="hide">
<div class="searchresults">
@ -80,7 +86,11 @@
</table>
<br/><br/>
<span>Tracks</span>
<table class="searchresults_tracks" id="searchresults_tracks">
<table class="searchresults_tracks searchresults_extrainfo" id="searchresults_tracks">
</table>
<br/><br/>
<span>Albums</span>
<table class="searchresults_albums searchresults_extrainfo" id="searchresults_albums">
</table>
</div>
</div>

View File

@ -0,0 +1,12 @@
{% set page ='admin_albumless' %}
{% extends "abstracts/admin.jinja" %}
{% block title %}Maloja - Albumless Tracks{% endblock %}
{% block maincontent %}
Here you can find tracks that currently have no album.<br/><br/>
{% with list = dbc.get_tracks_without_album() %}
{% include 'partials/list_tracks.jinja' %}
{% endwith %}
{% endblock %}

View File

@ -4,6 +4,14 @@
{% block scripts %}
<script src="/manualscrobble.js"></script>
<style>
.tooltip {
cursor: help;
}
.tooltip:hover {
text-decoration: underline dotted;
}
</style>
{% endblock %}
@ -26,14 +34,49 @@
<input placeholder='Enter to scrobble' class='simpleinput' id='title' onKeydown='scrobbleIfEnter(event)' />
</td>
</tr>
<tr>
<td style="padding-right:7px;">
Album artists (Optional):
</td><td id="albumartists_td">
<input placeholder='Separate with Enter' class='simpleinput' id='albumartists' onKeydown='keyDetect2(event)' onblur='addEnteredAlbumartist()' />
</td>
</tr>
<tr>
<td style="padding-right:7px;">
Album (Optional):
</td><td>
<input placeholder='Enter to scrobble' class='simpleinput' id='album' onKeydown='scrobbleIfEnter(event)' />
</td>
</tr>
<tr>
<td>
<input type="checkbox" id="use_custom_time" />
Custom Time:
</td>
<td>
<input id="scrobble_datetime" type="datetime-local">
</td>
</tr>
</table>
<script>
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById("scrobble_datetime").value = localDateTime;
</script>
<br/>
<input type="checkbox" id="use_track_artists_for_album" checked='true' />
<span class="tooltip" title="If this is unchecked, specifying no album artists will result in a compilation album ('Various Artists')">Use track artists as album artists fallback</span>
<br/><br/>
<button type="button" onclick="scrobbleNew(event)">Scrobble!</button>
<button type="button" onclick="repeatLast()">↻</button>
<br/>

View File

@ -0,0 +1,144 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - {{ info.album.albumtitle }}{% endblock %}
{% import 'snippets/links.jinja' as links %}
{% block scripts %}
<script src="/statselect.js"></script>
{% endblock %}
{% set album = filterkeys.album %}
{% set info = dbc.album_info({'album':album}) %}
{% set initialrange ='month' %}
{% set encodedalbum = mlj_uri.uriencode({'album':album}) %}
{% block custombodyclasses %}
{% if info.certification %}certified certified_{{ info.certification }}{% endif %}
{% endblock %}
{% block icon_bar %}
{% if adminmode %}
{% include 'icons/edit.jinja' %}
<div class="iconsubset mergeicons" data-entity_type="album" data-entity_id="{{ info.id }}" data-entity_name="{{ info.album.albumtitle }}">
{% include 'icons/merge.jinja' %}
{% include 'icons/merge_mark.jinja' %}
{% include 'icons/merge_unmark.jinja' %}
{% include 'icons/merge_cancel.jinja' %}
</div>
<div class="iconsubset associateicons" data-entity_type="album" data-entity_id="{{ info.id }}" data-entity_name="{{ info.album.albumtitle }}">
{% include 'icons/add_album.jinja' %}
<!-- no remove album since that is not a specified association - every track only has one album, so the removal should
be handled on the track page (or for now, not at all) -->
{% include 'icons/association_mark.jinja' %}
{% include 'icons/association_unmark.jinja' %}
{% include 'icons/association_cancel.jinja' %}
</div>
{% endif %}
{% endblock %}
{% block content %}
<script>
const entity_id = {{ info.id }};
const entity_type = 'album';
const entity_name = {{ album.albumtitle | tojson }};
</script>
{% import 'partials/awards_album.jinja' as awards %}
{% include 'partials/info_album.jinja' %}
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>
{% with amountkeys={"perpage":16,"page":0} %}
{% include 'partials/charts_tracks.jinja' %}
{% endwith %}
<br/>
<table class="twopart">
<tr>
<td>
<h2 class="headerwithextra"><a href='{{ mlj_uri.create_uri("/pulse",filterkeys) }}'>Pulse</a></h2>
<br/>
{% for r in xranges %}
<span
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
{% if not loop.last %}|{% endif %}
{% endfor %}
<br/><br/>
{% for r in xranges %}
<span
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
{% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %}
{% include 'partials/pulse.jinja' %}
{% endwith %}
</span>
{% endfor %}
</td>
<td>
<!-- We use the same classes / function calls here because we want it to switch together with pulse -->
<h2 class="headerwithextra"><a href='{{ mlj_uri.create_uri("/performance",filterkeys) }}'>Performance</a></h2>
<br/>
{% for r in xranges %}
<span
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
{% if not loop.last %}|{% endif %}
{% endfor %}
<br/><br/>
{% for r in xranges %}
<span
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
{% with limitkeys={"since":r.firstrange},delimitkeys={'step':r.identifier,'trail':1} %}
{% include 'partials/performance.jinja' %}
{% endwith %}
</span>
{% endfor %}
</td>
</tr>
</table>
<h2><a href='{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}'>Last Scrobbles</a></h2>
{% with amountkeys = {"perpage":16,"page":0} %}
{% include 'partials/scrobbles.jinja' %}
{% endwith %}
{% endblock %}

View File

@ -5,12 +5,11 @@
{% import 'partials/awards_artist.jinja' as awards %}
{% block scripts %}
<script src="/rangeselect.js"></script>
<script src="/edit.js"></script>
<script src="/statselect.js"></script>
{% endblock %}
{% set artist = filterkeys.artist %}
{% set info = db.artist_info(artist=artist) %}
{% set info = dbc.artist_info({'artist':artist}) %}
{% set credited = info.get('replace') %}
{% set included = info.get('associated') %}
@ -27,13 +26,27 @@
{% set encodedartist = mlj_uri.uriencode({'artist':artist}) %}
{% block custombodyclasses %}
{% if info.certification %}certified certified_{{ info.certification }}{% endif %}
{% endblock %}
{% block icon_bar %}
{% if adminmode %}
{% include 'icons/edit.jinja' %}
<div class="iconsubset mergeicons" data-entity_type="artist" data-entity_id="{{ info.id }}" data-entity_name="{{ info.artist }}">
{% include 'icons/merge.jinja' %}
{% include 'icons/merge_mark.jinja' %}
{% include 'icons/merge_unmark.jinja' %}
{% include 'icons/merge_cancel.jinja' %}
<script>showValidMergeIcons();</script>
</div>
<div class="iconsubset associateicons" data-entity_type="artist" data-entity_id="{{ info.id }}" data-entity_name="{{ info.artist }}">
{% include 'icons/add_artist.jinja' %}
{% include 'icons/remove_artist.jinja' %}
{% include 'icons/association_cancel.jinja' %}
</div>
{% endif %}
{% endblock %}
@ -47,56 +60,36 @@
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="changeable-image" data-uploader="b64=>upload('{{ encodedartist }}',b64)"
style="background-image:url('{{ images.get_artist_image(artist) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div style="background-image:url('{{ images.get_artist_image(artist) }}');">
</div>
{% endif %}
</td>
<td class="text">
<h1 id="main_entity_name" class="headerwithextra">{{ info.artist | e }}</h1>
{% if competes %}<span class="rank"><a href="/charts_artists?max=100">#{{ info.position }}</a></span>{% endif %}
<br/>
{% if competes and included %}
<span>associated: {{ links.links(included) }}</span>
{% elif not competes %}
<span>Competing under {{ links.link(credited) }} (#{{ info.position }})</span>
{% endif %}
<p class="stats">
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{% include 'partials/info_artist.jinja' %}
{% set albums_info = dbc.get_albums_artist_appears_on(filterkeys,limitkeys) %}
{% set ownalbums = albums_info.own_albums %}
{% set otheralbums = albums_info.appears_on %}
{% if ownalbums or otheralbums %}
{% if competes %}
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
{% endif %}
{{ awards.certs(artist) }}
{% if settings['ALBUM_SHOWCASE'] %}
<h2><a href='{{ mlj_uri.create_uri("/charts_albums",filterkeys) }}'>Albums</a></h2>
{% include 'partials/album_showcase.jinja' %}
{% else %}
<h2><a href='{{ mlj_uri.create_uri("/charts_albums",filterkeys) }}'>Top Albums</a></h2>
{% with amountkeys={"perpage":16,"page":0} %}
{% include 'partials/charts_albums.jinja' %}
{% endwith %}
{% endif %}
</td>
</tr>
</table>
{% endif %}
{% if info['scrobbles']>0 %}
<h2><a href='{{ mlj_uri.create_uri("/charts_tracks",filterkeys) }}'>Top Tracks</a></h2>
{% with amountkeys={"perpage":15,"page":0} %}
{% with amountkeys={"perpage":16,"page":0} %}
{% include 'partials/charts_tracks.jinja' %}
{% endwith %}
<br/>
<table class="twopart">
@ -107,8 +100,8 @@
<br/>
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
@ -120,7 +113,7 @@
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
@ -140,8 +133,8 @@
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
@ -153,7 +146,7 @@
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
@ -171,8 +164,9 @@
<h2><a href='{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}'>Last Scrobbles</a></h2>
{% with amountkeys = {"perpage":15,"page":0} %}
{% with amountkeys = {"perpage":16,"page":0} %}
{% include 'partials/scrobbles.jinja' %}
{% endwith %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Album Charts{% endblock %}
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% block scripts %}
<script src="/datechange.js" async></script>
{% endblock %}
{% set charts = dbc.get_charts_albums(filterkeys,limitkeys,{'only_own_albums':False}) %}
{% set pages = math.ceil(charts.__len__() / amountkeys.perpage) %}
{% if charts[0] is defined %}
{% set topalbum = charts[0].album %}
{% set img = images.get_album_image(topalbum) %}
{% else %}
{% set img = "/favicon.png" %}
{% endif %}
{% block content %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('{{ img }}')"></div>
</td>
<td class="text">
<h1>Album Charts</h1><a href="/top_albums"><span>View #1 Albums</span></a><br/>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% with delimitkeys = {} %}
{% include 'snippets/timeselection.jinja' %}
{% endwith %}
</td>
</tr>
</table>
{% if settings['CHARTS_DISPLAY_TILES'] %}
{% include 'partials/charts_albums_tiles.jinja' %}
<br/><br/>
{% endif %}
{% with compare=true %}
{% include 'partials/charts_albums.jinja' %}
{% endwith %}
{% import 'snippets/pagination.jinja' as pagination %}
{{ pagination.pagination(filterkeys,limitkeys,delimitkeys,amountkeys,pages) }}
{% endblock %}

View File

@ -1,11 +1,16 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - Artist Charts{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% block scripts %}
<script src="/datechange.js" async></script>
{% endblock %}
{% set charts = dbc.get_charts_artists(filterkeys,limitkeys) %}
{% set charts = dbc.get_charts_artists(filterkeys,limitkeys,specialkeys) %}
{% set pages = math.ceil(charts.__len__() / amountkeys.perpage) %}
{% if charts[0] is defined %}
{% set topartist = charts[0].artist %}
@ -25,9 +30,9 @@
</td>
<td class="text">
<h1>Artist Charts</h1><a href="/top_artists"><span>View #1 Artists</span></a><br/>
<span>{{ limitkeys.timerange.desc(prefix=True) }}</span>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% with delimitkeys = {} %}
{% with delimitkeys = {}, artistchart=True %}
{% include 'snippets/timeselection.jinja' %}
{% endwith %}

View File

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

View File

@ -0,0 +1,6 @@
<div title="Add to Album" id="associatealbumicon" class="clickable_icon" onclick="associate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M2 4.75C2 3.784 2.784 3 3.75 3h4.971a1.75 1.75 0 0 1 1.447.765l1.404 2.063a.25.25 0 0 0 .207.11h8.471c.966 0 1.75.783 1.75 1.75V19.25A1.75 1.75 0 0 1 20.25 21H4.75a.75.75 0 0 1 0-1.5h15.5a.25.25 0 0 0 .25-.25V7.688a.25.25 0 0 0-.25-.25h-8.471a1.751 1.751 0 0 1-1.447-.766L8.928 4.609a.252.252 0 0 0-.207-.109H3.75a.25.25 0 0 0-.25.25v3.5a.75.75 0 0 1-1.5 0v-3.5Z"></path>
<path d="m9.308 12.5-2.104-2.236a.75.75 0 1 1 1.092-1.028l3.294 3.5a.75.75 0 0 1 0 1.028l-3.294 3.5a.75.75 0 1 1-1.092-1.028L9.308 14H4.09a2.59 2.59 0 0 0-2.59 2.59v3.16a.75.75 0 0 1-1.5 0v-3.16a4.09 4.09 0 0 1 4.09-4.09h5.218Z"></path>
</svg>
</div>

View File

@ -0,0 +1,5 @@
<div title="Add Track to this Album" id="addalbumconfirmicon" class="clickable_icon" onclick="addAlbum()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M2 4.75C2 3.784 2.784 3 3.75 3h4.971c.58 0 1.12.286 1.447.765l1.404 2.063c.046.069.124.11.207.11h8.471c.966 0 1.75.783 1.75 1.75V19.25A1.75 1.75 0 0 1 20.25 21H3.75A1.75 1.75 0 0 1 2 19.25Z"></path>
</svg>
</div>

View File

@ -0,0 +1,5 @@
<div title="Add Artist" id="associateartisticon" class="clickable_icon" onclick="associate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M4 9.5a5 5 0 1 1 7.916 4.062 7.973 7.973 0 0 1 5.018 7.166.75.75 0 1 1-1.499.044 6.469 6.469 0 0 0-12.932 0 .75.75 0 0 1-1.499-.044 7.972 7.972 0 0 1 5.059-7.181A4.994 4.994 0 0 1 4 9.5ZM9 6a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Zm10.25-5a.75.75 0 0 1 .75.75V4h2.25a.75.75 0 0 1 0 1.5H20v2.25a.75.75 0 0 1-1.5 0V5.5h-2.25a.75.75 0 0 1 0-1.5h2.25V1.75a.75.75 0 0 1 .75-.75Z"></path>
</svg>
</div>

View File

@ -0,0 +1,5 @@
<div title="Add this Artist to Track" id="addartistconfirmicon" class="clickable_icon" onclick="addArtist()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M12 2.5a5.25 5.25 0 0 0-2.519 9.857 9.005 9.005 0 0 0-6.477 8.37.75.75 0 0 0 .727.773H20.27a.75.75 0 0 0 .727-.772 9.005 9.005 0 0 0-6.477-8.37A5.25 5.25 0 0 0 12 2.5Z"></path>
</svg>
</div>

View File

@ -0,0 +1,6 @@
<div title="Cancel associating" class="associatecancelicon clickable_icon" onclick="cancelAssociate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 1c6.075 0 11 4.925 11 11s-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1ZM5.834 19.227A9.464 9.464 0 0 0 12 21.5a9.5 9.5 0 0 0 9.5-9.5 9.464 9.464 0 0 0-2.273-6.166ZM2.5 12a9.464 9.464 0 0 0 2.273 6.166L18.166 4.773A9.463 9.463 0 0 0 12 2.5 9.5 9.5 0 0 0 2.5 12Z">
</path>
</svg>
</div>

View File

@ -0,0 +1,5 @@
<div title="Mark for association" class="associationmarkicon clickable_icon" onclick="markForAssociate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="m16.114 1.553 6.333 6.333a1.75 1.75 0 0 1-.603 2.869l-1.63.633a5.67 5.67 0 0 0-3.395 3.725l-1.131 3.959a1.75 1.75 0 0 1-2.92.757L9 16.061l-5.595 5.594a.749.749 0 1 1-1.06-1.06L7.939 15l-3.768-3.768a1.75 1.75 0 0 1 .757-2.92l3.959-1.131a5.666 5.666 0 0 0 3.725-3.395l.633-1.63a1.75 1.75 0 0 1 2.869-.603ZM5.232 10.171l8.597 8.597a.25.25 0 0 0 .417-.108l1.131-3.959A7.17 7.17 0 0 1 19.67 9.99l1.63-.634a.25.25 0 0 0 .086-.409l-6.333-6.333a.25.25 0 0 0-.409.086l-.634 1.63a7.17 7.17 0 0 1-4.711 4.293L5.34 9.754a.25.25 0 0 0-.108.417Z"></path>
</svg>
</div>

View File

@ -0,0 +1,7 @@
<div title="Unmark for Association"class="associationunmarkicon clickable_icon" onclick="umarkForAssociate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M2.345 20.595 8.47 14.47q.219-.22.53-.22.311 0 .53.22.22.219.22.53 0 .311-.22.53l-6.125 6.125q-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53Z"></path>
<path d="m16.72 11.97.358-.358a6.738 6.738 0 0 1 2.326-1.518l1.896-.738a.25.25 0 0 0 .086-.409l-6.333-6.333a.25.25 0 0 0-.409.086l-.521 1.34a8.663 8.663 0 0 1-2.243 3.265.75.75 0 0 1-1.01-1.11 7.132 7.132 0 0 0 1.854-2.699l.521-1.34a1.75 1.75 0 0 1 2.869-.603l6.333 6.333a1.75 1.75 0 0 1-.603 2.869l-1.896.737a5.26 5.26 0 0 0-1.81 1.18l-.358.358a.749.749 0 1 1-1.06-1.06Zm-12.549-.738a1.75 1.75 0 0 1 .757-2.92l3.366-.962.412 1.443-3.366.961a.25.25 0 0 0-.108.417l8.597 8.597a.25.25 0 0 0 .417-.108l.961-3.366 1.443.412-.962 3.366a1.75 1.75 0 0 1-2.92.757Z"></path>
<path d="m3.405 2.095 18.75 18.75q.22.219.22.53 0 .311-.22.53-.219.22-.53.22-.311 0-.53-.22L2.345 3.155q-.22-.219-.22-.53 0-.311.22-.53.219-.22.53-.22.311 0 .53.22Z"></path>
</svg>
</div>

View File

@ -0,0 +1,24 @@
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="15" weight="15" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 511.996 511.996" xml:space="preserve">
<g>
<g>
<g>
<path d="M0,42.667v426.667c0,11.776,9.536,21.333,21.333,21.333h64V21.333h-64C9.536,21.333,0,30.891,0,42.667z"/>
<path d="M234.667,363.855c0,11.115,9.557,20.139,21.333,20.139c11.563,0,21.333-9.771,21.333-21.333v-21.333H256
C244.843,341.327,234.667,352.058,234.667,363.855z"/>
<path d="M490.662,21.329H127.996v469.333h362.667c11.797,0,21.333-9.557,21.333-21.333V42.662
C511.996,30.886,502.46,21.329,490.662,21.329z M400.188,250.428l-6.379,20.16c-2.88,9.088-11.264,14.912-20.331,14.912
c-2.133,0-4.288-0.32-6.443-1.003c-11.221-3.541-17.451-15.531-13.888-26.773l6.485-20.48
c6.357-19.157,2.347-40.107-10.432-55.253l-29.205-26.816v164.821v42.667c0,35.307-28.693,64-64,64
c-35.285,0-64-28.181-64-62.805c0-35.328,29.312-65.195,64-65.195h21.333v-192c0-1.067,0.469-1.984,0.619-3.008
c0.213-1.6,0.341-3.179,0.939-4.693c0.576-1.493,1.536-2.709,2.411-4.011c0.597-0.875,0.896-1.899,1.643-2.709
c0.107-0.107,0.256-0.149,0.363-0.256c1.109-1.152,2.496-1.92,3.84-2.795c1.003-0.683,1.899-1.557,2.987-2.027
c0.896-0.384,1.92-0.427,2.88-0.683c1.728-0.491,3.456-1.024,5.248-1.067c0.149,0,0.256-0.085,0.405-0.085
c1.024,0,1.92,0.448,2.901,0.597c1.643,0.213,3.243,0.363,4.8,0.96c1.536,0.597,2.773,1.579,4.117,2.475
c0.853,0.597,1.813,0.875,2.603,1.579l65.899,60.459c0.576,0.512,1.131,1.088,1.643,1.664
C403.878,179.644,411.366,216.934,400.188,250.428z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,26 @@
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="15" weight="15" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512.002 512.002" xml:space="preserve">
<g>
<g>
<g>
<path d="M255.997,0c-34.112,0-66.645,6.848-96.448,19.008l84.053,152.917c4.075-0.597,8.149-1.259,12.395-1.259
c47.083,0,85.333,38.293,85.333,85.333c0,5.632-0.597,11.136-1.664,16.469L480.103,379.52
c20.288-36.651,31.893-78.741,31.893-123.52C511.997,114.837,397.181,0,255.997,0z"/>
<path d="M316.383,316.307c-2.603,2.624-5.376,5.056-8.299,7.275l82.347,149.867c25.173-15.616,47.467-35.349,65.835-58.432
L320.714,311.678C319.327,313.257,317.876,314.814,316.383,316.307z"/>
<path d="M256.005,341.335c-47.061,0-85.333-38.272-85.333-85.333c0-5.291,0.64-10.432,1.557-15.467L26.565,143.191
C9.733,177.282,0.005,215.49,0.005,256.002c0,141.163,114.837,256,256,256c34.155,0,66.688-6.848,96.491-19.029l-84.011-152.896
C264.389,340.695,260.272,341.335,256.005,341.335z"/>
<path d="M271.602,295.62c2.432-0.939,4.672-2.155,6.848-3.477c0.363-0.235,0.768-0.384,1.131-0.619
c1.813-1.195,3.435-2.645,5.035-4.096c0.576-0.533,1.216-0.981,1.771-1.536c3.605-3.648,6.464-8.043,8.747-12.971
c2.261-5.184,3.541-10.901,3.541-16.917c0-23.531-19.136-42.667-42.667-42.667c-5.504,0-10.709,1.131-15.552,3.029
c-2.645,1.045-5.141,2.347-7.488,3.84c-0.107,0.064-0.256,0.107-0.363,0.192c-2.432,1.579-4.693,3.392-6.72,5.44
c-3.947,3.947-7.04,8.555-9.216,13.781c0,0.043-0.043,0.064-0.064,0.107c-2.091,5.013-3.264,10.496-3.264,16.277
c0,23.531,19.136,42.667,42.667,42.667C261.533,298.671,266.759,297.54,271.602,295.62z"/>
<path d="M195.581,195.834c0.085-0.107,0.192-0.192,0.277-0.277c2.56-2.56,5.269-4.949,8.128-7.147L121.597,38.543
c-28.565,17.728-53.376,40.853-73.024,68.032l141.888,94.827C192.082,199.482,193.789,197.626,195.581,195.834z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,5 @@
<div class='disassociateicon clickable_icon danger' onclick="disassociate(this)" title="Disassociate artists">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20.347 3.653a3.936 3.936 0 0 0-5.567 0l-1.75 1.75a.75.75 0 0 1-1.06-1.06l1.75-1.75a5.436 5.436 0 0 1 7.688 7.687l-1.564 1.564a.75.75 0 0 1-1.06-1.06l1.563-1.564a3.936 3.936 0 0 0 0-5.567ZM9.786 12.369a.75.75 0 0 1 1.053.125c.096.122.2.24.314.353 1.348 1.348 3.386 1.587 4.89.658l-3.922-2.858a.745.745 0 0 1-.057-.037c-1.419-1.013-3.454-.787-4.784.543L3.653 14.78a3.936 3.936 0 0 0 5.567 5.567l3-3a.75.75 0 1 1 1.06 1.06l-3 3a5.436 5.436 0 1 1-7.688-7.687l3.628-3.628a5.517 5.517 0 0 1 3.014-1.547l-7.05-5.136a.75.75 0 0 1 .883-1.213l20.25 14.75a.75.75 0 0 1-.884 1.213l-5.109-3.722c-2.155 1.709-5.278 1.425-7.232-.53a5.491 5.491 0 0 1-.431-.485.75.75 0 0 1 .125-1.053Z"></path>
</svg>
</div>

View File

@ -1,4 +1,4 @@
<div title="Merge" id="mergeicon" class="clickable_icon hide" onclick="merge()">
<div title="Merge" id="mergeicon" class="clickable_icon" onclick="merge(this)">
<svg viewBox="0 0 16 16" width="24" height="24">
<path fill-rule="evenodd" d="M5 3.254V3.25v.005a.75.75 0 110-.005v.004zm.45 1.9a2.25 2.25 0 10-1.95.218v5.256a2.25 2.25 0 101.5 0V7.123A5.735 5.735 0 009.25 9h1.378a2.251 2.251 0 100-1.5H9.25a4.25 4.25 0 01-3.8-2.346zM12.75 9a.75.75 0 100-1.5.75.75 0 000 1.5zm-8.5 4.5a.75.75 0 100-1.5.75.75 0 000 1.5z"></path>
</svg>

View File

@ -1,5 +1,6 @@
<div title="Cancel merge" id="mergecancelicon" class="clickable_icon hide" onclick="cancelMerge()">
<svg viewBox="0 0 16 16" width="24" height="24">
<path fill-rule="evenodd" d="M10.72 1.227a.75.75 0 011.06 0l.97.97.97-.97a.75.75 0 111.06 1.061l-.97.97.97.97a.75.75 0 01-1.06 1.06l-.97-.97-.97.97a.75.75 0 11-1.06-1.06l.97-.97-.97-.97a.75.75 0 010-1.06zM12.75 6.5a.75.75 0 00-.75.75v3.378a2.251 2.251 0 101.5 0V7.25a.75.75 0 00-.75-.75zm0 5.5a.75.75 0 100 1.5.75.75 0 000-1.5zM2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5z"></path>
<div title="Cancel merging" id="mergecancelicon" class="clickable_icon" onclick="cancelMerge(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 1c6.075 0 11 4.925 11 11s-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1ZM5.834 19.227A9.464 9.464 0 0 0 12 21.5a9.5 9.5 0 0 0 9.5-9.5 9.464 9.464 0 0 0-2.273-6.166ZM2.5 12a9.464 9.464 0 0 0 2.273 6.166L18.166 4.773A9.463 9.463 0 0 0 12 2.5 9.5 9.5 0 0 0 2.5 12Z">
</path>
</svg>
</div>

View File

@ -1,4 +1,4 @@
<div title="Mark for merging" id="mergemarkicon" class="clickable_icon hide" onclick="markForMerge()">
<div title="Mark for merging" id="mergemarkicon" class="clickable_icon" onclick="markForMerge(this)">
<svg viewBox="0 0 16 16" width="24" height="24">
<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"></path>
</svg>

View File

@ -0,0 +1,5 @@
<div title="Unmark from merge" id="mergeunmarkicon" class="clickable_icon" onclick="unmarkForMerge(this)">
<svg viewBox="0 0 16 16" width="24" height="24">
<path fill-rule="evenodd" d="M10.72 1.227a.75.75 0 011.06 0l.97.97.97-.97a.75.75 0 111.06 1.061l-.97.97.97.97a.75.75 0 01-1.06 1.06l-.97-.97-.97.97a.75.75 0 11-1.06-1.06l.97-.97-.97-.97a.75.75 0 010-1.06zM12.75 6.5a.75.75 0 00-.75.75v3.378a2.251 2.251 0 101.5 0V7.25a.75.75 0 00-.75-.75zm0 5.5a.75.75 0 100 1.5.75.75 0 000-1.5zM2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5z"></path>
</svg>
</div>

View File

@ -1,7 +1,7 @@
<td style="opacity:0.5;text-align:center;">
<div class="tile" style="opacity:0.5;text-align:center;">
<svg height="96px" viewBox="0 0 24 24" width="96px">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M4.27 3L3 4.27l9 9v.28c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4v-1.73L19.73 21 21 19.73 4.27 3zM14 7h4V3h-6v5.18l2 2z"/>
</svg>
<br/>No scrobbles yet!
</td>
</div>

View File

@ -0,0 +1,6 @@
<div title="Remove from Album" id="removealbumicon" class="clickable_icon" onclick="removeAssociate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M2 4.75C2 3.784 2.784 3 3.75 3h4.971a1.75 1.75 0 0 1 1.447.765l1.404 2.063a.25.25 0 0 0 .207.11h8.471c.966 0 1.75.783 1.75 1.75V19.25A1.75 1.75 0 0 1 20.25 21H4.75a.75.75 0 0 1 0-1.5h15.5a.25.25 0 0 0 .25-.25V7.688a.25.25 0 0 0-.25-.25h-8.471a1.751 1.751 0 0 1-1.447-.766L8.928 4.609a.252.252 0 0 0-.207-.109H3.75a.25.25 0 0 0-.25.25v3.5a.75.75 0 0 1-1.5 0v-3.5Z"></path>
<path d="m 9.308 12.5 a 1 0.8 0 0 1 0 1.5 H 4.09 a 1 0.8 0 0 1 0 -1.5 h 5.218 Z"></path>
</svg>
</div>

View File

@ -0,0 +1,5 @@
<div title="Remove Artist" id="removeartisticon" class="clickable_icon" onclick="removeAssociate(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M 4 9.5 a 5 5 0 1 1 7.916 4.062 a 7.973 7.973 0 0 1 5.018 7.166 a 0.75 0.75 0 1 1 -1.499 0.044 a 6.469 6.469 0 0 0 -12.932 0 a 0.75 0.75 0 0 1 -1.499 -0.044 a 7.972 7.972 0 0 1 5.059 -7.181 A 4.994 4.994 0 0 1 4 9.5 Z M 9 6 a 3.5 3.5 0 1 0 0 7 a 3.5 3.5 0 0 0 0 -7 Z M 20 4 h 2.25 a 0.75 0.75 0 0 1 0 1.5 H 20 h -1.5 h -2.25 a 0.75 0.75 0 0 1 0 -1.5 h 2.25 h 0.75 Z"></path>
</svg>
</div>

View File

@ -0,0 +1,56 @@
{% import 'snippets/links.jinja' as links %}
<div id="showcase_container">
{% for entry in dbc.get_charts_albums(filterkeys,limitkeys,{'only_own_albums':True}) %}
{%- set cert = None -%}
{%- if entry.scrobbles >= settings.scrobbles_gold_album -%}{% set cert = 'gold' %}{%- endif -%}
{%- if entry.scrobbles >= settings.scrobbles_platinum_album -%}{% set cert = 'platinum' %}{%- endif -%}
{%- if entry.scrobbles >= settings.scrobbles_diamond_album -%}{% set cert = 'diamond' %}{%- endif -%}
<table class="album">
<tr><td>&nbsp</td></tr>
<tr><td>
<a href="{{ links.url(entry.album) }}">
<div class="shiny alwaysshiny certified_{{ cert }} lazy" data-bg="{{ images.get_album_image(entry.album) }}"'></div>
</a>
</td></tr>
<tr><td>
<span class="album_artists">{{ links.links(entry.album.artists) }}</span><br/>
<span class="album_title">{{ links.link(entry.album) }}</span>
</td></tr>
</table>
{% endfor %}
{% for entry in dbc.get_charts_albums(filterkeys,limitkeys,{'only_own_albums':False}) %}
{% if artist not in (entry.album.artists or []) %}
{%- set cert = None -%}
{%- if entry.scrobbles >= settings.scrobbles_gold_album -%}{% set cert = 'gold' %}{%- endif -%}
{%- if entry.scrobbles >= settings.scrobbles_platinum_album -%}{% set cert = 'platinum' %}{%- endif -%}
{%- if entry.scrobbles >= settings.scrobbles_diamond_album -%}{% set cert = 'diamond' %}{%- endif -%}
<table class="album">
<tr><td>Appears on</td></tr>
<tr><td>
<a href="{{ links.url(entry.album) }}">
<div class="shiny alwaysshiny certified_{{ cert }} lazy" data-bg="{{ images.get_album_image(entry.album) }}"'></div>
</a>
</td></tr>
<tr><td>
<span class="album_artists">{{ links.links(entry.album.artists) }}</span><br/>
<span class="album_title">{{ links.link(entry.album) }}</span>
</td></tr>
</table>
{% endif %}
{% endfor %}
</div>

View File

@ -0,0 +1,73 @@
{% import 'snippets/links.jinja' as links %}
{% macro medals(info) %}
<!-- MEDALS -->
{% for year in info.medals.gold -%}
<a title="Best Album in {{ year }}" class="hidelink medal shiny hovershiny gold" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.silver -%}
<a title="Second best Album in {{ year }}" class="hidelink medal shiny hovershiny silver" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.bronze -%}
<a title="Third best Album in {{ year }}" class="hidelink medal shiny hovershiny bronze" href='/charts_albums?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% if info.medals.gold or info.medals.silver or info.medals.bronze %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}
{% macro topweeks(info) %}
{% set encodedalbum = mlj_uri.uriencode({'album':info.album}) %}
<!-- TOPWEEKS -->
<span>
{% if info.topweeks > 0 -%}
<a title="{{ info.topweeks }} weeks on #1" href="/performance?{{ encodedalbum }}&step=week">
<img class="star" src="/media/star.png" />{{ info.topweeks }}</a>
{%- endif %}
</span>
{% if info.topweeks > 0 %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}
{% macro subcerts(album) %}
<!-- SUBCERTS -->
{% set charts = dbc.get_charts_tracks({'album':album.album,'timerange':malojatime.alltime()}) %}
{% for e in charts -%}
{%- if e.scrobbles >= settings.scrobbles_gold -%}{% set cert = 'gold' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_platinum -%}{% set cert = 'platinum' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%}
{%- if cert -%}
<a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title }} has reached {{ cert.capitalize() }} status">
{% include 'icons/cert_track.jinja' %}
</a>
{%- endif %}
{%- endfor %}
{%- endmacro %}

View File

@ -5,21 +5,25 @@
<!-- MEDALS -->
{% for year in info.medals.gold -%}
<a title="Best Artist in {{ year }}" class="hidelink medal shiny gold" href='/charts_artists?max=50&amp;in={{ year }}'>
<a title="Best Artist in {{ year }}" class="hidelink medal shiny hovershiny gold" href='/charts_artists?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.silver -%}
<a title="Second best Artist in {{ year }}" class="hidelink medal shiny silver" href='/charts_artists?max=50&amp;in={{ year }}'>
<a title="Second best Artist in {{ year }}" class="hidelink medal shiny hovershiny silver" href='/charts_artists?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.bronze -%}
<a title="Third best Artist in {{ year }}" class="hidelink medal shiny bronze" href='/charts_artists?max=50&amp;in={{ year }}'>
<a title="Third best Artist in {{ year }}" class="hidelink medal shiny hovershiny bronze" href='/charts_artists?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% if info.medals.gold or info.medals.silver or info.medals.bronze %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}
@ -42,6 +46,10 @@
{%- endif %}
</span>
{% if info.topweeks > 0 %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}
@ -52,22 +60,40 @@
{% macro certs(artist) %}
{% macro subcerts(artist) %}
<!-- CERTS -->
<!-- SUBCERTS -->
{% set charts = db.get_charts_tracks(artist=artist,timerange=malojatime.alltime()) %}
{% set albumcharts = dbc.get_charts_albums({'artist':artist,'timerange':malojatime.alltime(),'resolve_ids':True,'only_own_albums':True}) %}
{% for e in albumcharts -%}
{%- if e.scrobbles >= settings.scrobbles_gold_album -%}{% set cert = 'gold' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_platinum_album -%}{% set cert = 'platinum' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_diamond_album -%}{% set cert = 'diamond' %}{%- endif -%}
{%- if cert -%}
<a href='{{ links.url(e.album) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.album.albumtitle }} has reached {{ cert.capitalize() }} status">
{% include 'icons/cert_album.jinja' %}
</a>
{%- endif %}
{%- endfor %}
{% set charts = dbc.get_charts_tracks({'artist':artist,'timerange':malojatime.alltime()}) %}
{% for e in charts -%}
{%- if e.scrobbles >= settings.scrobbles_gold -%}{% set cert = 'gold' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_platinum -%}{% set cert = 'platinum' %}{%- endif -%}
{%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%}
{%- if cert -%}
<a href='{{ links.url(e.track) }}'><img class="certrecord_small"
src="/media/record_{{ cert }}.png"
title="{{ e.track.title }} has reached {{ cert.capitalize() }} status" /></a>
<a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title }} has reached {{ cert.capitalize() }} status">
{% include 'icons/cert_track.jinja' %}
</a>
{%- endif %}
{%- endfor %}
{%- endmacro %}

View File

@ -2,21 +2,25 @@
<!-- MEDALS -->
{% for year in info.medals.gold -%}
<a title="Best Track in {{ year }}" class="hidelink medal shiny gold" href='/charts_tracks?max=50&amp;in={{ year }}'>
<a title="Best Track in {{ year }}" class="hidelink medal shiny hovershiny gold" href='/charts_tracks?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.silver -%}
<a title="Second best Track in {{ year }}" class="hidelink medal shiny silver" href='/charts_tracks?max=50&amp;in={{ year }}'>
<a title="Second best Track in {{ year }}" class="hidelink medal shiny hovershiny silver" href='/charts_tracks?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% for year in info.medals.bronze -%}
<a title="Third best Track in {{ year }}" class="hidelink medal shiny bronze" href='/charts_tracks?max=50&amp;in={{ year }}'>
<a title="Third best Track in {{ year }}" class="hidelink medal shiny hovershiny bronze" href='/charts_tracks?max=50&amp;in={{ year }}'>
<span>{{ year }}</span>
</a>
{%- endfor %}
{% if info.medals.gold or info.medals.silver or info.medals.bronze %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}
@ -39,25 +43,10 @@
{% endif %}
</span>
{%- endmacro %}
{% macro certs(track) %}
<!-- CERTS -->
{% set info = db.track_info(track=track) %}
{% if info.certification is not none %}
<img class="certrecord"
src="/media/record_{{ info.certification }}.png"
title="This track has reached {{ info.certification.capitalize() }} status" />
{% if info.topweeks > 0 %}
<span class="spacer" ></span>
{% endif %}
{%- endmacro %}

View File

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

View File

@ -0,0 +1,31 @@
{% import 'snippets/links.jinja' as links %}
{% if charts is undefined %}
{% set charts = dbc.get_charts_albums(filterkeys,limitkeys,{'only_own_albums':True}) %}
{% endif %}
{% set charts_14 = charts | fixlength(14) %}
<div class="tiles">
{% if charts_14[0] is none %}
{% include 'icons/nodata.jinja' %}
{% endif %}
{% for entry in charts_14 %}
{% if entry is not none %}
{% set album = entry.album %}
{% set rank = entry.rank %}
<div class="tile">
<a href="{{ links.url(album) }}">
<div class="lazy" data-bg="{{ images.get_album_image(album) }}"'>
<span class='stats'>#{{ rank }}</span> <span>{{ album.albumtitle }}</span>
</div>
</a>
</div>
{% else %}
<div></div>
{% endif %}
{% endfor %}
</div>

View File

@ -2,7 +2,7 @@
{% import 'snippets/entityrow.jinja' as entityrow %}
{% if charts is undefined %}
{% set charts = dbc.get_charts_artists(limitkeys) %}
{% set charts = dbc.get_charts_artists(limitkeys,specialkeys) %}
{% endif %}
{% if compare %}
@ -11,7 +11,7 @@
{% if compare is none %}{% set compare = False %}{% endif %}
{% endif %}
{% if compare %}
{% set prevartists = dbc.get_charts_artists({'timerange':compare}) %}
{% set prevartists = dbc.get_charts_artists({'timerange':compare},specialkeys) %}
{% set lastranks = {} %}
{% for a in prevartists %}
@ -30,12 +30,13 @@
{% set lastindex = firstindex + amountkeys.perpage %}
{% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %}
<table class='list'>
{% for e in charts %}
{% if loop.index0 >= firstindex and loop.index0 < lastindex %}
<tr>
<tr class="listrow associateicons mergeicons" data-entity_id="{{ e['artist_id'] }}" data-entity_type="artist" data-entity_name="{{ e['artist'] }}">
<!-- Rank -->
<td class="rank">{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}</td>
<!-- Rank change -->
@ -48,11 +49,16 @@
{% endif %}
<!-- artist -->
{{ entityrow.row(e['artist']) }}
{{ entityrow.row(e['artist'],adminmode=adminmode,counting=([] if specialkeys.separate else e.associated_artists)) }}
<!-- scrobbles -->
<td class="amount">{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}</td>
<td class="bar">{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],percent=e['scrobbles']*100/maxbar) }}</td>
<td class="amount">{{ links.link_scrobbles([{'artist':e['artist'],'associated':(not specialkeys.separate),'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}</td>
<td class="bar">
{{ links.link_scrobbles([{'artist':e['artist'],'associated':False,'timerange':limitkeys.timerange}],percent=e['real_scrobbles']*100/maxbar) }}
{%- if e['real_scrobbles'] != e['scrobbles'] -%}
{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':limitkeys.timerange}],percent=(e['scrobbles']-e['real_scrobbles'])*100/maxbar) }}
{%- endif %}
</td>
</tr>
{% endif %}
{% endfor %}

View File

@ -6,40 +6,26 @@
{% endif %}
{% set charts_14 = charts | fixlength(14) %}
{% set charts_cycler = cycler(*charts_14) %}
<table class="tiles_top"><tr>
{% for segment in range(3) %}
{% if charts_14[0] is none and loop.first %}
{% include 'icons/nodata.jinja' %}
{% else %}
<td>
{% set segmentsize = segment+1 %}
<table class="tiles_{{ segmentsize }}x{{ segmentsize }} tiles_sub">
{% for row in range(segmentsize) -%}
<tr>
{% for col in range(segmentsize) %}
{% set entry = charts_cycler.next() %}
{% if entry is not none %}
{% set artist = entry.artist %}
{% set rank = entry.rank %}
<td>
<a href="{{ links.url(artist) }}">
<div class="lazy" data-bg="{{ images.get_artist_image(artist) }}"'>
<span class='stats'>#{{ rank }}</span> <span>{{ artist }}</span>
</div>
</a>
</td>
{% else -%}
<td></td>
{%- endif -%}
{%- endfor -%}
</tr>
{%- endfor -%}
</table>
</td>
<div class="tiles">
{% if charts_14[0] is none %}
{% include 'icons/nodata.jinja' %}
{% endif %}
{% endfor %}
</tr></table>
{% for entry in charts_14 %}
{% if entry is not none %}
{% set artist = entry.artist %}
{% set rank = entry.rank %}
<div class="tile">
<a href="{{ links.url(artist) }}">
<div class="lazy" data-bg="{{ images.get_artist_image(artist) }}"'>
<span class='stats'>#{{ rank }}</span> <span>{{ artist }}</span>
</div>
</a>
</div>
{% else %}
<div></div>
{% endif %}
{% endfor %}
</div>

View File

@ -28,11 +28,12 @@
{% set firstindex = amountkeys.page * amountkeys.perpage %}
{% set lastindex = firstindex + amountkeys.perpage %}
{% set maxbar = charts[0]['scrobbles'] if charts != [] else 0 %}
<table class='list'>
{% for e in charts %}
{% if loop.index0 >= firstindex and loop.index0 < lastindex %}
<tr>
<tr class="listrow associateicons mergeicons" data-entity_id="{{ e['track_id'] }}" data-entity_type="track" data-entity_name="{{ e['track'].title }}">
<!-- Rank -->
<td class="rank">{%if loop.changed(e.scrobbles) %}#{{ e.rank }}{% endif %}</td>
<!-- Rank change -->
@ -45,7 +46,7 @@
{% endif %}
<!-- artist -->
{{ entityrow.row(e['track']) }}
{{ entityrow.row(e['track'],adminmode=adminmode) }}
<!-- scrobbles -->
<td class="amount">{{ links.link_scrobbles([{'track':e.track,'timerange':limitkeys.timerange}],amount=e['scrobbles']) }}</td>

View File

@ -6,39 +6,26 @@
{% endif %}
{% set charts_14 = charts | fixlength(14) %}
{% set charts_cycler = cycler(*charts_14) %}
<table class="tiles_top"><tr>
{% for segment in range(3) %}
{% if charts_14[0] is none and loop.first %}
{% include 'icons/nodata.jinja' %}
{% else %}
<td>
{% set segmentsize = segment+1 %}
<table class="tiles_{{ segmentsize }}x{{ segmentsize }} tiles_sub">
{% for row in range(segmentsize) -%}
<tr>
{% for col in range(segmentsize) %}
{% set entry = charts_cycler.next() %}
{% if entry is not none %}
{% set track = entry.track %}
{% set rank = entry.rank %}
<td>
<a href="{{ links.url(track) }}">
<div class="lazy" data-bg="{{ images.get_track_image(track) }}")'>
<span class='stats'>#{{ rank }}</span> <span>{{ track.title }}</span>
</div>
</a>
</td>
{% else -%}
<td></td>
{%- endif %}
{%- endfor -%}
</tr>
{%- endfor %}
</table>
</td>
<div class="tiles">
{% if charts_14[0] is none %}
{% include 'icons/nodata.jinja' %}
{% endif %}
{% endfor %}
</tr></table>
{% for entry in charts_14 %}
{% if entry is not none %}
{% set track = entry.track %}
{% set rank = entry.rank %}
<div class="tile">
<a href="{{ links.url(track) }}">
<div class="lazy" data-bg="{{ images.get_track_image(track) }}"'>
<span class='stats'>#{{ rank }}</span> <span>{{ track.title }}</span>
</div>
</a>
</div>
{% else %}
<div></div>
{% endif %}
{% endfor %}
</div>

View File

@ -0,0 +1,45 @@
{% import 'snippets/links.jinja' as links %}
{% import 'partials/awards_album.jinja' as awards with context %}
{% set album = filterkeys.album %}
{% set info = dbc.album_info({'album':album}) %}
{% set encodedalbum = mlj_uri.uriencode({'album':album}) %}
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="shiny alwaysshiny changeable-image" data-uploader="b64=>upload('{{ encodedalbum }}',b64)"
style="background-image:url('{{ images.get_album_image(info.album) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div class="shiny alwaysshiny" style="background-image:url('{{ images.get_album_image(album) }}');">
</div>
{% endif %}
</td>
<td class="text">
<span>{{ links.links(album.artists) }}</span><br/>
{% if condensed %}<a href="/album?{{ encodedalbum }}">{% endif %}
<h1 id="main_entity_name" class="headerwithextra">{{ info.album.albumtitle | e }}</h1>
{%- if condensed -%}</a>{% endif %}
<span class="rank"><a href="/charts_albums?max=100">#{{ info.position }}</a></span>
<br/>
<p class="stats">
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
{{ awards.subcerts(info) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,62 @@
{% import 'snippets/links.jinja' as links %}
{% import 'partials/awards_artist.jinja' as awards with context %}
{% set artist = filterkeys.artist %}
{% set info = dbc.artist_info({'artist':artist}) %}
{% set encodedartist = mlj_uri.uriencode({'artist':artist}) %}
{% set credited = info.get('replace') %}
{% set included = info.get('associated') %}
{% if credited is not none %}
{% set competes = false %}
{% else %}
{% set credited = artist %}
{% set competes = true %}
{% endif %}
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="shiny alwaysshiny changeable-image" data-uploader="b64=>upload('{{ encodedartist }}',b64)"
style="background-image:url('{{ images.get_artist_image(info.artist) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div class="shiny alwaysshiny" style="background-image:url('{{ images.get_artist_image(artist) }}');">
</div>
{% endif %}
</td>
<td class="text">
{% if condensed %}<a href="/artist?{{ encodedartist }}">{% endif %}
<h1 id="main_entity_name" class="headerwithextra">{{ info.artist | e }}</h1>
{%- if condensed -%}</a>{% endif %}
{% if competes and info['scrobbles']>0 %}<span class="rank"><a href="/charts_artists?max=100">#{{ info.position }}</a></span>{% endif %}
<br/>
{% if competes and included and (not condensed) %}
<span>associated: {{ links.links(included) }}</span>
{% elif not competes %}
<span>Competing under {{ links.link(credited) }} (#{{ info.position }})</span>
{% endif %}
<p class="stats">
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{% if competes %}
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
{% endif %}
{{ awards.subcerts(artist) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,49 @@
{% import 'snippets/links.jinja' as links %}
{% set track = filterkeys.track %}
{% set info = dbc.track_info({'track':track}) %}
{% set encodedtrack = mlj_uri.uriencode({'track':track}) %}
{% import 'partials/awards_track.jinja' as awards with context %}
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="shiny alwaysshiny changeable-image" data-uploader="b64=>upload('{{ encodedtrack }}',b64)"
style="background-image:url('{{ images.get_track_image(info.track) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div class="shiny alwaysshiny" style="background-image:url('{{ images.get_track_image(track) }}');">
</div>
{% endif %}
</td>
<td class="text">
<span>{{ links.links(track.artists) }}</span><br/>
{% if condensed %}<a href="/track?{{ encodedtrack }}">{% endif %}
<h1 id="main_entity_name" class="headerwithextra">{{ info.track.title | e }}</h1>
{%- if condensed -%}</a>{% endif %}
<span class="rank"><a href="/charts_tracks?max=100">#{{ info.position }}</a></span>
<br/>
{% if info.track.album %}
from {{ links.link(info.track.album) }}<br/>
{% endif %}
<p class="stats">
{% if adminmode %}<button type="button" onclick="scrobbleThisTrack()">Scrobble now</button>{% endif %}
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,27 @@
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/entityrow.jinja' as entityrow %}
{% set firstindex = amountkeys.page * amountkeys.perpage %}
{% set lastindex = firstindex + amountkeys.perpage %}
<table class='list'>
{% for e in list %}
{% if loop.index0 >= firstindex and loop.index0 < lastindex %}
<tr class="listrow associateicons mergeicons" data-entity_id="{{ e['track_id'] }}" data-entity_type="track" data-entity_name="{{ e['track'].title }}">
<!-- artist -->
{{ entityrow.row(e['track'],adminmode=adminmode) }}
</tr>
{% endif %}
{% endfor %}
</table>
<script>
</script>

View File

@ -1,6 +1,6 @@
{% import 'snippets/links.jinja' as links %}
{% set ranges = dbc.get_performance(filterkeys,limitkeys,delimitkeys) %}
{% set ranges = dbc.get_performance(filterkeys,limitkeys,delimitkeys,specialkeys) %}
{% set minrank = ranges|map(attribute="rank")|reject("none")|max|default(60) %}
{% set minrank = minrank + 20 %}
@ -11,13 +11,13 @@
{% set thisrange = t.range %}
<tr>
<td>{{ thisrange.desc() }}</td>
<td class="timerange">{{ thisrange.desc() }}</td>
<td class="rank">
{{ links.link_rank(filterkeys,thisrange,rank=t.rank) }}
{{ links.link_rank(filterkeys,specialkeys,thisrange,rank=t.rank) }}
</td>
<td class="chart">
{% set prct = ((minrank+1-t.rank)*100/minrank if t.rank is not none else 0) %}
{{ links.link_rank(filterkeys,thisrange,percent=prct,rank=t.rank) }}
{{ links.link_rank(filterkeys,specialkeys,thisrange,percent=prct,rank=t.rank) }}
</td>

View File

@ -10,12 +10,19 @@
{% set thisrange = t.range %}
<tr>
<td>{{ thisrange.desc() }}</td>
<td class="timerange">{{ thisrange.desc() }}</td>
<td class="amount">
{{ links.link_scrobbles([filterkeys,{'timerange':thisrange}],amount=t.scrobbles) }}
</td>
<td class="bar">
{{ links.link_scrobbles([filterkeys,{'timerange':thisrange}],percent=t.scrobbles*100/maxbar) }}
{% if 'artist' in filterkeys and filterkeys.get('associated') %}
{{ links.link_scrobbles([{'artist':filterkeys.artist,'associated':False,'timerange':thisrange}],percent=t.real_scrobbles*100/maxbar) }}
{%- if t.real_scrobbles != t.scrobbles -%}
{{ links.link_scrobbles([{'artist':filterkeys.artist,'associated':True,'timerange':thisrange}],percent=(t.scrobbles-t.real_scrobbles)*100/maxbar) }}
{%- endif %}
{% else %}
{{ links.link_scrobbles([filterkeys,{'timerange':thisrange}],percent=t.scrobbles*100/maxbar) }}
{% endif %}
</td>
</tr>
{% endfor %}

View File

@ -6,12 +6,8 @@
{% import 'snippets/entityrow.jinja' as entityrow %}
<script src="/edit.js"></script>
<table class='list'>
{% for s in scrobbles -%}
{%- if loop.index0 >= firstindex and loop.index0 < lastindex -%}
<tr>
<td class='time'>{{ malojatime.timestamp_desc(s["time"],short=shortTimeDesc) }}</td>
{{ entityrow.row(s.track) }}
@ -44,6 +40,5 @@
</td>
{% endif %}
</tr>
{%- endif -%}
{% endfor %}
</table>

View File

@ -0,0 +1,30 @@
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/entityrow.jinja' as entityrow %}
{% set ranges = dbc.get_top_albums(filterkeys,limitkeys,delimitkeys) %}
{% set maxbar = ranges|map(attribute="scrobbles")|max|default(1) %}
{% if maxbar < 1 %}{% set maxbar = 1 %}{% endif %}
<table class="list">
{% for e in ranges %}
{% set thisrange = e.range %}
{% set album = e.album %}
<tr>
<td><a href="{{ mlj_uri.create_uri("/charts_albums",{'timerange':thisrange}) }}">{{ thisrange.desc() }}</a></td>
{% if album is none %}
<td><div></div></td>
<td class='stats'>n/a</td>
<td class='amount'>0</td>
<td class='bar'></td>
{% else %}
{{ entityrow.row(album) }}
<td class='amount'>{{ links.link_scrobbles([{'album':album,'timerange':thisrange}],amount=e.scrobbles) }}</td>
<td class='bar'> {{ links.link_scrobbles([{'album':album,'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}</td>
{% endif %}
</tr>
{% endfor %}
</table>

View File

@ -1,7 +1,7 @@
{% import 'snippets/links.jinja' as links %}
{% import 'snippets/entityrow.jinja' as entityrow %}
{% set ranges = dbc.get_top_artists(limitkeys,delimitkeys) %}
{% set ranges = dbc.get_top_artists(limitkeys,delimitkeys,specialkeys) %}
{% set maxbar = ranges|map(attribute="scrobbles")|max|default(1) %}
{% if maxbar < 1 %}{% set maxbar = 1 %}{% endif %}
@ -12,7 +12,7 @@
{% set thisrange = e.range %}
{% set artist = e.artist %}
<tr>
<td>{{ thisrange.desc() }}</td>
<td><a href="{{ mlj_uri.create_uri("/charts_artists",{'timerange':thisrange},specialkeys) }}">{{ thisrange.desc() }}</a></td>
{% if artist is none %}
<td><div></div></td>
@ -20,9 +20,14 @@
<td class='amount'>0</td>
<td class='bar'></td>
{% else %}
{{ entityrow.row(artist) }}
<td class='amount'>{{ links.link_scrobbles([{'artist':artist,'timerange':thisrange}],amount=e.scrobbles) }}</td>
<td class='bar'> {{ links.link_scrobbles([{'artist':artist,'timerange':thisrange}],percent=e.scrobbles*100/maxbar) }}</td>
{{ entityrow.row(artist,counting=([] if specialkeys.separate else e.associated_artists)) }}
<td class='amount'>{{ links.link_scrobbles([{'artist':artist,'associated':(not specialkeys.separate),'timerange':thisrange}],amount=e.scrobbles) }}</td>
<td class='bar'>
{{ links.link_scrobbles([{'artist':e['artist'],'associated':False,'timerange':e.range}],percent=e['real_scrobbles']*100/maxbar) }}
{%- if e['real_scrobbles'] != e['scrobbles'] -%}
{{ links.link_scrobbles([{'artist':e['artist'],'associated':True,'timerange':e.range}],percent=(e['scrobbles']-e['real_scrobbles'])*100/maxbar) }}
{%- endif %}
</td>
{% endif %}
</tr>

View File

@ -12,7 +12,7 @@
{% set thisrange = e.range %}
{% set track = e.track %}
<tr>
<td>{{ thisrange.desc() }}</td>
<td><a href="{{ mlj_uri.create_uri("/charts_tracks",{'timerange':thisrange}) }}">{{ thisrange.desc() }}</a></td>
{% if track is none %}
<td><div></div></td>

View File

@ -25,7 +25,9 @@
<br/>
{{ filterdesc.desc(filterkeys,limitkeys,prefix='of') }}
<br/><br/>
{% with artistchart = (filterkeys.get('artist') is not none) %}
{% include 'snippets/timeselection.jinja' %}
{% endwith %}
</td>
</tr>

View File

@ -4,8 +4,9 @@
{% import 'snippets/filterdescription.jinja' as filterdesc %}
{% import 'snippets/pagination.jinja' as pagination %}
{% set totalscrobbles = dbc.get_scrobbles_num(filterkeys,limitkeys) %}
{% set scrobbles = dbc.get_scrobbles(filterkeys,limitkeys,amountkeys) %}
{% set pages = math.ceil(scrobbles.__len__() / amountkeys.perpage) %}
{% set pages = math.ceil(totalscrobbles / amountkeys.perpage) %}
{% if filterkeys.get('track') is not none %}
{% set img = images.get_track_image(filterkeys.track) %}
@ -29,7 +30,7 @@
<h1>Scrobbles</h1><br/>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/>
<p class="stats">{{ scrobbles.__len__() }} Scrobbles</p>
<p class="stats">{{ totalscrobbles }} Scrobbles</p>
<br/>
{% with delimitkeys = {} %}
{% include 'snippets/timeselection.jinja' %}

View File

@ -1,9 +1,11 @@
{% macro row(entity,counting=[]) %}
{% macro row(entity,counting=[],adminmode=False) %}
{% import 'snippets/links.jinja' as links %}
{% if entity is mapping and 'artists' in entity %}
{% if entity is mapping and 'title' in entity %}
{% set img = images.get_track_image(entity) %}
{% elif entity is mapping and 'albumtitle' in entity %}
{% set img = images.get_album_image(entity) %}
{% else %}
{% set img = images.get_artist_image(entity) %}
{% endif %}
@ -20,6 +22,10 @@
<td class='track'>
<span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> {{ links.link(entity) }}
</td>
{% elif entity is mapping and 'albumtitle' in entity %}
<td class='album'>
<span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> {{ links.link(entity) }}
</td>
{% else %}
<td class='artist'>{{ links.link(entity) }}
{% if counting != [] %}
@ -29,4 +35,16 @@
{% endif %}
{% if adminmode %}
<td>
{% include 'icons/merge_mark.jinja' %}
{% include 'icons/merge_unmark.jinja' %}
{% if (entity is mapping) %}
{% include 'icons/association_mark.jinja' %}
{% include 'icons/association_unmark.jinja' %}
{% endif %}
</td>
{% endif %}
{% endmacro %}

View File

@ -3,10 +3,13 @@
{% macro desc(filterkeys,limitkeys,prefix="by") %}
{% if filterkeys.get('artist') is not none %}
{{ prefix }} {{ links.link(filterkeys.get('artist')) }}
{{ prefix }} {{ links.link(filterkeys.get('artist')) }}{% if filterkeys.get('associated') %} (and associated artists){% endif %}
{% elif filterkeys.get('track') is not none %}
of {{ links.link(filterkeys.get('track')) }}
by {{ links.links(filterkeys["track"]["artists"]) }}
{% elif filterkeys.get('album') is not none %}
from {{ links.link(filterkeys.get('album')) }}
by {{ links.links(filterkeys["album"]["artists"]) }}
{% endif %}
{{ limitkeys.timerange.desc(prefix=True) }}

View File

@ -1,6 +1,6 @@
{% macro link(entity) -%}
{% if entity is mapping and 'artists' in entity %}
{% set name = entity.title %}
{% if entity is mapping and ('title' in entity or 'albumtitle' in entity) %}
{% set name = entity.title or entity.albumtitle %}
{% else %}
{% set name = entity %}
{% endif %}
@ -9,15 +9,21 @@
{%- endmacro %}
{% macro links(entities) -%}
{% for entity in entities -%}
{{ link(entity) }}{{ ", " if not loop.last }}
{%- endfor %}
{% if entities is none or entities == [] %}
{{ settings["DEFAULT_ALBUM_ARTIST"] }}
{% else %}
{% for entity in entities -%}
{{ link(entity) }}{{ ", " if not loop.last }}
{%- endfor %}
{% endif %}
{%- endmacro %}
{% macro url(entity) %}
{% if entity is mapping and 'artists' in entity -%}
{% if entity is mapping and 'albumtitle' in entity -%}
{{ mlj_uri.create_uri("/album",{'album':entity}) }}
{% elif entity is mapping and 'artists' in entity -%}
{{ mlj_uri.create_uri("/track",{'track':entity}) }}
{%- else -%}
{{ mlj_uri.create_uri("/artist",{'artist':entity}) }}
@ -39,12 +45,14 @@
{%- endmacro %}
{% macro link_rank(filterkeys,timerange,rank=None,percent=None) %}
{% macro link_rank(filterkeys,specialkeys,timerange,rank=None,percent=None) %}
{% if 'track' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_tracks",{'timerange':timerange}) %}
{% elif 'album' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_albums",{'timerange':timerange}) %}
{% elif 'artist' in filterkeys %}
{% set url = mlj_uri.create_uri("/charts_artists",{'timerange':timerange}) %}
{% set url = mlj_uri.create_uri("/charts_artists",{'timerange':timerange},specialkeys) %}
{% endif %}
{% set rankclass = {1:'gold',2:'silver',3:'bronze'}[rank] or "" %}

View File

@ -1,7 +1,7 @@
{% set allkeys = [filterkeys,limitkeys,delimitkeys,amountkeys] | combine_dicts %}
{% set allkeys = [filterkeys,limitkeys,delimitkeys,amountkeys,specialkeys] | combine_dicts %}
@ -13,7 +13,7 @@
{% set nextrange = thisrange.next(1) %}
{% if prevrange is not none %}
<a href='{{ mlj_uri.create_uri("",filterkeys,limitkeys,delimitkeys,{'timerange':prevrange}) }}'><span class='stat_selector'>{{ prevrange.desc() }}</span></a>
<a href='{{ mlj_uri.create_uri("",filterkeys,limitkeys,delimitkeys,specialkeys,{'timerange':prevrange}) }}'><span class='stat_selector'>{{ prevrange.desc() }}</span></a>
«
{% endif %}
{% if prevrange is not none or nextrange is not none %}
@ -21,7 +21,7 @@
{% endif %}
{% if nextrange is not none %}
»
<a href='{{ mlj_uri.create_uri("",filterkeys,limitkeys,delimitkeys,{'timerange':nextrange}) }}'><span class='stat_selector'>{{ nextrange.desc() }}</span></a>
<a href='{{ mlj_uri.create_uri("",filterkeys,limitkeys,delimitkeys,specialkeys,{'timerange':nextrange}) }}'><span class='stat_selector'>{{ nextrange.desc() }}</span></a>
{% endif %}
</div>
@ -61,3 +61,32 @@
</div>
{% endif %}
<!-- we do this only for things filtered by artist (to include or not include their subunits etc), but NOT for get_performance
since it's more intuitive to fall under the separate/combined logic below -->
{% if ('artist' in filterkeys) and (not artistchart) %}
<div>
{% for o in xassociated %}
{% if o.replacekeys | map('compare_key_in_dicts',o.replacekeys,allkeys) | alltrue %}
<span style='opacity:0.5;'>{{ o.localisation }}</span>
{% else %}
<a href='{{ mlj_uri.create_uri("",allkeys,o.replacekeys) }}'><span>{{ o.localisation }}</span></a>
{% endif %}
{{ "|" if not loop.last }}
{% endfor %}
</div>
{% endif %}
{% if artistchart %}
<div>
{% for o in xseparate %}
{% if o.replacekeys | map('compare_key_in_dicts',o.replacekeys,allkeys) | alltrue %}
<span style='opacity:0.5;'>{{ o.localisation }}</span>
{% else %}
<a href='{{ mlj_uri.create_uri("",allkeys,o.replacekeys) }}'><span>{{ o.localisation }}</span></a>
{% endif %}
{{ "|" if not loop.last }}
{% endfor %}
</div>
{% endif %}

View File

@ -3,109 +3,28 @@
{% block scripts %}
<script>document.addEventListener('DOMContentLoaded',function() {
showRange('topartists','{{ settings["DEFAULT_RANGE_CHARTS_ARTISTS"] }}');
showRange('toptracks','{{ settings["DEFAULT_RANGE_CHARTS_TRACKS"] }}');
showRange('pulse','{{ settings["DEFAULT_STEP_PULSE"] }}');
})</script>
<script src="/rangeselect.js"></script>
<script src="/cookies.js"></script>
<script src="/statselect.js"></script>
<link rel="stylesheet" href="/static/css/startpage.css" />
{% endblock %}
{% block content -%}
<!-- ARTIST CHARTS -->
<h1><a class="stat_link_topartists" href="/charts_artists?in=alltime">Top Artists</a></h1>
{% for r in xcurrent -%}
<span onclick="showRangeManual('topartists','{{ r.identifier }}')" class="stat_selector_topartists selector_topartists_{{ r.identifier }}">
{{ r.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
<div id="startpage">
{% for r in xcurrent -%}
<span class="stat_module_topartists topartists_{{ r.identifier }}" style="display:none;">
{%- with limitkeys = {"timerange":r.range} -%}
{% include 'partials/charts_artists_tiles.jinja' %}
{%- endwith -%}
</span>
{%- endfor %}
{% for module in ['charts_artists','charts_tracks','charts_albums','pulse','lastscrobbles'] %}
<section id="start_page_module_{{ module }}" style="grid-area: {{ module }}">
<!-- MODULE: {{ module }} -->
{% include 'startpage_modules/' + module + '.jinja' %}
<!-- END MODULE: {{ module }} -->
</section>
{% endfor %}
</div>
<!-- TRACK CHARTS -->
<h1><a class="stat_link_toptracks" href="/charts_tracks?in=alltime">Top Tracks</a></h1>
{% for r in xcurrent -%}
<span onclick="showRangeManual('toptracks','{{ r.identifier }}')" class="stat_selector_toptracks selector_toptracks_{{ r.identifier }}">
{{ r.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for r in xcurrent -%}
<span class="stat_module_toptracks toptracks_{{ r.identifier }}" style="display:none;">
{%- with limitkeys = {"timerange":r.range} -%}
{% include 'partials/charts_tracks_tiles.jinja' %}
{%- endwith -%}
</span>
{%- endfor %}
<div class="sidelist">
<!-- SCROBBLES -->
<h1><a href="/scrobbles">Last Scrobbles</a></h1>
{% for range in xcurrent %}
<span class="stats">{{ range.localisation }}</span>
<a href='/scrobbles?in={{ range.identifier }}'>{{ dbc.get_scrobbles_num({'timerange':range.range}) }}</a>
{% endfor %}
<br/><br/>
<span class="stat_module">
{%- with amountkeys = {"perpage":12,"page":0}, shortTimeDesc=True -%}
{% include 'partials/scrobbles.jinja' %}
{%- endwith -%}
</span>
<br/>
<!-- PULSE -->
<h1><a class="stat_link_pulse" href="/pulse?trail=1&step=month">Pulse</a></h1>
{% for range in xranges -%}
<span onclick="showRangeManual('pulse','{{ range.identifier }}')" class="stat_selector_pulse selector_pulse_{{ range.identifier }}">
{{ range.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for range in xranges -%}
<span class="stat_module_pulse pulse_{{ range.identifier }}" style="display:none;">
{%- with limitkeys={"since":range.firstrange},delimitkeys={"step":range.identifier} -%}
{% include 'partials/pulse.jinja' %}
{%- endwith -%}
</span>
{%- endfor %}
</div>
{%- endblock %}

View File

@ -0,0 +1,19 @@
<h1><a class="stat_link_topalbums" href="/charts_albums?in=alltime">Top Albums</a></h1>
{% for r in xcurrent -%}
<span onclick="showStatsManual('topalbums','{{ r.identifier }}')" class="stat_selector_topalbums selector_topalbums_{{ r.identifier }}">
{{ r.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for r in xcurrent -%}
<section class="stat_module_topalbums topalbums_{{ r.identifier }}" style="display:none;">
{%- with limitkeys = {"timerange":r.range} -%}
{% include 'partials/charts_albums_tiles.jinja' %}
{%- endwith -%}
</section>
{%- endfor %}

View File

@ -0,0 +1,19 @@
<h1><a class="stat_link_topartists" href="/charts_artists?in=alltime">Top Artists</a></h1>
{% for r in xcurrent -%}
<span onclick="showStatsManual('topartists','{{ r.identifier }}')" class="stat_selector_topartists selector_topartists_{{ r.identifier }}">
{{ r.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for r in xcurrent -%}
<section class="stat_module_topartists topartists_{{ r.identifier }}" style="display:none;">
{%- with limitkeys = {"timerange":r.range} -%}
{% include 'partials/charts_artists_tiles.jinja' %}
{%- endwith -%}
</section>
{%- endfor %}

View File

@ -0,0 +1,19 @@
<h1><a class="stat_link_toptracks" href="/charts_tracks?in=alltime">Top Tracks</a></h1>
{% for r in xcurrent -%}
<span onclick="showStatsManual('toptracks','{{ r.identifier }}')" class="stat_selector_toptracks selector_toptracks_{{ r.identifier }}">
{{ r.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for r in xcurrent -%}
<section class="stat_module_toptracks toptracks_{{ r.identifier }}" style="display:none;">
{%- with limitkeys = {"timerange":r.range} -%}
{% include 'partials/charts_tracks_tiles.jinja' %}
{%- endwith -%}
</section>
{%- endfor %}

View File

@ -0,0 +1,65 @@
<!-- TODO: THIS IS PRELIMINARY. not sure if i want to keep this, but for now let's fill up that empty slot on the start page -->
<h1>Featured</h1>
{% set featured = dbc.get_featured() %}
{% set entitytypes = [
{'identifier':'artist','localisation':"Artist", 'template':"info_artist.jinja", 'filterkeys':{"artist": featured.artist } },
{'identifier':'track','localisation':"Track", 'template':"info_track.jinja", 'filterkeys':{"track": featured.track } },
{'identifier':'album','localisation':"Album", 'template':"info_album.jinja", 'filterkeys':{"album": featured.album } }
] %}
{% for t in entitytypes -%}
<span onclick="showStatsManual('featured','{{ t.identifier }}')" class="stat_selector_featured selector_featured_{{ t.identifier }}">
{{ t.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/><br/>
{% for t in entitytypes -%}
<section class="stat_module_featured featured_{{ t.identifier }}" style="display:none;">
{%- with filterkeys = t.filterkeys -%}
{%- with condensed = true -%}
{% if filterkeys[t.identifier] %}
{% include 'partials/' + t.template %}
{% endif %}
{%- endwith -%}
{%- endwith -%}
</section>
{%- endfor %}
<script>
function showFeatured(t) {
// Make all modules disappear
var modules = document.getElementsByClassName("stat_module_featured");
for (var i=0;i<modules.length;i++) {
modules[i].setAttribute("style","display:none;");
}
// Make requested module appear
var reactivate = document.getElementsByClassName("featured_" + t);
for (var i=0;i<reactivate.length;i++) {
reactivate[i].setAttribute("style","");
}
// Set all selectors to unselected
var selectors = document.getElementsByClassName("stat_selector_featured");
for (var i=0;i<selectors.length;i++) {
selectors[i].setAttribute("style","");
}
// Set the active selector to selected
var reactivate = document.getElementsByClassName("stat_selector_featured_" + t);
for (var i=0;i<reactivate.length;i++) {
reactivate[i].setAttribute("style","opacity:0.5;");
}
}
</script>

View File

@ -0,0 +1,15 @@
<h1><a href="/scrobbles">Last Scrobbles</a></h1>
{% for range in xcurrent %}
<span class="stats">{{ range.localisation }}</span>
<a href='/scrobbles?in={{ range.identifier }}'>{{ dbc.get_scrobbles_num({'timerange':range.range}) }}</a>
{% endfor %}
<br/><br/>
<span class="stat_module">
{%- with amountkeys = {"perpage":12,"page":0}, shortTimeDesc=True -%}
{% include 'partials/scrobbles.jinja' %}
{%- endwith -%}
</span>

View File

@ -0,0 +1,17 @@
<h1><a class="stat_link_pulse" href="/pulse?trail=1&step=month">Pulse</a></h1>
{% for range in xranges -%}
<span onclick="showStatsManual('pulse','{{ range.identifier }}')" class="stat_selector_pulse selector_pulse_{{ range.identifier }}">
{{ range.localisation }}
</span>
{{ "|" if not loop.last }}
{%- endfor %}
<br/><br/>
{% for range in xranges -%}
<span class="stat_module_pulse pulse_{{ range.identifier }}" style="display:none;">
{%- with limitkeys={"since":range.firstrange},delimitkeys={"step":range.identifier} -%}
{% include 'partials/pulse.jinja' %}
{%- endwith -%}
</span>
{%- endfor %}

View File

@ -0,0 +1,31 @@
{% extends "abstracts/base.jinja" %}
{% block title %}Maloja - #1 Albums{% endblock %}
{% import 'snippets/filterdescription.jinja' as filterdesc %}
<!-- find representative -->
{% set entries = dbc.get_top_albums(filterkeys,limitkeys,delimitkeys) %}
{% set repr = entries | find_representative('album','scrobbles') %}
{% set img = "/favicon.png" if repr is none else images.get_album_image(repr.album) %}
{% block content %}
<table class="top_info">
<tr>
<td class="image">
<div style="background-image:url('{{ img }}')"></div>
</td>
<td class="text">
<h1>#1 Albums</h1><br/>
{{ filterdesc.desc(filterkeys,limitkeys) }}
<br/><br/>
{% include 'snippets/timeselection.jinja' %}
</td>
</tr>
</table>
{% include 'partials/top_albums.jinja' %}
{% endblock %}

View File

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

View File

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

View File

@ -4,11 +4,15 @@
{% import 'snippets/links.jinja' as links %}
{% block scripts %}
<script src="/rangeselect.js"></script>
<script src="/edit.js"></script>
<script src="/statselect.js"></script>
<script>
function scrobble(encodedtrack) {
neo.xhttprequest('/apis/mlj_1/newscrobble?nofix&' + encodedtrack,data={},method="POST").then(response=>{window.location.reload()});
function scrobbleThisTrack() {
var payload = {
"artists":{{ info.track.artists | tojson }},
"title":{{ info.track.title | tojson }},
"nofix": true
}
neo.xhttprequest("/apis/mlj_1/newscrobble",data=payload,method="POST",callback=notifyCallback,json=true).then(response=>{window.location.reload()});
}
</script>
{% endblock %}
@ -21,14 +25,26 @@
{% set encodedtrack = mlj_uri.uriencode({'track':track}) %}
{% block custombodyclasses %}
{% if info.certification %}certified certified_{{ info.certification }}{% endif %}
{% endblock %}
{% block icon_bar %}
{% if adminmode %}
{% include 'icons/edit.jinja' %}
<div class="iconsubset mergeicons" data-entity_type="track" data-entity_id="{{ info.id }}" data-entity_name="{{ info.track.title }}">
{% include 'icons/merge.jinja' %}
{% include 'icons/merge_mark.jinja' %}
{% include 'icons/merge_unmark.jinja' %}
{% include 'icons/merge_cancel.jinja' %}
<script>showValidMergeIcons();</script>
</div>
<div class="iconsubset associateicons" data-entity_type="track" data-entity_id="{{ info.id }}" data-entity_name="{{ info.track.title }}">
{% include 'icons/association_mark.jinja' %}
{% include 'icons/association_unmark.jinja' %}
{% include 'icons/association_cancel.jinja' %}
</div>
{% endif %}
{% endblock %}
@ -44,43 +60,7 @@
{% import 'partials/awards_track.jinja' as awards %}
<table class="top_info">
<tr>
<td class="image">
{% if adminmode %}
<div
class="changeable-image" data-uploader="b64=>upload('{{ encodedtrack }}',b64)"
style="background-image:url('{{ images.get_track_image(track) }}');"
title="Drag & Drop to upload new image"
></div>
{% else %}
<div style="background-image:url('{{ images.get_track_image(track) }}');">
</div>
{% endif %}
</td>
<td class="text">
<span>{{ links.links(track.artists) }}</span><br/>
<h1 id="main_entity_name" class="headerwithextra">{{ info.track.title | e }}</h1>
{{ awards.certs(track) }}
<span class="rank"><a href="/charts_tracks?max=100">#{{ info.position }}</a></span>
<br/>
<p class="stats">
{% if adminmode %}<button type="button" onclick="scrobble('{{ encodedtrack }}')">Scrobble now</button>{% endif %}
<a href="{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}">{{ info['scrobbles'] }} Scrobbles</a>
</p>
{{ awards.medals(info) }}
{{ awards.topweeks(info) }}
</td>
</tr>
</table>
{% include 'partials/info_track.jinja' %}
<table class="twopart">
@ -91,8 +71,8 @@
<br/>
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
@ -104,7 +84,7 @@
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
@ -121,8 +101,8 @@
<br/>
{% for r in xranges %}
<span
onclick="showRangeManual('pulse','{{ r.identifier }}')"
class="stat_selector_pulse selector_pulse_{{ r.identifier }}"
onclick="showStatsManual('pulseperformancecombined','{{ r.identifier }}')"
class="stat_selector_pulseperformancecombined selector_pulseperformancecombined_{{ r.identifier }}"
style="{{ 'opacity:0.5;' if initialrange==r.identifier else '' }}">
{{ r.localisation }}
</span>
@ -134,7 +114,7 @@
{% for r in xranges %}
<span
class="stat_module_pulse pulse_{{ r.identifier }}"
class="stat_module_pulseperformancecombined pulseperformancecombined_{{ r.identifier }}"
style="{{ 'display:none;' if initialrange!=r.identifier else '' }}"
>
@ -152,7 +132,7 @@
<h2><a href='{{ mlj_uri.create_uri("/scrobbles",filterkeys) }}'>Last Scrobbles</a></h2>
{% with amountkeys = {"perpage":15,"page":0} %}
{% with amountkeys = {"perpage":16,"page":0} %}
{% include 'partials/scrobbles.jinja' %}
{% endwith %}

Some files were not shown because too many files have changed in this diff Show More