Eat your greens, especially broccoli

This commit is contained in:
nik gaffney 2023-05-18 21:16:31 +02:00
commit d1ec6704c7
5 changed files with 1010 additions and 0 deletions

252
README.org Normal file
View file

@ -0,0 +1,252 @@
# -*- mode: org; coding: utf-8; -*-
#+OPTIONS: toc:2 num:nil html-style:nil
#+author:
#+title: MusicBrainz & ListenBrainz & other
[[file:img/musicbrainz-logo.svg]]
MusicBrainz is a community-maintained open source encyclopedia of [[https://musicbrainz.org/doc/About][music information]]. The REST-based [[https://musicbrainz.org/doc/MusicBrainz_API][webservice API]] can be used for direct access to MusicBrainz data with output in XML and JSON.
This code provides a simple, incomplete yet possibly useful interface to some of the MusicBrainz and ListenBrainz APIs from emacs for exploratory use in =org-mode= or behind the scenes sending listening metadata.
** some examples
#+BEGIN_SRC emacs-lisp
(musicbrainz-search "recording" "taema")
#+END_SRC
Autechre albums & eps
docs provide the example URL https://musicbrainz.org/ws/2/release-group?artist=410c9baf-5469-44f6-9852-826524b80c61&type=album|ep
#+BEGIN_SRC emacs-lisp
(musicbrainz-browse "release-group" "artist" "410c9baf-5469-44f6-9852-826524b80c61" "album|ep")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(musicbrainz-search-artist "Autechre")
#+END_SRC
#+RESULTS:
| Autechre | 410c9baf-5469-44f6-9852-826524b80c61 |
#+BEGIN_SRC emacs-lisp
(musicbrainz-search-label "Warp")
#+END_SRC
#+RESULTS:
| Warp | 46f0f4cd-8aab-4b33-b698-f459faf64190 |
#+BEGIN_SRC emacs-lisp
(musicbrainz-search-label "Music" 5)
#+END_SRC
#+RESULTS:
| 100 | Sony Music | global brand, excluding JP, owned by Sony Music Entertainment (1991 ongoing) | 9e6b4d7f-4958-4db7-8504-d89e315836af |
| 96 | [no label] | Special purpose label white labels, self-published releases and other “no label” releases (ongoing) | 157afde4-4bf5-4039-8ad2-5a15acc85176 |
| 91 | Polydor | worldwide imprint, see annotation (1913-04-02 ongoing) | ce24ab18-1bd6-4293-a486-546d13d6a5e2 |
| 91 | Universal Music | plain logo: “Universal Music” (ongoing) | 13a464dc-b9fd-4d16-a4f4-d4316f6a46c7 |
| 90 | ZYX Music | (1992 ongoing) | 6844efda-a451-431e-8cc1-48ab111b4711 |
** MBID
“One of MusicBrainz' aims is to be the universal lingua franca for music by providing a reliable and unambiguous form of music identification; this music identification is performed through the use of MusicBrainz Identifiers (MBIDs). An MBID is a 36 character Universally Unique Identifier that is permanently assigned to each entity in the database, i.e. artists, release groups, releases, recordings, works, labels, areas, places and URLs. MBIDs are also assigned to Tracks, though tracks do not share many other properties of entities.”
https://musicbrainz.org/doc/MusicBrainz_Identifier
#+BEGIN_SRC emacs-lisp
(musicbrainz-artist-to-mbid "Autechre")
#+END_SRC
=410c9baf-5469-44f6-9852-826524b80c61=
#+BEGIN_SRC emacs-lisp
(musicbrainz-mbid-p "410c9baf-5469-44f6-9852-826524b80c61")
#+END_SRC
** ambiguity
from the docs…
#+BEGIN_SRC text
John Williams, the soundtrack composer and conductor, has an artist MBID of 53b106e7-0cc6-42cc-ac95-ed8d30a3a98e
John Williams, the classical guitar player, has an artist MBID of 8b8a38a9-a290-4560-84f6-3d4466e8d791
#+END_SRC
#+BEGIN_SRC emacs-lisp
(musicbrainz-disambiguate-artist "John Williams")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(musicbrainz-artist-to-mbid "Bad Seeds")
#+END_SRC
=7feb02f2-51fa-422d-838e-2c14ecb4c7b8= → Tomorrows Bad Seeds
#+BEGIN_SRC emacs-lisp
(musicbrainz-disambiguate-artist "Bad Seeds")
#+END_SRC
#+RESULTS:
| | Artist: Bad Seeds | MBID |
| 100 | Tomorrows Bad Seeds, nil | [[https://musicbrainz.org/artist/7feb02f2-51fa-422d-838e-2c14ecb4c7b8][7feb02f2-51fa-422d-838e-2c14ecb4c7b8]] |
| 98 | The Bad Seeds, 60s Texas rock band | [[https://musicbrainz.org/artist/3e593712-9f70-4b7a-b21b-466016998a3d][3e593712-9f70-4b7a-b21b-466016998a3d]] |
| 98 | The Bad Seeds, 60's US garage rock band from Erlanger, KY | [[https://musicbrainz.org/artist/34bc9a97-fa78-424e-8ca8-a904f978f041][34bc9a97-fa78-424e-8ca8-a904f978f041]] |
| 98 | The Bad Seeds, backing band for Nick Cave | [[https://musicbrainz.org/artist/eb2a8edc-5670-4896-82be-87db38de9583][eb2a8edc-5670-4896-82be-87db38de9583]] |
| 86 | Nick Cave & the Bad Seeds, nil | [[https://musicbrainz.org/artist/172e1f1a-504d-4488-b053-6344ba63e6d0][172e1f1a-504d-4488-b053-6344ba63e6d0]] |
| 50 | The Lightning Seeds, nil | [[https://musicbrainz.org/artist/1ba601a0-3401-4b28-8ddd-9af8203661e8][1ba601a0-3401-4b28-8ddd-9af8203661e8]] |
[[file:img/listenbrainz-logo.svg]]
* listening
- https://listenbrainz.org
- https://listenbrainz.readthedocs.io/
* examples
#+BEGIN_SRC emacs-lisp
(setq listenbrainz-api-token "000-000-000")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-validate-token listenbrainz-api-token)
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-listens "zzzkt")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-listens "zzzkt" 33)
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-submit-single-listen "farmersmanual" "808808008088 (11)")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-submit-single-listen "Matthew Thomas" "Taema" "Architecture")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-submit-playing-now "farmersmanual" "808808008088 (11)")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-playing-now "zzzkt")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-stats-artists "zzzkt") ;; defaults to all time
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-stats-releases "zzzkt")
#+END_SRC
#+BEGIN_SRC emacs-lisp
(listenbrainz-stats-recordings "zzzkt" 13 "month")
#+END_SRC
* incompleteness
** Core API endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
| POST /1/submit-listens | listenbrainz-submit-listen |
| | listenbrainz-submit-single-listen |
| | listenbrainz-submit-playing-now |
| GET /1/validate-token | listenbrainz-validate-token |
| POST /1/delete-listen | - |
| GET /1/user/(playlist_user_name)/playlists/collaborator | - |
| GET /1/user/(playlist_user_name)/playlists/createdfor | - |
| GET /1/users/(user_list)/recent-listens | - |
| GET /1/user/(user_name)/similar-users | - |
| GET /1/user/(user_name)/listen-count | - |
| GET /1/user/(user_name)/playing-now | listenbrainz-playing-now |
| GET /1/user/(user_name)/similar-to/(other_user_name) | - |
| GET /1/user/(playlist_user_name)/playlists | - |
| GET /1/user/(user_name)/listens | listenbrainz-listens |
| GET /1/latest-import | - |
| POST /1/latest-import | - |
** Feedback API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#feedback-api-endpoints
| POST /1/feedback/recording-feedback | - |
| GET /1/feedback/recording/(recording_msid)/get-feedback | - |
| GET /1/feedback/user/(user_name)/get-feedback-for-recordings | |
| GET /1/feedback/user/(user_name)/get-feedback | - |
** Recording Recommendation API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
| GET /1/cf/recommendation/user/(user_name)/recording | - |
** Recording Recommendation Feedback API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#recording-recommendation-feedback-api-endpoints
| POST /1/recommendation/feedback/submit | - |
| POST /1/recommendation/feedback/delete | - |
| GET /1/recommendation/feedback/user/(user_name)/recordings | - |
| GET /1/recommendation/feedback/user/(user_name) | - |
** Statistics API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
| GET /1/stats/sitewide/artists | - |
| GET /1/stats/user/(user_name)/listening-activity | - |
| GET /1/stats/user/(user_name)/daily-activity | - |
| GET /1/stats/user/(user_name)/recordings | listenbrainz-stats-recordings |
| GET /1/stats/user/(user_name)/artist-map | - |
| GET /1/stats/user/(user_name)/releases | listenbrainz-stats-releases |
| GET /1/stats/user/(user_name)/artists | listenbrainz-stats-artists |
** Status API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#status-api-endpoints
| GET /1/status/get-dump-info | - |
** User Timeline API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#user-timeline-api-endpoints
| POST /1/user/(user_name)/timeline-event/create/notification | - |
| POST /1/user/(user_name)/timeline-event/create/recording | - |
| POST /1/user/(user_name)/feed/events/delete | - |
| GET /1/user/(user_name)/feed/events | - |
** Social API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
| GET /1/user/(user_name)/followers | listenbrainz-followers |
| GET /1/user/(user_name)/following | listenbrainz-following |
| POST /1/user/(user_name)/unfollow | - |
| POST /1/user/(user_name)/follow | - |
** Pinned Recording API Endpoints
https://listenbrainz.readthedocs.io/en/production/dev/api/#pinned-recording-api-endpoints
| POST /1/pin/unpin | - |
| POST /1/pin | - |
| POST /1/pin/delete/(row_id) | - |
| GET /1/(user_name)/pins/following | - |
| GET /1/(user_name)/pins | - |
* further
- https://listenbrainz.org/user/troi-bot/playlists/
- https://github.com/metabrainz/bono-data-sets/blob/main/top_discoveries.py

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

1
img/musicbrainz-logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

452
listenbrainz.el Normal file
View file

@ -0,0 +1,452 @@
;;; listenbrainz.el --- ListenBrainz API interface -*- coding: utf-8; lexical-binding: t -*-
;; Copyright 2023 FoAM
;;
;; Author: nik gaffney <nik@fo.am>
;; Created: 2023-05-05
;; Version: 0.1
;; Package-Requires: ((emacs "27.1") (request "0.3"))
;; Keywords: music, scrobbling, multimedia
;; URL: https://github.com/zzkt/listenbrainz
;; This file is not part of GNU Emacs.
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; - listen & submit metadata to ListenBrainz
;; - partial & incomplete
;; - no error checks
;; - sync -> async
;;; Code:
(require 'request)
(require 'json)
;;; ;; ;; ; ; ; ; ; ;
;;
;; API config
;;
;;; ; ;; ;;
(defcustom listenbrainz-api-url "https://api.listenbrainz.org"
"URL for listenbrainz API.
Documentation available at https://listenbrainz.readthedocs.io/"
:type 'string
:group 'listenbrainz)
(defcustom listenbrainz-api-token ""
"An auth token is required for some functions.
Details can be found near https://listenbrainz.org/profile/"
:type 'string
:group 'listenbrainz)
;;; ;; ;; ; ; ; ; ; ;
;;
;; Constants that are relevant to using the API
;; https://listenbrainz.readthedocs.io/en/production/dev/api/#constants
;;
;; ;; ; ; ;
(defconst listenbrainz--MAX_LISTEN_SIZE 10240
"Maximum overall listen size in bytes, to prevent egregious spamming.
listenbrainz.webserver.views.api_tools.MAX_LISTEN_SIZE = 10240")
(defconst listenbrainz--MAX_ITEMS_PER_GET 100
"The maximum number of listens returned in a single GET request.
listenbrainz.webserver.views.api_tools.MAX_ITEMS_PER_GET = 100")
(defconst listenbrainz--DEFAULT_ITEMS_PER_GET 25
"The default number of listens returned in a single GET request.
listenbrainz.webserver.views.api_tools.DEFAULT_ITEMS_PER_GET = 25")
(defconst listenbrainz--MAX_TAGS_PER_LISTEN 50
"The maximum number of tags per listen.
listenbrainz.webserver.views.api_tools.MAX_TAGS_PER_LISTEN = 50")
(defconst listenbrainz--MAX_TAG_SIZE 64
"The maximum length of a tag.
listenbrainz.webserver.views.api_tools.MAX_TAG_SIZE = 64")
;;; ;; ;; ; ; ; ; ; ;
;;
;; Timestamps
;;
;;; ;; ; ;
(defun listenbrainz-timestamp (&optional time)
"Return a ListenBrainz compatible timestamp for the `current-time' or TIME.
All timestamps used in ListenBrainz are UNIX epoch timestamps in UTC."
(if time (time-convert time 'integer)
(time-convert (current-time) 'integer)))
;;; ;; ;; ; ; ; ; ; ;
;;
;; Formatting & formatters
;;
;;;; ; ;; ;
(defmacro listenbrainz--deformatter (name format-string format-args alist)
"Generate function with NAME to format data returned from an API call.
The function has the name `listenbrainz--format-NAME`.
The ALIST is the relevant section of the response payload in dotted form as
seen in `let-alist'. The FORMAT-STRING and FORMAT-ARGS are applied to each
element in ALIST and also assumed to be accessors for the ALIST, but can
be any valid `format' string.
For example, format track info from .payload.listens as an `org-mode' table.
(listenbrainz--deformatter (\"listens\"
\"%s | %s | %s | %s |\n\"
(.track_metadata.artist_name
.track_metadata.track_name
.track_metadata.release_name
.recording_msid)
.payload.listens))
macroexpands to something like ->
(defun listenbrainz--format-listens (data)
(let-alist data
(seq-map
(lambda (i)
(let-alist i
(format \"%s | %s | %s | %s |\n\"
.track_metadata.artist_name
.track_metadata.track_name
.track_metadata.release_name
.recording_msid
)))
.payload.listens)))"
(let ((f (intern (concat "listenbrainz--format-" name)))
(doc "Some details from listening data."))
`(defun ,f (data) ,doc
(let-alist data
(seq-map
(lambda (i)
(let-alist i
(format ,format-string ,@format-args)))
,alist)))))
;; Core API formatters
;; listens -> listenbrainz--format-listens
(listenbrainz--deformatter "listens"
"%s | %s |\n"
(.track_metadata.artist_name
.track_metadata.track_name)
.payload.listens)
;; playing now -> listenbrainz--format-playing
(listenbrainz--deformatter "playing"
"%s | %s |\n"
(.track_metadata.artist_name
.track_metadata.track_name)
.payload.listens)
;; Statistics API formatters
;; releases -> listenbrainz--format-stats-0
(listenbrainz--deformatter "stats-0"
"%s | %s | %s |\n"
(.artist_name .release_name .listen_count)
.payload.releases)
;; artists -> listenbrainz--format-stats-1
(listenbrainz--deformatter "stats-1"
"%s | %s |\n"
(.artist_name .listen_count)
.payload.artists)
;; recordings -> listenbrainz--format-stats-2
(listenbrainz--deformatter "stats-2"
"%s | %s | %s |\n"
(.artist_name .track_name .listen_count)
.payload.recordings)
;; Social API formatters
;; follows -> listenbrainz--format-followers-list
(listenbrainz--deformatter "followers-list"
"%s |\n"
(i) ;; note scope
.followers)
;; follows -> listenbrainz--format-followers-graph
(listenbrainz--deformatter "followers-graph"
"%s -> %s |\n"
(i (cdadr data)) ;; note scope
.followers)
;; following -> listenbrainz--format-following
(listenbrainz--deformatter "following"
"%s |\n"
(i) ;; note scope
.following)
;;; ;; ;; ; ; ; ; ; ;
;;
;; Core API Endpoints
;; https://listenbrainz.readthedocs.io/en/production/dev/api/#core-api-endpoints
;;
;;; ; ;; ; ; ;
;;;###autoload
(defun listenbrainz-validate-token (token)
"Check if TOKEN is valid. Return a username or nil."
(message "listenbrainz: checking token %s" token)
(let ((response
(request-response-data
(request
(format "%s/1/validate-token" listenbrainz-api-url)
:type "GET"
:headers (list `("Authorization" . ,(format "Token %s" token)))
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(if (eq t (assoc-default 'valid data))
(message "Token is valid for user: %s"
(assoc-default 'user_name data))
(message "Not a valid user token"))))))))
;; return user_name or nil
(if (assoc-default 'user_name response)
(format "%s" (assoc-default 'user_name response))
nil)))
;;;###autoload
(defun listenbrainz-listens (username &optional count)
"Get listing data for USERNAME (optionally get COUNT number of items)."
(message "listenbrainz: getting listens for %s" username)
(let* ((limit (if count count 25))
(response
(request-response-data
(request
(format "%s/1/user/%s/listens" listenbrainz-api-url username)
:type "GET"
:params (list `("count" . ,limit))
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Listens for user: %s" username)))))))
(princ (listenbrainz--format-listens response))))
;;;###autoload
(defun listenbrainz-playing-now (username)
"Get `playing now' info for USERNAME."
(message "listenbrainz: getting playing now for %s" username)
(let* ((response
(request-response-data
(request
(format "%s/1/user/%s/playing-now" listenbrainz-api-url username)
:type "GET"
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "User playing now: %s" username)))))))
(princ (listenbrainz--format-playing response))))
;; see
;; - https://listenbrainz.readthedocs.io/en/production/dev/api-usage/#submitting-listens
;; - https://listenbrainz.readthedocs.io/en/production/dev/json/#json-doc
(defun listenbrainz-submit-listen (type artist track &optional release)
"Submit listening data to ListenBrainz.
- listen TYPE (string) either \='single\=', \='import\=' or \='playing_now\='
- ARTIST name (string)
- TRACK title (string)
- RELEASE title (string) also album title."
(message "listenbrainz: submitting %s - %s - %s" artist track release)
(let* ((json-null "")
(now (listenbrainz-timestamp))
(token (format "Token %s" listenbrainz-api-token))
(listen (json-encode
(list
(cons "listen_type" type)
(list "payload"
(remove nil
(list
(when (string= type "single") (cons "listened_at" now))
(list "track_metadata"
(cons "artist_name" artist)
(cons "track_name" track)
;; (cons "release_name" release)
))))))))
(request
(format "%s/1/submit-listens" listenbrainz-api-url)
:type "POST"
:data listen
:headers (list '("Content-Type" . "application/json")
`("Authorization" . ,token))
:parser 'json-read
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "status: %s" (assoc-default 'status data)))))))
;;;###autoload
(defun listenbrainz-submit-single-listen (artist track &optional release)
"Submit data for a single track (ARTIST TRACK and optional RELEASE)."
(listenbrainz-submit-listen "single" artist track (when release release)))
;;;###autoload
(defun listenbrainz-submit-playing-now (artist track &optional release)
"Submit data for track (ARTIST TRACK and optional RELEASE) playing now."
(listenbrainz-submit-listen "playing_now" artist track (when release release)))
;;; ;; ;; ; ; ; ; ; ;
;;
;; Statistics API Endpoints
;; https://listenbrainz.readthedocs.io/en/production/dev/api/#statistics-api-endpoints
;;
;; ; ;; ; ;
;;;###autoload
(defun listenbrainz-stats-recordings (username &optional count range)
"Get top tracks for USERNAME (optionally get COUNT number of items.
RANGE (str) Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
(message "listenbrainz: getting top releases for %s" username)
(let* ((limit (if count count 25))
(range (if range range "all_time"))
(response
(request-response-data
(request
(format "%s/1/stats/user/%s/recordings" listenbrainz-api-url username)
:type "GET"
:params (list `("count" . ,limit)
`("range" . ,range))
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Top recordings for user: %s" username)))))))
(princ (listenbrainz--format-stats-2 response))))
;;;###autoload
(defun listenbrainz-stats-releases (username &optional count range)
"Get top releases for USERNAME (optionally get COUNT number of items.
RANGE (str) Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
(message "listenbrainz: getting top releases for %s" username)
(let* ((limit (if count count 25))
(range (if range range "all_time"))
(response
(request-response-data
(request
(format "%s/1/stats/user/%s/releases" listenbrainz-api-url username)
:type "GET"
:params (list `("count" . ,limit)
`("range" . ,range))
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Top releases for user: %s" username)))))))
(princ (listenbrainz--format-stats-0 response))))
;;;###autoload
(defun listenbrainz-stats-artists (username &optional count range)
"Get top artists for USERNAME (optionally get COUNT number of items.
RANGE (str) Optional, time interval for which statistics should be collected,
possible values are week, month, year, all_time, defaults to all_time."
(message "listenbrainz: getting top artists for %s" username)
(let* ((limit (if count count 25))
(range (if range range "all_time"))
(response
(request-response-data
(request
(format "%s/1/stats/user/%s/artists" listenbrainz-api-url username)
:type "GET"
:params (list `("count" . ,limit)
`("range" . ,range))
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Top artists for user: %s" username)))))))
(princ (listenbrainz--format-stats-1 response))))
;;; ;; ;; ; ; ; ; ; ;
;;
;; Social API Endpoints
;; https://listenbrainz.readthedocs.io/en/production/dev/api/#social-api-endpoints
;;
;;; ; ; ;;; ;
;;;###autoload
(defun listenbrainz-followers (username &optional output)
"Fetch the list of followers of USERNAME.
OUTPUT format can be either `list' (default) or `graph'."
(message "listenbrainz: getting followers for %s" username)
(let* ((response
(request-response-data
(request
(format "%s/1/user/%s/followers" listenbrainz-api-url username)
:type "GET"
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Followers for %s" username)))))))
(if (string= "graph" output)
(princ (listenbrainz--format-followers-graph response))
(princ (listenbrainz--format-followers-list response)))))
;;;###autoload
(defun listenbrainz-following (username)
"Fetch the list of users followed by USERNAME."
(message "listenbrainz: getting users %s is following" username)
(let* ((response
(request-response-data
(request
(format "%s/1/user/%s/following" listenbrainz-api-url username)
:type "GET"
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Users %s is following" username)))))))
(princ (listenbrainz--format-following response))))
;;;
(provide 'listenbrainz)
;;; listenbrainz.el ends here

304
musicbrainz.el Normal file
View file

@ -0,0 +1,304 @@
;;; musicbrainz.el --- MusicBrainz API interface -*- coding: utf-8; lexical-binding: t -*-
;; Copyright 2023 FoAM
;;
;; Author: nik gaffney <nik@fo.am>
;; Created: 2023-05-05
;; Version: 0.1
;; Package-Requires: ((emacs "27.1") (request "0.3"))
;; Keywords: music, scrobbling, multimedia
;; URL: https://github.com/zzkt/metabrainz
;; This file is not part of GNU Emacs.
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; - basic MusicBrainz interface
;; - partial & incomplete
;; - no error checks
;; - sync -> async
;;; Code:
(require 'request)
(require 'json)
;;; ;; ;; ; ; ; ; ; ;
;;
;; API config
;;
;;; ; ;; ;;
(defcustom musicbrainz-api-url "https://musicbrainz.org/ws/2"
"URL for musicbrainz API.
Documentation available at https://musicbrainz.org/doc/MusicBrainz_API"
:type 'string
:group 'musicbrainz)
(defcustom musicbrainz-api-token ""
"An auth token is required for some functions."
:type 'string
:group 'musicbrainz)
;;; ; ; ;;; ; ;
;;
;; API entities
;; https://musicbrainz.org/doc/MusicBrainz_API#Browse
;;
;; On each entity resource, you can perform three different GET requests:
;; lookup: /<ENTITY_TYPE>/<MBID>?inc=<INC>
;; browse: /<RESULT_ENTITY_TYPE>?<BROWSING_ENTITY_TYPE>=<MBID>&limit=<LIMIT>&offset=<OFFSET>&inc=<INC>
;; search: /<ENTITY_TYPE>?query=<QUERY>&limit=<LIMIT>&offset=<OFFSET>
;;
;; Note: Keep in mind only the search request is available without an MBID
;; (or, in specific cases, a disc ID, ISRC or ISWC). If all you have is the
;; name of an artist or album, for example, you'll need to make a search and
;; pick the right result to get its MBID; only then will you able to use it
;; in a lookup or browse request.
;;
;; On the genre resource, we support an "all" sub-resource to fetch all genres,
;; paginated, in alphabetical order:
;;
;; all: /genre/all?limit=<LIMIT>&offset=<OFFSET>
;;
;; ; ;;; ;
(defconst musicbrainz-entities-core
(list "area" "artist" "event" "genre" "instrument" "label" "place"
"recording" "release" "release-group" "series" "work" "url")
"API resources which represent core entities in the MusicBrainz database.")
(defconst musicbrainz-entities-non-core
(list "rating" "tag" "collection")
"API resources which represent non-core entities in the MusicBrainz database.")
(defconst musicbrainz-entities-uids
(list "discid" "isrc" "iswc")
"API resources based on other unique identifiers in the MusicBrainz database.")
(defconst musicbrainz-entities-linked
(list "area" "artist" "collection" "event" "instrument" "label" "place"
"recording" "release" "release-group" "series" "work" "url")
"API resources for linked entites in the MusicBrainz database.")
;; Linked entities
(defun musicbrainz-linked-entity-p (entity)
"Check if ENTITY can be used in a browse request (incomplete).
The following list shows which linked entities you can use in a browse request:
/ws/2/area collection
/ws/2/artist area, collection, recording, release, release-group, work
/ws/2/collection area, artist, editor, event, label, place, recording,
release, release-group, work
/ws/2/event area, artist, collection, place
/ws/2/instrument collection
/ws/2/label area, collection, release
/ws/2/place area, collection
/ws/2/recording artist, collection, release, work
/ws/2/release area, artist, collection, label, track, track_artist,
recording, release-group
/ws/2/release-group artist, collection, release
/ws/2/series collection
/ws/2/work artist, collection
/ws/2/url resource"
(if (member entity musicbrainz-entities-linked) t nil))
;; utils & aux
(defun musicbrainz-mbid-p (mbid)
"Check (permissive) if MBID is valid and/or well formatted.
An MBID is a 36 character Universally Unique Identifier, see https://musicbrainz.org/doc/MusicBrainz_Identifier for details."
(if (and (length= mbid 36)
(string-match-p
(rx (repeat 8 hex) ;; [A-F0-9]{8}
"-" (repeat 4 hex) ;; -[A-F0-9]{4}
"-4" (repeat 3 hex) ;; -4[A-F0-9]{3}
"-" (repeat 4 hex) ;; -[89AB][A-F0-9]{3}
"-" (repeat 12 hex)) ;; -[A-F0-9]{12}
mbid))
t nil))
;;; ;; ;; ; ; ; ; ; ;
;;
;; Search API
;; https://musicbrainz.org/doc/MusicBrainz_API/Search
;;
;; ;; ; ; ;
;;;###autoload
(defun musicbrainz-search (entity query &optional limit)
"Search the MusicBrainz database for ENTITY matching QUERY.
Optionally return only LIMIT number of results.
The QUERY field supports the full Lucene Search syntax, some details
can be found near https://musicbrainz.org/doc/MusicBrainz_API/Search
or in the Lucene docs."
(message "musicbrainz: searching %s=%s" entity query)
(let* ((max (if limit limit 1))
(response
(request-response-data
(request
(url-encode-url
(format "%s/%s?query=%s&fmt=json&limit=%s"
musicbrainz-api-url entity query max))
:type "GET"
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(if (eq t (assoc-default 'valid data))
(message "Token is valid for user: %s"
(assoc-default 'user_name data))
(message "Not a valid user token"))))))))
response))
;; various specific searches
;;;###autoload
(defun musicbrainz-search-artist (artist &optional limit)
"Search for an ARTIST and show matches.
Optionally return LIMIT number of results."
(let ((data (musicbrainz-search "artist" artist limit)))
(let-alist
data
(seq-map
(lambda (i)
(let-alist i
(if (not limit)
(format "%s | %s |\n" .name .id)
(format "%s | %s | %s |\n"
.score .name .id))))
.artists))))
;;;###autoload
(defun musicbrainz-artist-to-mbid (artist)
"Find an MBID for ARTIST (with 100% match).
See `musicbrainz-disambiguate-artist' if there are multiple matches."
(let ((data (musicbrainz-search "artist" artist)))
(let-alist data
(car (remove nil (seq-map
(lambda (i)
(let-alist i
(when (= 100 .score)
(format "%s" .id))))
.artists))))))
;;;###autoload
(defun musicbrainz-disambiguate-artist (artist &optional limit)
"More ARTIST data. less ambiguity (with optional LIMIT).
Outputs an `org-mode' table with descriptions and MBID link to artists pages."
(let ((data (musicbrainz-search "artist" artist limit)))
(let-alist data
(cons (format "| Artist: %s| MBID |\n" artist)
(seq-map
(lambda (i)
(let-alist i
(format "%s | %s, %s | [[https://musicbrainz.org/artist/%s][%s]] |\n"
.score .name .disambiguation .id .id)))
.artists)))))
;;;###autoload
(defun musicbrainz-search-label (label &optional limit)
"Search for a LABEL and show matches.
Optionally return LIMIT number of results."
(let ((data (musicbrainz-search "label" label limit)))
(let-alist
data
(seq-map
(lambda (i)
(let-alist i
(if (not limit)
(format "%s | %s |\n" .name .id)
(format "%s | %s | %s (%s%s) | %s |\n"
.score .name
(if .disambiguation .disambiguation "")
(if .life-span.begin
(format "%s " .life-span.begin) "")
(if .life-span.end
(format "—%s" .life-span.end)
"ongoing")
.id))))
.labels))))
;;;;;; ; ; ;; ; ; ; ; ; ;; ;
;;
;; Browse API
;; https://musicbrainz.org/doc/MusicBrainz_API#Browse
;;
;;;; ; ; ; ; ;
;; Browse requests are a direct lookup of all the entities directly linked
;; to another entity ("directly linked" here meaning it does not include
;; entities linked by a relationship). For example, you may want to see all
;; releases on the label ubiktune:
;; /ws/2/release?label=47e718e1-7ee4-460c-b1cc-1192a841c6e5
;; Note that browse requests are not searches: in order to browse all the releases
;; on the ubiktune label you will need to know the MBID of ubiktune.
;; The order of the results depends on what linked entity you are browsing
;; by (however it will always be consistent). If you need to sort the entities,
;; you will have to fetch all entities and sort them yourself.
;; Keep in mind only the search request is available without an MBID (or, in
;; specific cases, a disc ID, ISRC or ISWC). If all you have is the name of an
;; artist or album, for example, you'll need to make a search and pick the right
;; result to get its MBID to use it in a lookup or browse request.
;;;###autoload
(defun musicbrainz-browse (entity link lookup &optional type)
"Search the MusicBrainz database for ENTITY with LINK matching LOOKUP.
Optionally limit the search to TYPE results for ENTITY."
(message "musicbrainz: browsing %s linked to %s" entity link)
(message "url: %s/%s?%s=%s&type=%s&fmt=json" musicbrainz-api-url entity link lookup type)
(let ((response
(request-response-data
(request
(url-encode-url
(format "%s/%s?%s=%s&type=%s&fmt=json" musicbrainz-api-url entity link lookup type))
:type "GET"
:parser 'json-read
:sync t
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "ok")))))))
response))
;;;
(provide 'musicbrainz)
;;; musicbrainz.el ends here