Skip to content

Instantly share code, notes, and snippets.

@lgmoneda
Created January 1, 2026 09:01
Show Gist options
  • Select an option

  • Save lgmoneda/6dbb6f5e6183928e0c6897547b153570 to your computer and use it in GitHub Desktop.

Select an option

Save lgmoneda/6dbb6f5e6183928e0c6897547b153570 to your computer and use it in GitHub Desktop.
;;; my-smudge-lyrics.el --- Show LRCLIB lyrics for current Smudge track -*- lexical-binding: t; -*-
;; Smudge → LRCLIB (async) → read-only lyrics buffer
;;; Commentary:
;; - M-x my-smudge-lyrics-popup: fetch lyrics for current track and show them.
;; - Optional: enable `my-smudge-lyrics-auto-popup` to auto-fetch on track change.
;;
;; Requirements:
;; - Smudge (https://github.com/danielfm/smudge)
;; - Emacs built-ins: url, json, subr-x
;;; Code:
(with-eval-after-load 'smudge-controller
(require 'url)
(require 'json)
(require 'subr-x)
;; Avoid byte-compiler warnings if Smudge isn't installed at compile time.
(defvar smudge-controller-player-metadata)
(declare-function smudge-controller-player-status "smudge-controller")
(declare-function smudge-controller-update-metadata "smudge-controller")
(defgroup my-smudge-lyrics nil
"Fetch and display lyrics for the current Smudge track via LRCLIB."
:group 'smudge)
(defcustom my-smudge-lyrics-auto-popup nil
"When non-nil, automatically fetch lyrics when the track changes."
:type 'boolean
:group 'my-smudge-lyrics)
(defcustom my-smudge-lyrics-service-url
"https://lrclib.net/api/get"
"Base URL for LRCLIB's lyrics endpoint."
:type 'string
:group 'my-smudge-lyrics)
(defcustom my-smudge-lyrics-timeout 15
"Timeout in seconds for lyrics requests."
:type 'integer
:group 'my-smudge-lyrics)
(defcustom my-smudge-lyrics-debug nil
"When non-nil, log a snippet of the raw response body."
:type 'boolean
:group 'my-smudge-lyrics)
(defconst my-smudge-lyrics-buffer-name "*Smudge Lyrics*"
"Name of the lyrics display buffer.")
;; Internal state
(defvar my-smudge-lyrics--last-track nil
"Last (ARTIST . TITLE) pair used for auto-popup deduping.")
(defvar my-smudge-lyrics--pending-token nil
"Token identifying the most recent in-flight request.")
(defvar my-smudge-lyrics--manual-pending nil
"Non-nil means the user requested lyrics; wait for fresh metadata.")
(defvar my-smudge-lyrics--manual-timer nil
"Timer used to wait briefly for Smudge metadata refresh.")
(defvar my-smudge-lyrics--mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map special-mode-map)
;; special-mode already provides lots of navigation; keep only what we want to advertise.
(define-key map (kbd "q") #'kill-buffer-and-window)
map)
"Keymap for `my-smudge-lyrics-mode'.")
(define-derived-mode my-smudge-lyrics-mode special-mode "Smudge-Lyrics"
"Major mode for displaying lyrics fetched for the current Smudge track."
:keymap my-smudge-lyrics--mode-map
(setq-local buffer-read-only t)
(setq-local buffer-offer-save nil)
(setq-local truncate-lines nil))
(defun my-smudge-lyrics--clean (s)
"Normalize artist/title S for better matching on lyrics services."
(let ((s (string-trim (or s ""))))
;; Remove parenthetical/bracketed parts: "(Remastered)", "[Live]", etc.
(setq s (replace-regexp-in-string " *[(\\[].*?[])]" "" s))
;; Remove trailing dash metadata: "Song - 2011 Remaster"
(setq s (replace-regexp-in-string " *- *.*$" "" s))
;; Remove common feature suffixes.
(setq s (replace-regexp-in-string " *feat\\..*$" "" s))
(setq s (replace-regexp-in-string " *ft\\..*$" "" s))
(string-trim s)))
(defun my-smudge-lyrics--current-track ()
"Return current track as a plist, or nil if unavailable/not playing.
Plist keys: :artist :title :duration (seconds, integer)."
(when (hash-table-p smudge-controller-player-metadata)
(let ((artist (gethash "artist" smudge-controller-player-metadata))
(title (gethash "name" smudge-controller-player-metadata))
(state (gethash "player_state" smudge-controller-player-metadata))
(dur-ms (gethash "duration" smudge-controller-player-metadata)))
(when (and (stringp artist) (stringp title) (string= state "playing"))
(list :artist artist
:title title
:duration (when (numberp dur-ms)
(max 1 (round (/ dur-ms 1000.0)))))))))
(defun my-smudge-lyrics--lrclib-url (artist title &optional duration)
"Build LRCLIB query URL for ARTIST and TITLE, optionally with DURATION seconds."
(let* ((params `(("artist_name" . ,artist)
("track_name" . ,title)))
(params (if duration
(append params `(("duration" . ,(number-to-string duration))))
params)))
(concat my-smudge-lyrics-service-url "?"
(mapconcat (lambda (kv)
(format "%s=%s"
(car kv)
(url-hexify-string (cdr kv))))
params "&"))))
(defun my-smudge-lyrics--parse-json-body (body)
"Parse BODY as JSON and return a hash-table, or nil on failure."
(when (and body (not (string-empty-p (string-trim body))))
(condition-case nil
(json-parse-string body :object-type 'hash-table)
(error nil))))
(defun my-smudge-lyrics--http-body ()
"Return the HTTP response body in the current url-retrieve buffer."
(goto-char (point-min))
;; Headers can be \r\n\r\n or \n\n.
(re-search-forward "\r?\n\r?\n" nil 'move)
(buffer-substring-no-properties (point) (point-max)))
(defun my-smudge-lyrics--show-buffer (artist title lyrics)
"Display LYRICS for ARTIST and TITLE in a read-only buffer."
(let ((buf (get-buffer-create my-smudge-lyrics-buffer-name)))
(with-current-buffer buf
(let ((inhibit-read-only t))
(erase-buffer)
(insert (format "%s — %s\n\n" artist title))
(insert lyrics)
(goto-char (point-min))
(my-smudge-lyrics-mode)))
(pop-to-buffer buf)))
(defun my-smudge-lyrics--handle-response (status artist title token)
"Handle LRCLIB response for ARTIST/TITLE. TOKEN identifies the request."
(let ((urlbuf (current-buffer))
(stale (and my-smudge-lyrics--pending-token
(not (equal token my-smudge-lyrics--pending-token)))))
(unwind-protect
(cond
(stale
(message "Lyrics response ignored (stale)"))
((plist-get status :error)
(let ((err (plist-get status :error)))
;; `:error` is typically (ERROR-SYMBOL . DATA)
(message "Lyrics request failed: %s"
(if (consp err) (format "%S" err) (format "%S" err)))))
(t
(let ((code url-http-response-status)
(body (my-smudge-lyrics--http-body)))
(when my-smudge-lyrics-debug
(message "Lyrics raw body: %s"
(truncate-string-to-width body 200 0 nil "…")))
(cond
((eq code 404)
(message "No lyrics found for %s — %s" artist title))
((and (numberp code) (>= code 400))
(message "Lyrics service error: %s" code))
(t
(let* ((json (my-smudge-lyrics--parse-json-body body))
(lyrics (and json (gethash "plainLyrics" json))))
(if (and (stringp lyrics) (not (string-empty-p (string-trim lyrics))))
(my-smudge-lyrics--show-buffer artist title lyrics)
(message "No lyrics returned for %s — %s" artist title)))))))))
;; Always kill ONLY the url-retrieve buffer.
(when (buffer-live-p urlbuf)
(kill-buffer urlbuf))))
(defun my-smudge-lyrics--request (artist title duration token)
"Start an async LRCLIB request for ARTIST/TITLE/DURATION tagged by TOKEN."
(let* ((artist (my-smudge-lyrics--clean artist))
(title (my-smudge-lyrics--clean title))
(url (my-smudge-lyrics--lrclib-url artist title duration))
(url-request-method "GET")
(url-request-timeout my-smudge-lyrics-timeout))
(url-retrieve url #'my-smudge-lyrics--handle-response
(list artist title token)
;; silent inhibit-cookies
t t)))
(defun my-smudge-lyrics--request-track (track)
"Request lyrics for TRACK plist produced by `my-smudge-lyrics--current-track`."
(let* ((artist (plist-get track :artist))
(title (plist-get track :title))
(dur (plist-get track :duration))
(token (float-time)))
(setq my-smudge-lyrics--pending-token token)
(my-smudge-lyrics--request artist title dur token)
(message "Fetching lyrics for %s — %s..." artist title)))
(defun my-smudge-lyrics--manual-timeout ()
"If a manual request is pending, use the freshest metadata to fetch lyrics."
(when my-smudge-lyrics--manual-pending
(setq my-smudge-lyrics--manual-pending nil)
(when-let ((track (my-smudge-lyrics--current-track)))
(my-smudge-lyrics--request-track track))))
;;;###autoload
(defun my-smudge-lyrics-popup ()
"Fetch lyrics for the current Smudge track and show them (async)."
(interactive)
;; Ask Smudge to refresh metadata, then fetch shortly after.
(setq my-smudge-lyrics--manual-pending t)
(when (timerp my-smudge-lyrics--manual-timer)
(cancel-timer my-smudge-lyrics--manual-timer))
(setq my-smudge-lyrics--manual-timer
(run-at-time 0.8 nil #'my-smudge-lyrics--manual-timeout))
(smudge-controller-player-status))
(defun my-smudge-lyrics--maybe-auto (&rest _)
"Auto-fetch handler triggered after Smudge metadata updates."
(when-let ((track (my-smudge-lyrics--current-track)))
;; If a manual request is pending, honor it with the fresh metadata.
(when my-smudge-lyrics--manual-pending
(setq my-smudge-lyrics--manual-pending nil)
(my-smudge-lyrics--request-track track))
;; Auto popup on track change, if enabled.
(when my-smudge-lyrics-auto-popup
(let ((id (cons (plist-get track :artist)
(plist-get track :title))))
(unless (equal id my-smudge-lyrics--last-track)
(setq my-smudge-lyrics--last-track id)
(my-smudge-lyrics--request-track track))))))
(advice-add 'smudge-controller-update-metadata :after #'my-smudge-lyrics--maybe-auto)
(provide 'my-smudge-lyrics))
;;; my-smudge-lyrics.el ends here
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment