Created
January 1, 2026 09:01
-
-
Save lgmoneda/6dbb6f5e6183928e0c6897547b153570 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;;; 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