malojaserver 3.2.1__py3-none-any.whl → 3.2.3__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. maloja/__main__.py +1 -1
  2. maloja/__pkginfo__.py +1 -1
  3. maloja/apis/_base.py +26 -19
  4. maloja/apis/_exceptions.py +1 -1
  5. maloja/apis/audioscrobbler.py +35 -7
  6. maloja/apis/audioscrobbler_legacy.py +5 -5
  7. maloja/apis/listenbrainz.py +7 -5
  8. maloja/apis/native_v1.py +43 -26
  9. maloja/cleanup.py +9 -7
  10. maloja/data_files/config/rules/predefined/krateng_kpopgirlgroups.tsv +4 -2
  11. maloja/database/__init__.py +55 -23
  12. maloja/database/associated.py +10 -6
  13. maloja/database/exceptions.py +28 -3
  14. maloja/database/sqldb.py +216 -168
  15. maloja/dev/profiler.py +3 -4
  16. maloja/images.py +6 -0
  17. maloja/malojauri.py +2 -0
  18. maloja/pkg_global/conf.py +97 -72
  19. maloja/proccontrol/tasks/export.py +2 -1
  20. maloja/proccontrol/tasks/import_scrobbles.py +57 -15
  21. maloja/server.py +4 -5
  22. maloja/setup.py +56 -44
  23. maloja/thirdparty/lastfm.py +18 -17
  24. maloja/web/jinja/abstracts/base.jinja +1 -1
  25. maloja/web/jinja/admin_albumless.jinja +2 -0
  26. maloja/web/jinja/admin_overview.jinja +3 -3
  27. maloja/web/jinja/admin_setup.jinja +1 -1
  28. maloja/web/jinja/error.jinja +2 -2
  29. maloja/web/jinja/partials/album_showcase.jinja +1 -1
  30. maloja/web/jinja/partials/awards_album.jinja +1 -1
  31. maloja/web/jinja/partials/awards_artist.jinja +2 -2
  32. maloja/web/jinja/partials/charts_albums_tiles.jinja +4 -0
  33. maloja/web/jinja/partials/charts_artists_tiles.jinja +5 -1
  34. maloja/web/jinja/partials/charts_tracks_tiles.jinja +4 -0
  35. maloja/web/jinja/snippets/entityrow.jinja +2 -2
  36. maloja/web/jinja/snippets/links.jinja +3 -1
  37. maloja/web/static/css/maloja.css +14 -2
  38. maloja/web/static/css/startpage.css +2 -2
  39. maloja/web/static/js/manualscrobble.js +1 -1
  40. maloja/web/static/js/notifications.js +16 -8
  41. {malojaserver-3.2.1.dist-info → malojaserver-3.2.3.dist-info}/METADATA +10 -46
  42. {malojaserver-3.2.1.dist-info → malojaserver-3.2.3.dist-info}/RECORD +45 -45
  43. {malojaserver-3.2.1.dist-info → malojaserver-3.2.3.dist-info}/WHEEL +1 -1
  44. {malojaserver-3.2.1.dist-info → malojaserver-3.2.3.dist-info}/LICENSE +0 -0
  45. {malojaserver-3.2.1.dist-info → malojaserver-3.2.3.dist-info}/entry_points.txt +0 -0
@@ -55,24 +55,25 @@ class LastFM(MetadataInterface, ProxyScrobbleInterface):
55
55
  })
56
56
 
57
57
  def authorize(self):
58
- try:
59
- response = requests.post(
60
- url=self.proxyscrobble['scrobbleurl'],
61
- params=self.query_compose({
62
- "method":"auth.getMobileSession",
63
- "username":self.settings["username"],
64
- "password":self.settings["password"],
65
- "api_key":self.settings["apikey"]
66
- }),
67
- headers={
68
- "User-Agent":self.useragent
69
- }
70
- )
58
+ if all(self.settings[key] not in [None,"ASK",False] for key in ["username","password","apikey","secret"]):
59
+ try:
60
+ response = requests.post(
61
+ url=self.proxyscrobble['scrobbleurl'],
62
+ params=self.query_compose({
63
+ "method":"auth.getMobileSession",
64
+ "username":self.settings["username"],
65
+ "password":self.settings["password"],
66
+ "api_key":self.settings["apikey"]
67
+ }),
68
+ headers={
69
+ "User-Agent":self.useragent
70
+ }
71
+ )
71
72
 
72
- data = ElementTree.fromstring(response.text)
73
- self.settings["sk"] = data.find("session").findtext("key")
74
- except Exception as e:
75
- log("Error while authenticating with LastFM: " + repr(e))
73
+ data = ElementTree.fromstring(response.text)
74
+ self.settings["sk"] = data.find("session").findtext("key")
75
+ except Exception as e:
76
+ log("Error while authenticating with LastFM: " + repr(e))
76
77
 
77
78
 
78
79
  # creates signature and returns full query
@@ -75,7 +75,7 @@
75
75
  <a href="/"><img style="display:block;" src="/favicon.png" /></a>
76
76
  </div>
77
77
  <div id="right-side">
78
- <span><input id="searchinput" placeholder="Search for an artist or track..." oninput="search(this)" onblur="clearresults()" /></span>
78
+ <span><input id="searchinput" placeholder="Search for an album, artist or track..." oninput="search(this)" onblur="clearresults()" /></span>
79
79
  </div>
80
80
 
81
81
 
@@ -6,6 +6,8 @@
6
6
  Here you can find tracks that currently have no album.<br/><br/>
7
7
 
8
8
  {% with list = dbc.get_tracks_without_album() %}
9
+ You have {{list|length}} tracks with no album.<br/><br/>
10
+
9
11
  {% include 'partials/list_tracks.jinja' %}
10
12
  {% endwith %}
11
13
 
@@ -67,9 +67,9 @@
67
67
  <li>manually scrobble from track pages</li>
68
68
  <li>delete scrobbles</li>
69
69
  <li>reparse scrobbles</li>
70
- <li>edit tracks and artists</li>
71
- <li>merge tracks and artists</li>
72
- <li>upload artist and track art by dropping a file on the existing image on an artist or track page</li>
70
+ <li>edit tracks, albums and artists</li>
71
+ <li>merge tracks, albums and artists</li>
72
+ <li>upload artist, album and track art by dropping a file on the existing image on an artist or track page</li>
73
73
  <li>see more detailed error pages</li>
74
74
  </ul>
75
75
 
@@ -56,7 +56,7 @@
56
56
 
57
57
  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.
58
58
  <br/><br/>
59
- You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter <span class="stats"><span name="serverurl">yourserver.tld</span>/apis/audioscrobbler</span> as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use <span class="stats"><span name="serverurl">yourserver.tld</span>/apis/listenbrainz</span> as the API URL and your API key as token.
59
+ You can also use any standard-compliant scrobbler. For GNUFM (audioscrobbler) scrobblers, enter <span class="stats"><span name="serverurl">yourserver.tld</span>/apis/audioscrobbler</span> as your Gnukebox server and your API key as the password. For Listenbrainz scrobblers, use <span class="stats"><span name="serverurl">yourserver.tld</span>/apis/listenbrainz</span> as the API URL (depending on the implementation, you might need to add a <span class="stats">/1</span> at the end) and your API key as token.
60
60
  <br/><br/>
61
61
  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
62
62
 
@@ -8,8 +8,8 @@
8
8
  <div style="background-image:url('/favicon.png')"></div>
9
9
  </td>
10
10
  <td class="text">
11
- <h1>{{ error_desc }}</h1><br/>
12
- {{ error_full_desc }}
11
+ <h1>{{ error_desc | e }}</h1><br/>
12
+ {{ error_full_desc | e }}
13
13
 
14
14
  </td>
15
15
  </tr>
@@ -29,7 +29,7 @@
29
29
  {% for entry in dbc.get_charts_albums(filterkeys,limitkeys,{'only_own_albums':False}) %}
30
30
 
31
31
 
32
- {% if artist not in (entry.album.artists or []) %}
32
+ {% if info.artist not in (entry.album.artists or []) %}
33
33
 
34
34
  {%- set cert = None -%}
35
35
  {%- if entry.scrobbles >= settings.scrobbles_gold_album -%}{% set cert = 'gold' %}{%- endif -%}
@@ -63,7 +63,7 @@
63
63
  {%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%}
64
64
 
65
65
  {%- if cert -%}
66
- <a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title }} has reached {{ cert.capitalize() }} status">
66
+ <a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title | e }} has reached {{ cert.capitalize() }} status">
67
67
  {% include 'icons/cert_track.jinja' %}
68
68
  </a>
69
69
  {%- endif %}
@@ -72,7 +72,7 @@
72
72
  {%- if e.scrobbles >= settings.scrobbles_diamond_album -%}{% set cert = 'diamond' %}{%- endif -%}
73
73
 
74
74
  {%- if cert -%}
75
- <a href='{{ links.url(e.album) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.album.albumtitle }} has reached {{ cert.capitalize() }} status">
75
+ <a href='{{ links.url(e.album) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.album.albumtitle | e }} has reached {{ cert.capitalize() }} status">
76
76
  {% include 'icons/cert_album.jinja' %}
77
77
  </a>
78
78
  {%- endif %}
@@ -87,7 +87,7 @@
87
87
  {%- if e.scrobbles >= settings.scrobbles_diamond -%}{% set cert = 'diamond' %}{%- endif -%}
88
88
 
89
89
  {%- if cert -%}
90
- <a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title }} has reached {{ cert.capitalize() }} status">
90
+ <a href='{{ links.url(e.track) }}' class="hidelink certified certified_{{ cert }} smallcerticon" title="{{ e.track.title | e }} has reached {{ cert.capitalize() }} status">
91
91
  {% include 'icons/cert_track.jinja' %}
92
92
  </a>
93
93
  {%- endif %}
@@ -16,10 +16,14 @@
16
16
  {% if entry is not none %}
17
17
  {% set album = entry.album %}
18
18
  {% set rank = entry.rank %}
19
+ {% set scrobbles = entry.scrobbles %}
19
20
  <div class="tile">
20
21
  <a href="{{ links.url(album) }}">
21
22
  <div class="lazy" data-bg="{{ images.get_album_image(album) }}"'>
22
23
  <span class='stats'>#{{ rank }}</span> <span>{{ album.albumtitle }}</span>
24
+ {% if settings['SHOW_PLAY_NUMBER_ON_TILES'] %}
25
+ <p class="scrobbles"><span>{{ scrobbles }} {{ 'play' if scrobbles == 1 else 'plays' }}</span> </p>
26
+ {% endif %}
23
27
  </div>
24
28
  </a>
25
29
  </div>
@@ -16,10 +16,14 @@
16
16
  {% if entry is not none %}
17
17
  {% set artist = entry.artist %}
18
18
  {% set rank = entry.rank %}
19
+ {% set scrobbles = entry.scrobbles %}
19
20
  <div class="tile">
20
21
  <a href="{{ links.url(artist) }}">
21
22
  <div class="lazy" data-bg="{{ images.get_artist_image(artist) }}"'>
22
- <span class='stats'>#{{ rank }}</span> <span>{{ artist }}</span>
23
+ <span class='stats'>#{{ rank }}</span> <span>{{ artist }}</span>
24
+ {% if settings['SHOW_PLAY_NUMBER_ON_TILES'] %}
25
+ <p class="scrobbles"><span>{{ scrobbles }} {{ 'play' if scrobbles == 1 else 'plays' }}</span> </p>
26
+ {% endif %}
23
27
  </div>
24
28
  </a>
25
29
  </div>
@@ -16,10 +16,14 @@
16
16
  {% if entry is not none %}
17
17
  {% set track = entry.track %}
18
18
  {% set rank = entry.rank %}
19
+ {% set scrobbles = entry.scrobbles %}
19
20
  <div class="tile">
20
21
  <a href="{{ links.url(track) }}">
21
22
  <div class="lazy" data-bg="{{ images.get_track_image(track) }}"'>
22
23
  <span class='stats'>#{{ rank }}</span> <span>{{ track.title }}</span>
24
+ {% if settings['SHOW_PLAY_NUMBER_ON_TILES'] %}
25
+ <p class="scrobbles"><span>{{ scrobbles }} {{ 'play' if scrobbles == 1 else 'plays' }}</span> </p>
26
+ {% endif %}
23
27
  </div>
24
28
  </a>
25
29
  </div>
@@ -20,11 +20,11 @@
20
20
  <td class='searchProvider'>{{ links.link_search(entity) }}</td>
21
21
  {% endif %}
22
22
  <td class='track'>
23
- <span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> – {{ links.link(entity) }}
23
+ <span class='artist_in_trackcolumn'>{{ links.links(entity.artists, restrict_amount=True) }}</span> – {{ links.link(entity) }}
24
24
  </td>
25
25
  {% elif entity is mapping and 'albumtitle' in entity %}
26
26
  <td class='album'>
27
- <span class='artist_in_trackcolumn'>{{ links.links(entity.artists) }}</span> – {{ links.link(entity) }}
27
+ <span class='artist_in_albumcolumn'>{{ links.links(entity.artists, restrict_amount=True) }}</span> – {{ links.link(entity) }}
28
28
  </td>
29
29
  {% else %}
30
30
  <td class='artist'>{{ links.link(entity) }}
@@ -8,9 +8,11 @@
8
8
  <a href="{{ url(entity) }}">{{ name | e }}</a>
9
9
  {%- endmacro %}
10
10
 
11
- {% macro links(entities) -%}
11
+ {% macro links(entities, restrict_amount=False) -%}
12
12
  {% if entities is none or entities == [] %}
13
13
  {{ settings["DEFAULT_ALBUM_ARTIST"] }}
14
+ {% elif entities.__len__() > 3 and restrict_amount %}
15
+ {{ link(entities[0]) }} et al.
14
16
  {% else %}
15
17
  {% for entity in entities -%}
16
18
  {{ link(entity) }}{{ ", " if not loop.last }}
@@ -363,12 +363,14 @@ div#notification_area {
363
363
  right:20px;
364
364
  }
365
365
  div#notification_area div.notification {
366
- background-color:white;
366
+ background-color:black;
367
367
  width:400px;
368
368
  min-height:50px;
369
369
  margin-bottom:7px;
370
370
  padding:9px;
371
- opacity:0.4;
371
+ opacity:0.5;
372
+ border-left: 8px solid var(--notification-color);
373
+ border-radius: 3px;
372
374
  }
373
375
  div#notification_area div.notification:hover {
374
376
  opacity:0.95;
@@ -781,6 +783,9 @@ table.list td.artists,td.artist,td.title,td.track {
781
783
  table.list td.track span.artist_in_trackcolumn {
782
784
  color: var(--text-color-secondary);
783
785
  }
786
+ table.list td.album span.artist_in_albumcolumn {
787
+ color: var(--text-color-secondary);
788
+ }
784
789
 
785
790
  table.list td.searchProvider {
786
791
  width: 20px;
@@ -987,6 +992,7 @@ table.misc td {
987
992
 
988
993
 
989
994
  div.tiles {
995
+ max-height: 600px;
990
996
  display: grid;
991
997
  grid-template-columns: repeat(18, calc(100% / 18));
992
998
  grid-template-rows: repeat(6, calc(100% / 6));
@@ -1069,6 +1075,12 @@ div.tiles span {
1069
1075
  overflow-wrap: anywhere;
1070
1076
  }
1071
1077
 
1078
+ div.tiles p.scrobbles {
1079
+ margin: 0;
1080
+ top:100%;
1081
+ position: sticky;
1082
+ }
1083
+
1072
1084
  div.tiles a:hover {
1073
1085
  text-decoration: none;
1074
1086
  }
@@ -22,8 +22,8 @@ div#startpage {
22
22
 
23
23
  @media (min-width: 1401px) and (max-width: 2200px) {
24
24
  div#startpage {
25
- grid-template-columns: 45vw 45vw;
26
- grid-template-rows: 45vh 45vh 45vh;
25
+ grid-template-columns: repeat(2, 45vw);
26
+ grid-template-rows: repeat(3, 45vh);
27
27
 
28
28
  grid-template-areas:
29
29
  "charts_artists lastscrobbles"
@@ -126,7 +126,7 @@ function scrobble(artists,title,albumartists,album,timestamp) {
126
126
  lastArtists = artists;
127
127
  lastTrack = title;
128
128
  lastAlbum = album;
129
- lastAlbumartists = albumartists;
129
+ lastAlbumartists = albumartists || [];
130
130
 
131
131
  var payload = {
132
132
  "artists":artists,
@@ -1,12 +1,14 @@
1
1
  // JS for feedback to the user whenever any XHTTP action is taken
2
2
 
3
3
  const colors = {
4
- 'warning':'red',
4
+ 'error': 'red',
5
+ 'warning':'#8ACC26',
5
6
  'info':'green'
6
7
  }
7
8
 
9
+
8
10
  const notification_template = info => `
9
- <div class="notification" style="background-color:${colors[info.notification_type]};">
11
+ <div class="notification" style="--notification-color: ${colors[info.notification_type]};">
10
12
  <b>${info.title}</b><br/>
11
13
  <span>${info.body}</span>
12
14
 
@@ -35,18 +37,24 @@ function notify(title,msg,notification_type='info',reload=false) {
35
37
  }
36
38
 
37
39
  function notifyCallback(request) {
38
- var body = request.response;
40
+ var response = request.response;
39
41
  var status = request.status;
40
42
 
41
43
  if (status == 200) {
42
- var notification_type = 'info';
44
+ if (response.hasOwnProperty('warnings') && response.warnings.length > 0) {
45
+ var notification_type = 'warning';
46
+ }
47
+ else {
48
+ var notification_type = 'info';
49
+ }
50
+
43
51
  var title = "Success!";
44
- var msg = body.desc || body;
52
+ var msg = response.desc || response;
45
53
  }
46
54
  else {
47
- var notification_type = 'warning';
48
- var title = "Error: " + body.error.type;
49
- var msg = body.error.desc || "";
55
+ var notification_type = 'error';
56
+ var title = "Error: " + response.error.type;
57
+ var msg = response.error.desc || "";
50
58
  }
51
59
 
52
60
 
@@ -1,17 +1,17 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: malojaserver
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: Self-hosted music scrobble database
5
5
  Keywords: scrobbling,music,selfhosted,database,charts,statistics
6
6
  Author-email: Johannes Krattenmacher <maloja@dev.krateng.ch>
7
- Requires-Python: >=3.10
7
+ Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
11
11
  Classifier: Operating System :: OS Independent
12
12
  Requires-Dist: bottle>=0.12.16
13
13
  Requires-Dist: waitress>=2.1.0
14
- Requires-Dist: doreah>=1.9.4, <2
14
+ Requires-Dist: doreah>=2.0.1, <3
15
15
  Requires-Dist: nimrodel>=0.8.0
16
16
  Requires-Dist: setproctitle>=1.1.10
17
17
  Requires-Dist: jinja2>=3.0.0
@@ -21,6 +21,8 @@ Requires-Dist: sqlalchemy>=2.0
21
21
  Requires-Dist: python-datauri>=1.1.0
22
22
  Requires-Dist: requests>=2.27.1
23
23
  Requires-Dist: setuptools>68.0.0
24
+ Requires-Dist: toml>=0.10.2
25
+ Requires-Dist: PyYAML>=6.0.1
24
26
  Requires-Dist: pyvips>=2.1 ; extra == "full"
25
27
  Project-URL: documentation, https://github.com/krateng/maloja
26
28
  Project-URL: homepage, https://github.com/krateng/maloja
@@ -69,15 +71,8 @@ You can check [my own Maloja page](https://maloja.krateng.ch) as an example inst
69
71
 
70
72
  ## How to install
71
73
 
72
- ### Requirements
73
-
74
- Maloja should run on any x86 or ARM machine that runs Python.
75
-
76
- It is highly recommended to use **Docker** or **Podman**.
77
-
78
- 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.
79
-
80
- ### Docker / Podman
74
+ To avoid issues with version / dependency mismatches, Maloja should only be used in **Docker** or **Podman**, not on bare metal.
75
+ I cannot offer any help for bare metal installations.
81
76
 
82
77
  Pull the [latest image](https://hub.docker.com/r/krateng/maloja) or check out the repository and use the included Containerfile.
83
78
 
@@ -96,11 +91,7 @@ An example of a minimum run configuration to access maloja via `localhost:42010`
96
91
  docker run -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
97
92
  ```
98
93
 
99
- #### Linux Host
100
-
101
- **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.
102
-
103
- 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)
94
+ If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) the following DOES NOT apply to you, but 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)
104
95
 
105
96
  To get the UID and GID for the current user run these commands from a terminal:
106
97
 
@@ -113,33 +104,6 @@ The modified run command with these variables would look like:
113
104
  docker run -e PUID=1000 -e PGID=1001 -p 42010:42010 -v $PWD/malojadata:/mljdata -e MALOJA_DATA_DIRECTORY=/mljdata krateng/maloja
114
105
  ```
115
106
 
116
- ### PyPI
117
-
118
- You can install Maloja with
119
-
120
- ```console
121
- pip install malojaserver
122
- ```
123
-
124
- To make sure all dependencies are installed, you can also use one of the included scripts in the `install` folder.
125
-
126
- ### From Source
127
-
128
- Clone this repository and enter the directory with
129
-
130
- ```console
131
- git clone https://github.com/krateng/maloja
132
- cd maloja
133
- ```
134
-
135
- Then install all the requirements and build the package, e.g.:
136
-
137
- ```console
138
- sh ./install/install_dependencies_alpine.sh
139
- pip install -r requirements.txt
140
- pip install .
141
- ```
142
-
143
107
 
144
108
  ### Extras
145
109
 
@@ -160,7 +124,7 @@ When not running in a container, you can run the application with `maloja run`.
160
124
 
161
125
  If you would like to import your previous scrobbles, use the command `maloja import *filename*`. This works on:
162
126
 
163
- * a Last.fm export generated by [benfoxall's website](https://benjaminbenben.com/lastfm-to-csv/) ([GitHub page](https://github.com/benfoxall/lastfm-to-csv))
127
+ * a Last.fm export generated by [ghan64's website](https://lastfm.ghan.nl/export/)
164
128
  * an official [Spotify data export file](https://www.spotify.com/us/account/privacy/)
165
129
  * an official [ListenBrainz export file](https://listenbrainz.org/profile/export/)
166
130
  * the export of another Maloja instance