From 7ec5e88bc46389b05743744c77c3a601f5c68390 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 18 Dec 2023 03:22:54 +0100 Subject: [PATCH 01/18] Upgrade auth and logging to new doreah --- dev/releases/3.2.yml | 6 ++++- maloja/apis/native_v1.py | 45 ++++++++++++++++++------------------- maloja/database/__init__.py | 1 - maloja/dev/profiler.py | 7 +++--- maloja/pkg_global/conf.py | 26 ++++++++------------- maloja/server.py | 9 ++++---- maloja/setup.py | 11 +++++---- pyproject.toml | 2 +- requirements.txt | 2 +- 9 files changed, 50 insertions(+), 59 deletions(-) diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index 3c890e6..7d9f2a1 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -32,8 +32,12 @@ minor_release_name: "Nicole" - "[Bugfix] Fixed Spotify authentication thread blocking the process from terminating" - "[Technical] Upgraded all third party modules to use requests module and send User Agent" 3.2.2: + commit: "febaff97228b37a192f2630aa331cac5e5c3e98e" notes: - "[Security] Fixed XSS vulnerability in error page (Disclosed by https://github.com/NULLYUKI)" - "[Architecture] Reworked the default directory selection" - "[Feature] Added option to show scrobbles on tile charts" - - "[Bugfix] Fixed Last.fm authentication" \ No newline at end of file + - "[Bugfix] Fixed Last.fm authentication" +3.2.3: + notes: + - "[Architecture] Upgraded doreah, significant rework of authentication" \ No newline at end of file diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index aea7710..1c9ad80 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -7,7 +7,6 @@ from bottle import response, static_file, FormsDict from inspect import signature from doreah.logging import log -from doreah.auth import authenticated_function # nimrodel API from nimrodel import EAPI as API @@ -15,7 +14,7 @@ from nimrodel import Multi from .. import database -from ..pkg_global.conf import malojaconfig, data_dir +from ..pkg_global.conf import malojaconfig, data_dir, auth @@ -567,7 +566,7 @@ def album_info_external(k_filter, k_limit, k_delimit, k_amount): @api.post("newscrobble") -@authenticated_function(alternate=api_key_correct,api=True,pass_auth_result_as='auth_result') +@auth.authenticated_function(alternate=api_key_correct,api=True,pass_auth_result_as='auth_result') @catch_exceptions def post_scrobble( artist:Multi=None, @@ -647,7 +646,7 @@ def post_scrobble( @api.post("addpicture") -@authenticated_function(alternate=api_key_correct,api=True) +@auth.authenticated_function(alternate=api_key_correct,api=True) @catch_exceptions @convert_kwargs def add_picture(k_filter, k_limit, k_delimit, k_amount, k_special): @@ -670,7 +669,7 @@ def add_picture(k_filter, k_limit, k_delimit, k_amount, k_special): @api.post("importrules") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def import_rulemodule(**keys): """Internal Use Only""" @@ -689,7 +688,7 @@ def import_rulemodule(**keys): @api.post("rebuild") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def rebuild(**keys): """Internal Use Only""" @@ -765,7 +764,7 @@ def search(**keys): @api.post("newrule") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def newrule(**keys): """Internal Use Only""" @@ -776,21 +775,21 @@ def newrule(**keys): @api.post("settings") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def set_settings(**keys): """Internal Use Only""" malojaconfig.update(keys) @api.post("apikeys") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def set_apikeys(**keys): """Internal Use Only""" apikeystore.update(keys) @api.post("import") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def import_scrobbles(identifier): """Internal Use Only""" @@ -798,7 +797,7 @@ def import_scrobbles(identifier): import_scrobbles(identifier) @api.get("backup") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def get_backup(**keys): """Internal Use Only""" @@ -811,7 +810,7 @@ def get_backup(**keys): return static_file(os.path.basename(archivefile),root=tmpfolder) @api.get("export") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def get_export(**keys): """Internal Use Only""" @@ -825,7 +824,7 @@ def get_export(**keys): @api.post("delete_scrobble") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def delete_scrobble(timestamp): """Internal Use Only""" @@ -837,7 +836,7 @@ def delete_scrobble(timestamp): @api.post("edit_artist") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def edit_artist(id,name): """Internal Use Only""" @@ -847,7 +846,7 @@ def edit_artist(id,name): } @api.post("edit_track") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def edit_track(id,title): """Internal Use Only""" @@ -857,7 +856,7 @@ def edit_track(id,title): } @api.post("edit_album") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def edit_album(id,albumtitle): """Internal Use Only""" @@ -868,7 +867,7 @@ def edit_album(id,albumtitle): @api.post("merge_tracks") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def merge_tracks(target_id,source_ids): """Internal Use Only""" @@ -879,7 +878,7 @@ def merge_tracks(target_id,source_ids): } @api.post("merge_artists") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def merge_artists(target_id,source_ids): """Internal Use Only""" @@ -890,7 +889,7 @@ def merge_artists(target_id,source_ids): } @api.post("merge_albums") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def merge_artists(target_id,source_ids): """Internal Use Only""" @@ -901,7 +900,7 @@ def merge_artists(target_id,source_ids): } @api.post("associate_albums_to_artist") -@authenticated_function(api=True) +@auth.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) @@ -913,7 +912,7 @@ def associate_albums_to_artist(target_id,source_ids,remove=False): } @api.post("associate_tracks_to_artist") -@authenticated_function(api=True) +@auth.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) @@ -925,7 +924,7 @@ def associate_tracks_to_artist(target_id,source_ids,remove=False): } @api.post("associate_tracks_to_album") -@authenticated_function(api=True) +@auth.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) @@ -937,7 +936,7 @@ def associate_tracks_to_album(target_id,source_ids): @api.post("reparse_scrobble") -@authenticated_function(api=True) +@auth.authenticated_function(api=True) @catch_exceptions def reparse_scrobble(timestamp): """Internal Use Only""" diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index fe5ce62..97634db 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -27,7 +27,6 @@ from . import exceptions # doreah toolkit from doreah.logging import log -from doreah.auth import authenticated_api, authenticated_api_with_alternate import doreah diff --git a/maloja/dev/profiler.py b/maloja/dev/profiler.py index c20db90..49563a4 100644 --- a/maloja/dev/profiler.py +++ b/maloja/dev/profiler.py @@ -1,9 +1,9 @@ import os import cProfile, pstats +import time from doreah.logging import log -from doreah.timing import Clock from ..pkg_global.conf import data_dir @@ -27,8 +27,7 @@ def profile(func): def newfunc(*args,**kwargs): - clock = Clock() - clock.start() + starttime = time.time() if FULL_PROFILE: benchmarkfolder = data_dir['logs']("benchmarks") @@ -44,7 +43,7 @@ def profile(func): if FULL_PROFILE: localprofiler.disable() - seconds = clock.stop() + seconds = time.time() - starttime if not SINGLE_CALLS: times.setdefault(realfunc,[]).append(seconds) diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 26ff692..eff0746 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -1,4 +1,7 @@ import os + +import doreah.auth +import doreah.logging from doreah.configuration import Configuration from doreah.configuration import types as tp @@ -331,26 +334,15 @@ data_dir = { -### DOREAH CONFIGURATION +### DOREAH OBJECTS -from doreah import config - -config( - auth={ - "multiuser":False, - "cookieprefix":"maloja", - "stylesheets":["/maloja.css"], - "dbfile":data_dir['auth']("auth.ddb") - }, - logging={ - "logfolder": data_dir['logs']() if malojaconfig["LOGGING"] else None - }, - regular={ - "offset": malojaconfig["TIMEZONE"] - } -) +auth = doreah.auth.AuthManager(singleuser=True,cookieprefix='maloja',stylesheets=("/maloja.css",),dbfile=data_dir['auth']("auth.sqlite")) +#logger = doreah.logging.Logger(logfolder=data_dir['logs']() if malojaconfig["LOGGING"] else None) +#log = logger.log +# this is not how its supposed to be done, but lets ease the transition +doreah.logging.defaultlogger.logfolder = data_dir['logs']() if malojaconfig["LOGGING"] else None diff --git a/maloja/server.py b/maloja/server.py index a2ff5bc..9090630 100644 --- a/maloja/server.py +++ b/maloja/server.py @@ -12,14 +12,13 @@ from jinja2.exceptions import TemplateNotFound # doreah toolkit from doreah.logging import log -from doreah import auth # rest of the project from . import database from .database.jinjaview import JinjaDBConnection from .images import image_request from .malojauri import uri_to_internal, remove_identical -from .pkg_global.conf import malojaconfig, data_dir +from .pkg_global.conf import malojaconfig, data_dir, auth from .pkg_global import conf from .jinjaenv.context import jinja_environment from .apis import init_apis, apikeystore @@ -97,7 +96,7 @@ aliases = { ### API -auth.authapi.mount(server=webserver) +conf.auth.authapi.mount(server=webserver) init_apis(webserver) # redirects for backwards compatibility @@ -197,7 +196,7 @@ def jinja_page(name): if name in aliases: redirect(aliases[name]) keys = remove_identical(FormsDict.decode(request.query)) - adminmode = request.cookies.get("adminmode") == "true" and auth.check(request) + adminmode = request.cookies.get("adminmode") == "true" and auth.check_request(request) with JinjaDBConnection() as conn: @@ -222,7 +221,7 @@ def jinja_page(name): return res @webserver.route("/") -@auth.authenticated +@auth.authenticated_function() def jinja_page_private(name): return jinja_page(name) diff --git a/maloja/setup.py b/maloja/setup.py index 4aa75dd..a8652f1 100644 --- a/maloja/setup.py +++ b/maloja/setup.py @@ -6,9 +6,8 @@ try: except ImportError: import distutils from doreah.io import col, ask, prompt -from doreah import auth -from .pkg_global.conf import data_dir, dir_settings, malojaconfig +from .pkg_global.conf import data_dir, dir_settings, malojaconfig, auth @@ -67,10 +66,10 @@ def setup(): if forcepassword is not None: # user has specified to force the pw, nothing else matters - auth.defaultuser.setpw(forcepassword) + auth.change_pw(password=forcepassword) print("Password has been set.") - elif auth.defaultuser.checkpw("admin"): - # if the actual pw is admin, it means we've never set this up properly (eg first start after update) + elif auth.still_has_factory_default_user(): + # this means we've never set this up properly (eg first start after update) while True: newpw = prompt("Please set a password for web backend access. Leave this empty to generate a random password.",skip=SKIP,secret=True) if newpw is None: @@ -81,7 +80,7 @@ def setup(): newpw_repeat = prompt("Please type again to confirm.",skip=SKIP,secret=True) if newpw != newpw_repeat: print("Passwords do not match!") else: break - auth.defaultuser.setpw(newpw) + auth.change_pw(password=newpw) except EOFError: print("No user input possible. If you are running inside a container, set the environment variable",col['yellow']("MALOJA_SKIP_SETUP=yes")) diff --git a/pyproject.toml b/pyproject.toml index 3a56757..883a911 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ dependencies = [ "bottle>=0.12.16", "waitress>=2.1.0", - "doreah>=1.9.4, <2", + "doreah>=2.0.0, <3", "nimrodel>=0.8.0", "setproctitle>=1.1.10", #"pyvips>=2.1.16", diff --git a/requirements.txt b/requirements.txt index 940b543..c869349 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bottle>=0.12.16 waitress>=2.1.0 -doreah>=1.9.4, <2 +doreah>=2.0.0, <3 nimrodel>=0.8.0 setproctitle>=1.1.10 jinja2>=3.0.0 From fd4c99f88898ff978df7ccb5a1a958f478b07910 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 27 Dec 2023 14:36:43 +0100 Subject: [PATCH 02/18] Fix GH-311, fix GH-282 --- dev/releases/3.2.yml | 8 +++++++- maloja/database/__init__.py | 2 +- maloja/database/sqldb.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dev/releases/3.2.yml b/dev/releases/3.2.yml index 3c890e6..fa78d45 100644 --- a/dev/releases/3.2.yml +++ b/dev/releases/3.2.yml @@ -32,8 +32,14 @@ minor_release_name: "Nicole" - "[Bugfix] Fixed Spotify authentication thread blocking the process from terminating" - "[Technical] Upgraded all third party modules to use requests module and send User Agent" 3.2.2: + commit: "febaff97228b37a192f2630aa331cac5e5c3e98e" notes: - "[Security] Fixed XSS vulnerability in error page (Disclosed by https://github.com/NULLYUKI)" - "[Architecture] Reworked the default directory selection" - "[Feature] Added option to show scrobbles on tile charts" - - "[Bugfix] Fixed Last.fm authentication" \ No newline at end of file + - "[Bugfix] Fixed Last.fm authentication" +3.2.3: + notes: + - "[Bugfix] Fixed initial permission check" + - "[Bugfix] Fixed and updated various texts" + - "[Bugfix] Fixed moving tracks to different album" \ No newline at end of file diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index fe5ce62..e3978e0 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -318,7 +318,7 @@ def associate_tracks_to_album(target_id,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}) + sqldb.add_tracks_to_albums({src:target_id for src in source_ids},replace=True) else: sqldb.remove_album(source_ids) result = {'sources':sources,'target':target} diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 0748170..5dce8a0 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -406,7 +406,7 @@ def add_track_to_album(track_id,album_id,replace=False,dbconn=None): def add_tracks_to_albums(track_to_album_id_dict,replace=False,dbconn=None): for track_id in track_to_album_id_dict: - add_track_to_album(track_id,track_to_album_id_dict[track_id],dbconn=dbconn) + add_track_to_album(track_id,track_to_album_id_dict[track_id],replace=replace,dbconn=dbconn) @connection_provider def remove_album(*track_ids,dbconn=None): From 8e06c343238d083bcbde4106c411128206f5b9d6 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 27 Dec 2023 16:11:03 +0100 Subject: [PATCH 03/18] Fallback to album art when no track image --- maloja/images.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/maloja/images.py b/maloja/images.py index 2025521..fdbd8a0 100644 --- a/maloja/images.py +++ b/maloja/images.py @@ -284,6 +284,12 @@ def image_request(artist_id=None,track_id=None,album_id=None): 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: + # fallback to album regardless of setting (because we have no image) + if track_id: + track = database.sqldb.get_track(track_id) + if track.get("album"): + album_id = database.sqldb.get_album_id(track["album"]) + return image_request(album_id=album_id) # use placeholder if malojaconfig["FANCY_PLACEHOLDER_ART"]: placeholder_url = "https://generative-placeholders.glitch.me/image?width=300&height=300&style=" From 1ce3119ddac8736eb90212f54dd166ab9e5d23e8 Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 27 Dec 2023 18:12:57 +0100 Subject: [PATCH 04/18] Design adjustments --- maloja/web/static/css/startpage.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/maloja/web/static/css/startpage.css b/maloja/web/static/css/startpage.css index f736b42..5507f01 100644 --- a/maloja/web/static/css/startpage.css +++ b/maloja/web/static/css/startpage.css @@ -22,8 +22,8 @@ div#startpage { @media (min-width: 1401px) and (max-width: 2200px) { div#startpage { - grid-template-columns: 45vw 45vw; - grid-template-rows: 45vh 45vh 45vh; + grid-template-columns: repeat(2, 45vw); + grid-template-rows: repeat(3, 30vh); grid-template-areas: "charts_artists lastscrobbles" From 20d8a109d644b41c07110de6d38a65d76774a033 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 01:22:40 +0100 Subject: [PATCH 05/18] Make first scrobble register slightly more efficient, close GH-308 --- maloja/database/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index e3978e0..6e8f51e 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -913,7 +913,7 @@ def start_db(): # inform time module about begin of scrobbling try: - firstscrobble = sqldb.get_scrobbles()[0] + firstscrobble = sqldb.get_scrobbles(limit=1)[0] register_scrobbletime(firstscrobble['time']) except IndexError: register_scrobbletime(int(datetime.datetime.now().timestamp())) From 4c487232c064b8074bc0a76f9e0efc46a14fd6e0 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 01:39:17 +0100 Subject: [PATCH 06/18] Add hint to server setup, close GH-272 --- maloja/web/jinja/admin_setup.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/web/jinja/admin_setup.jinja b/maloja/web/jinja/admin_setup.jinja index 9f1f008..42f15a4 100644 --- a/maloja/web/jinja/admin_setup.jinja +++ b/maloja/web/jinja/admin_setup.jinja @@ -56,7 +56,7 @@ If you use a Chromium-based browser and listen to music on Plex, Spotify, Soundcloud, Bandcamp or YouTube Music, download the extension and simply enter the server URL as well as your API key in the relevant fields. They will turn green if the server is accessible.

- You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter yourserver.tld/apis/audioscrobbler as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use yourserver.tld/apis/listenbrainz as the API URL and your API key as token. + You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter yourserver.tld/apis/audioscrobbler as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use yourserver.tld/apis/listenbrainz as the API URL (depending on the implementation, you might need to add a /1 at the end) and your API key as token.

If you use another browser or another music player, you could try to code your own extension. The API is super simple! Just send a POST HTTP request to From 966739e677907e904b5c6d978d6f6d2440eedc3a Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 01:41:43 +0100 Subject: [PATCH 07/18] Clarified setting, close GH-267 --- maloja/pkg_global/conf.py | 2 +- settings.md | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/maloja/pkg_global/conf.py b/maloja/pkg_global/conf.py index 0d404fd..16b132c 100644 --- a/maloja/pkg_global/conf.py +++ b/maloja/pkg_global/conf.py @@ -177,7 +177,7 @@ malojaconfig = Configuration( "name":(tp.String(), "Name", "Generic Maloja User") }, "Third Party Services":{ - "metadata_providers":(tp.List(tp.String()), "Metadata Providers", ['lastfm','spotify','deezer','audiodb','musicbrainz'], "Which metadata providers should be used in what order. Musicbrainz is rate-limited and should not be used first."), + "metadata_providers":(tp.List(tp.String()), "Metadata Providers", ['lastfm','spotify','deezer','audiodb','musicbrainz'], "List of which metadata providers should be used in what order. Musicbrainz is rate-limited and should not be used first."), "scrobble_lastfm":(tp.Boolean(), "Proxy-Scrobble to Last.fm", False), "lastfm_api_key":(tp.String(), "Last.fm API Key", None), "lastfm_api_secret":(tp.String(), "Last.fm API Secret", None), diff --git a/settings.md b/settings.md index 2038e53..425001b 100644 --- a/settings.md +++ b/settings.md @@ -32,14 +32,17 @@ Settings File | Environment Variable | Type | Description `cache_expire_negative` | `MALOJA_CACHE_EXPIRE_NEGATIVE` | Integer | Days until failed image fetches are reattempted `db_max_memory` | `MALOJA_DB_MAX_MEMORY` | Integer | RAM Usage in percent at which Maloja should no longer increase its database cache. `use_request_cache` | `MALOJA_USE_REQUEST_CACHE` | Boolean | Use request-local DB Cache -`use_global_cache` | `MALOJA_USE_GLOBAL_CACHE` | Boolean | Use global DB Cache +`use_global_cache` | `MALOJA_USE_GLOBAL_CACHE` | Boolean | This is vital for Maloja's performance. Do not disable this unless you have a strong reason to. **Fluff** `scrobbles_gold` | `MALOJA_SCROBBLES_GOLD` | Integer | How many scrobbles a track needs to be considered 'Gold' status `scrobbles_platinum` | `MALOJA_SCROBBLES_PLATINUM` | Integer | How many scrobbles a track needs to be considered 'Platinum' status `scrobbles_diamond` | `MALOJA_SCROBBLES_DIAMOND` | Integer | How many scrobbles a track needs to be considered 'Diamond' status +`scrobbles_gold_album` | `MALOJA_SCROBBLES_GOLD_ALBUM` | Integer | How many scrobbles an album needs to be considered 'Gold' status +`scrobbles_platinum_album` | `MALOJA_SCROBBLES_PLATINUM_ALBUM` | Integer | How many scrobbles an album needs to be considered 'Platinum' status +`scrobbles_diamond_album` | `MALOJA_SCROBBLES_DIAMOND_ALBUM` | Integer | How many scrobbles an album needs to be considered 'Diamond' status `name` | `MALOJA_NAME` | String | Name **Third Party Services** -`metadata_providers` | `MALOJA_METADATA_PROVIDERS` | List | Which metadata providers should be used in what order. Musicbrainz is rate-limited and should not be used first. +`metadata_providers` | `MALOJA_METADATA_PROVIDERS` | List | List of which metadata providers should be used in what order. Musicbrainz is rate-limited and should not be used first. `scrobble_lastfm` | `MALOJA_SCROBBLE_LASTFM` | Boolean | Proxy-Scrobble to Last.fm `lastfm_api_key` | `MALOJA_LASTFM_API_KEY` | String | Last.fm API Key `lastfm_api_secret` | `MALOJA_LASTFM_API_SECRET` | String | Last.fm API Secret @@ -55,6 +58,7 @@ Settings File | Environment Variable | Type | Description `send_stats` | `MALOJA_SEND_STATS` | Boolean | Send Statistics `proxy_images` | `MALOJA_PROXY_IMAGES` | Boolean | Whether third party images should be downloaded and served directly by Maloja (instead of just linking their URL) **Database** +`album_information_trust` | `MALOJA_ALBUM_INFORMATION_TRUST` | Choice | Whether to trust the first album information that is sent with a track or update every time a different album is sent `invalid_artists` | `MALOJA_INVALID_ARTISTS` | Set | Artists that should be discarded immediately `remove_from_title` | `MALOJA_REMOVE_FROM_TITLE` | Set | Phrases that should be removed from song titles `delimiters_feat` | `MALOJA_DELIMITERS_FEAT` | Set | Delimiters used for extra artists, even when in the title field @@ -62,14 +66,19 @@ Settings File | Environment Variable | Type | Description `delimiters_formal` | `MALOJA_DELIMITERS_FORMAL` | Set | Delimiters used to tag multiple artists when only one tag field is available `filters_remix` | `MALOJA_FILTERS_REMIX` | Set | Filters used to recognize the remix artists in the title `parse_remix_artists` | `MALOJA_PARSE_REMIX_ARTISTS` | Boolean | Parse Remix Artists +`week_offset` | `MALOJA_WEEK_OFFSET` | Integer | Start of the week for the purpose of weekly statistics. 0 = Sunday, 6 = Saturday +`timezone` | `MALOJA_TIMEZONE` | Integer | UTC Offset **Web Interface** -`default_range_charts_artists` | `MALOJA_DEFAULT_RANGE_CHARTS_ARTISTS` | Choice | Default Range Artist Charts -`default_range_charts_tracks` | `MALOJA_DEFAULT_RANGE_CHARTS_TRACKS` | Choice | Default Range Track Charts +`default_range_startpage` | `MALOJA_DEFAULT_RANGE_STARTPAGE` | Choice | Default Range for Startpage Stats `default_step_pulse` | `MALOJA_DEFAULT_STEP_PULSE` | Choice | Default Pulse Step `charts_display_tiles` | `MALOJA_CHARTS_DISPLAY_TILES` | Boolean | Display Chart Tiles +`album_showcase` | `MALOJA_ALBUM_SHOWCASE` | Boolean | Display a graphical album showcase for artist overview pages instead of a chart list `display_art_icons` | `MALOJA_DISPLAY_ART_ICONS` | Boolean | Display Album/Artist Icons +`default_album_artist` | `MALOJA_DEFAULT_ALBUM_ARTIST` | String | Default Albumartist +`use_album_artwork_for_tracks` | `MALOJA_USE_ALBUM_ARTWORK_FOR_TRACKS` | Boolean | Use Album Artwork for tracks +`fancy_placeholder_art` | `MALOJA_FANCY_PLACEHOLDER_ART` | Boolean | Use fancy placeholder artwork +`show_play_number_on_tiles` | `MALOJA_SHOW_PLAY_NUMBER_ON_TILES` | Boolean | Show amount of plays on tiles `discourage_cpu_heavy_stats` | `MALOJA_DISCOURAGE_CPU_HEAVY_STATS` | Boolean | Prevent visitors from mindlessly clicking on CPU-heavy options. Does not actually disable them for malicious actors! `use_local_images` | `MALOJA_USE_LOCAL_IMAGES` | Boolean | Use Local Images -`timezone` | `MALOJA_TIMEZONE` | Integer | UTC Offset `time_format` | `MALOJA_TIME_FORMAT` | String | Time Format `theme` | `MALOJA_THEME` | String | Theme From 472281230ccf048618be92fbe67cf23492740ff9 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 02:05:22 +0100 Subject: [PATCH 08/18] Make Maloja export file recognition more resilient, fix GH-309 --- maloja/proccontrol/tasks/export.py | 3 ++- maloja/proccontrol/tasks/import_scrobbles.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/maloja/proccontrol/tasks/export.py b/maloja/proccontrol/tasks/export.py index a1e2a2b..2b68919 100644 --- a/maloja/proccontrol/tasks/export.py +++ b/maloja/proccontrol/tasks/export.py @@ -12,11 +12,12 @@ def export(targetfolder=None): targetfolder = os.getcwd() timestr = time.strftime("%Y_%m_%d_%H_%M_%S") + timestamp = int(time.time()) # ok this is technically a separate time get from above, but those ms are not gonna matter, and im too lazy to change it all to datetime filename = f"maloja_export_{timestr}.json" outputfile = os.path.join(targetfolder,filename) assert not os.path.exists(outputfile) - data = {'scrobbles':get_scrobbles()} + data = {'maloja':{'export_time': timestamp },'scrobbles':get_scrobbles()} with open(outputfile,'w') as outfd: json.dump(data,outfd,indent=3) diff --git a/maloja/proccontrol/tasks/import_scrobbles.py b/maloja/proccontrol/tasks/import_scrobbles.py index 245a49b..986b86f 100644 --- a/maloja/proccontrol/tasks/import_scrobbles.py +++ b/maloja/proccontrol/tasks/import_scrobbles.py @@ -32,6 +32,8 @@ def import_scrobbles(inputf): } filename = os.path.basename(inputf) + importfunc = None + if re.match(r".*\.csv",filename): typeid,typedesc = "lastfm","Last.fm" @@ -62,7 +64,17 @@ def import_scrobbles(inputf): typeid,typedesc = "rockbox","Rockbox" importfunc = parse_rockbox - else: + elif re.match(r".*\.json",filename): + try: + with open(filename,'r') as fd: + data = json.load(fd) + if 'maloja' in data: + typeid,typedesc = "maloja","Maloja" + importfunc = parse_maloja + except Exception: + pass + + if not importfunc: print("File",inputf,"could not be identified as a valid import source.") return result From ea6e27de5ca2bb1a9ff659f64d072bd42d40834b Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 02:26:32 +0100 Subject: [PATCH 09/18] Add feedback for failed scrobble submission, fix GH-297 --- maloja/apis/native_v1.py | 8 ++++++++ maloja/database/exceptions.py | 7 +++++++ maloja/database/sqldb.py | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index aea7710..7a90501 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -82,6 +82,14 @@ errors = { 'desc':"This entity does not exist in the database." } }), + database.exceptions.DuplicateTimestamp: lambda e: (409,{ + "status":"error", + "error":{ + 'type':'duplicate_timestamp', + 'value':e.rejected_scrobble, + 'desc':"A scrobble is already registered with this timestamp." + } + }), images.MalformedB64: lambda e: (400,{ "status":"failure", "error":{ diff --git a/maloja/database/exceptions.py b/maloja/database/exceptions.py index 534dc1e..47abbd4 100644 --- a/maloja/database/exceptions.py +++ b/maloja/database/exceptions.py @@ -14,6 +14,13 @@ class ArtistExists(EntityExists): class AlbumExists(EntityExists): pass + +class DuplicateTimestamp(Exception): + def __init__(self,existing_scrobble,rejected_scrobble): + self.existing_scrobble = existing_scrobble + self.rejected_scrobble = rejected_scrobble + + class DatabaseNotBuilt(HTTPError): def __init__(self): super().__init__( diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 5dce8a0..2641c66 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -328,7 +328,10 @@ def album_dict_to_db(info,dbconn=None): @connection_provider def add_scrobble(scrobbledict,update_album=False,dbconn=None): - add_scrobbles([scrobbledict],update_album=update_album,dbconn=dbconn) + _, e = add_scrobbles([scrobbledict],update_album=update_album,dbconn=dbconn) + if e > 0: + raise exc.DuplicateTimestamp(existing_scrobble=None,rejected_scrobble=scrobbledict) + # TODO: actually pass existing scrobble @connection_provider def add_scrobbles(scrobbleslist,update_album=False,dbconn=None): From d160078def794361a28b10f3043928d42a84c9f2 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 02:45:46 +0100 Subject: [PATCH 10/18] Design adjustment --- maloja/web/static/css/maloja.css | 1 + 1 file changed, 1 insertion(+) diff --git a/maloja/web/static/css/maloja.css b/maloja/web/static/css/maloja.css index 41f321f..7f91dde 100644 --- a/maloja/web/static/css/maloja.css +++ b/maloja/web/static/css/maloja.css @@ -987,6 +987,7 @@ table.misc td { div.tiles { + max-height: 600px; display: grid; grid-template-columns: repeat(18, calc(100% / 18)); grid-template-rows: repeat(6, calc(100% / 6)); From 436b40821a5839fd2394e395739ca05779f7e01b Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 03:09:25 +0100 Subject: [PATCH 11/18] Return multiple top results for ranges, GH-278 --- maloja/apis/native_v1.py | 6 ++-- maloja/database/__init__.py | 68 +++++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/maloja/apis/native_v1.py b/maloja/apis/native_v1.py index 7a90501..042687f 100644 --- a/maloja/apis/native_v1.py +++ b/maloja/apis/native_v1.py @@ -482,7 +482,7 @@ def get_top_artists_external(k_filter, k_limit, k_delimit, k_amount): :rtype: Dictionary""" ckeys = {**k_limit, **k_delimit} - results = database.get_top_artists(**ckeys) + results = database.get_top_artists(**ckeys,compatibility=True) return { "status":"ok", @@ -501,7 +501,7 @@ def get_top_tracks_external(k_filter, k_limit, k_delimit, k_amount): :rtype: Dictionary""" ckeys = {**k_limit, **k_delimit} - results = database.get_top_tracks(**ckeys) + results = database.get_top_tracks(**ckeys,compatibility=True) # IMPLEMENT THIS FOR TOP TRACKS OF ARTIST/ALBUM AS WELL? return { @@ -521,7 +521,7 @@ def get_top_albums_external(k_filter, k_limit, k_delimit, k_amount): :rtype: Dictionary""" ckeys = {**k_limit, **k_delimit} - results = database.get_top_albums(**ckeys) + results = database.get_top_albums(**ckeys,compatibility=True) # IMPLEMENT THIS FOR TOP ALBUMS OF ARTIST AS WELL? return { diff --git a/maloja/database/__init__.py b/maloja/database/__init__.py index 6e8f51e..c8cc75d 100644 --- a/maloja/database/__init__.py +++ b/maloja/database/__init__.py @@ -42,6 +42,7 @@ from collections import namedtuple from threading import Lock import yaml, json import math +from itertools import takewhile # url handling import urllib @@ -570,7 +571,7 @@ def get_performance(dbconn=None,**keys): return results @waitfordb -def get_top_artists(dbconn=None,**keys): +def get_top_artists(dbconn=None,compatibility=True,**keys): separate = keys.get('separate') @@ -578,42 +579,73 @@ def get_top_artists(dbconn=None,**keys): results = [] for rng in rngs: - try: - 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,"real_scrobbles":0}) + result = {'range':rng} + res = get_charts_artists(timerange=rng,separate=separate,dbconn=dbconn) + + result['top'] = [ + {'artist': r['artist'], 'scrobbles': r['scrobbles'], 'real_scrobbles':r['real_scrobbles'], 'associated_artists': sqldb.get_associated_artists(r['artist'])} + for r in takewhile(lambda x:x['rank']==1,res) + ] + # for third party applications + if compatibility: + if result['top']: + result.update(result['top'][0]) + else: + result.update({'artist':None,'scrobbles':0,'real_scrobbles':0}) + + results.append(result) return results @waitfordb -def get_top_tracks(dbconn=None,**keys): +def get_top_tracks(dbconn=None,compatibility=True,**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_tracks(timerange=rng,dbconn=dbconn)[0] - results.append({"range":rng,"track":res["track"],"scrobbles":res["scrobbles"]}) - except Exception: - results.append({"range":rng,"track":None,"scrobbles":0}) + result = {'range':rng} + res = get_charts_tracks(timerange=rng,dbconn=dbconn) + + result['top'] = [ + {'track': r['track'], 'scrobbles': r['scrobbles']} + for r in takewhile(lambda x:x['rank']==1,res) + ] + # for third party applications + if compatibility: + if result['top']: + result.update(result['top'][0]) + else: + result.update({'track':None,'scrobbles':0}) + + results.append(result) return results @waitfordb -def get_top_albums(dbconn=None,**keys): +def get_top_albums(dbconn=None,compatibility=True,**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}) + + result = {'range':rng} + res = get_charts_tracks(timerange=rng,dbconn=dbconn) + + result['top'] = [ + {'album': r['album'], 'scrobbles': r['scrobbles']} + for r in takewhile(lambda x:x['rank']==1,res) + ] + # for third party applications + if compatibility: + if result['top']: + result.update(result['top'][0]) + else: + result.update({'album':None,'scrobbles':0}) + + results.append(result) return results From 1f1a65840c94f769cfd0d4f1c070e17e42782a53 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 03:47:07 +0100 Subject: [PATCH 12/18] Prefer real scrobbles in case of artist chart tie --- maloja/database/sqldb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 2641c66..555e094 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -1075,7 +1075,7 @@ def count_scrobbles_by_artist(since,to,associated=True,resolve_ids=True,dbconn=N DB['scrobbles'].c.timestamp.between(since,to) ).group_by( artistselect - ).order_by(sql.desc('count')) + ).order_by(sql.desc('count'),sql.desc('really_by_this_artist')) result = dbconn.execute(op).all() if resolve_ids: From b725c98fa521de2a86389f9c896dc1c393fefee5 Mon Sep 17 00:00:00 2001 From: krateng Date: Thu, 28 Dec 2023 04:05:15 +0100 Subject: [PATCH 13/18] Bump requirements --- pyproject.toml | 6 ++++-- requirements.txt | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 883a911..774afdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "malojaserver" version = "3.2.2" description = "Self-hosted music scrobble database" readme = "./README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = { file="./LICENSE" } authors = [ { name="Johannes Krattenmacher", email="maloja@dev.krateng.ch" } ] @@ -31,7 +31,9 @@ dependencies = [ "sqlalchemy>=2.0", "python-datauri>=1.1.0", "requests>=2.27.1", - "setuptools>68.0.0" + "setuptools>68.0.0", + "toml>=0.10.2", + "PyYAML>=6.0.1" ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index c869349..c24f84b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ sqlalchemy>=2.0 python-datauri>=1.1.0 requests>=2.27.1 setuptools>68.0.0 +toml>=0.10.2 +PyYAML>=6.0.1 \ No newline at end of file From f1c86973c9b80db1f56d5ba3ec28d149b9b38a61 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 1 Jan 2024 15:46:49 +0100 Subject: [PATCH 14/18] Fix incomplete scrobble results with associated artists --- maloja/database/sqldb.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/maloja/database/sqldb.py b/maloja/database/sqldb.py index 555e094..daaae0c 100644 --- a/maloja/database/sqldb.py +++ b/maloja/database/sqldb.py @@ -863,19 +863,24 @@ def get_scrobbles_of_artist(artist,since=None,to=None,resolve_references=True,li op = op.order_by(sql.desc('timestamp')) else: op = op.order_by(sql.asc('timestamp')) - if limit: + if limit and associated: + # if we count associated we cant limit here because we remove stuff later! op = op.limit(limit) result = dbconn.execute(op).all() # remove duplicates (multiple associated artists in the song, e.g. Irene & Seulgi being both counted as Red Velvet) # distinct on doesn't seem to exist in sqlite - seen = set() - filtered_result = [] - for row in result: - if row.timestamp not in seen: - filtered_result.append(row) - seen.add(row.timestamp) - result = filtered_result + if associated: + seen = set() + filtered_result = [] + for row in result: + if row.timestamp not in seen: + filtered_result.append(row) + seen.add(row.timestamp) + result = filtered_result + if limit: + result = result[:limit] + if resolve_references: From a7dcf6d41d67fe9175a6b1f7d125b541e4a5ddd3 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 1 Jan 2024 18:31:19 +0100 Subject: [PATCH 15/18] Upgrade base container to 3.19 --- Containerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Containerfile b/Containerfile index 9ae9198..0be4d51 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,4 @@ -FROM lsiobase/alpine:3.17 as base +FROM lsiobase/alpine:3.19 as base WORKDIR /usr/src/app @@ -32,13 +32,15 @@ RUN \ tzdata && \ echo "" && \ echo "**** install pip dependencies ****" && \ + python3 -m venv /venv && \ + . /venv/bin/activate && \ python3 -m ensurepip && \ - pip3 install -U --no-cache-dir \ + pip install -U --no-cache-dir \ pip \ wheel && \ echo "" && \ echo "**** install maloja requirements ****" && \ - pip3 install --no-cache-dir -r requirements.txt && \ + pip install --no-cache-dir -r requirements.txt && \ echo "" && \ echo "**** cleanup ****" && \ apk del --purge \ @@ -56,6 +58,8 @@ RUN \ echo "**** install maloja ****" && \ apk add --no-cache --virtual=install-deps \ py3-pip && \ + python3 -m venv /venv && \ + . /venv/bin/activate && \ pip3 install /usr/src/app && \ apk del --purge \ install-deps && \ From a4ae92e6421fcbff7a474e797ec766259261f848 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 1 Jan 2024 18:34:21 +0100 Subject: [PATCH 16/18] Fix start script --- container/root/etc/s6-overlay/s6-rc.d/svc-python/run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/root/etc/s6-overlay/s6-rc.d/svc-python/run b/container/root/etc/s6-overlay/s6-rc.d/svc-python/run index 276e6be..589d615 100755 --- a/container/root/etc/s6-overlay/s6-rc.d/svc-python/run +++ b/container/root/etc/s6-overlay/s6-rc.d/svc-python/run @@ -4,4 +4,4 @@ echo -e "\nMaloja is starting!" exec \ - s6-setuidgid abc python -m maloja run \ No newline at end of file + s6-setuidgid abc python -m venv /venv && s6-setuidgid abc /venv/bin/python -m maloja run \ No newline at end of file From c75bd4fcc3db8867ac6e1460bb861b55c49c3c67 Mon Sep 17 00:00:00 2001 From: krateng Date: Mon, 1 Jan 2024 19:15:17 +0100 Subject: [PATCH 17/18] This should work --- container/root/etc/s6-overlay/s6-rc.d/svc-python/run | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/container/root/etc/s6-overlay/s6-rc.d/svc-python/run b/container/root/etc/s6-overlay/s6-rc.d/svc-python/run index 589d615..1346fae 100755 --- a/container/root/etc/s6-overlay/s6-rc.d/svc-python/run +++ b/container/root/etc/s6-overlay/s6-rc.d/svc-python/run @@ -4,4 +4,4 @@ echo -e "\nMaloja is starting!" exec \ - s6-setuidgid abc python -m venv /venv && s6-setuidgid abc /venv/bin/python -m maloja run \ No newline at end of file + s6-setuidgid abc /venv/bin/python -m maloja run \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 774afdb..a73f88a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ dependencies = [ "bottle>=0.12.16", "waitress>=2.1.0", - "doreah>=2.0.0, <3", + "doreah>=2.0.1, <3", "nimrodel>=0.8.0", "setproctitle>=1.1.10", #"pyvips>=2.1.16", diff --git a/requirements.txt b/requirements.txt index c24f84b..7c78921 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bottle>=0.12.16 waitress>=2.1.0 -doreah>=2.0.0, <3 +doreah>=2.0.1, <3 nimrodel>=0.8.0 setproctitle>=1.1.10 jinja2>=3.0.0 From 259e3b06bbc8cc23e751553c5b6265be6696970f Mon Sep 17 00:00:00 2001 From: krateng Date: Wed, 3 Jan 2024 22:03:08 +0100 Subject: [PATCH 18/18] Fix GH-316 --- .github/FUNDING.yml | 1 + maloja/web/static/css/startpage.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d2c00ab..0109bce 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ custom: ["https://paypal.me/krateng"] +patreon: krateng diff --git a/maloja/web/static/css/startpage.css b/maloja/web/static/css/startpage.css index 5507f01..45958a5 100644 --- a/maloja/web/static/css/startpage.css +++ b/maloja/web/static/css/startpage.css @@ -23,7 +23,7 @@ div#startpage { @media (min-width: 1401px) and (max-width: 2200px) { div#startpage { grid-template-columns: repeat(2, 45vw); - grid-template-rows: repeat(3, 30vh); + grid-template-rows: repeat(3, 45vh); grid-template-areas: "charts_artists lastscrobbles"