Álvaro Ramírez
Emacs: search/play Music (macOS)
While trying out macOS's Music app to manage offline media, I wondered if I could easily search and control playback from Emacs. Spoiler alert: yes it can be done and fuzzy searching music is rather gratifying.
Luckily, the hard work's already handled by pytunes, a command line interface to macOS's iTunes/Music app. We add ffprobe and some elisp glue to the mix, and we can generate an Emacs media index.
Indexing takes roughly a minute per 1000 files. Prolly suboptimal, but I don't intend to re-index frequently. For now, we can use a separate process to prevent Emacs from blocking, so we can get back to playing tetris from our beloved editor:
(defun musica-index () "Indexes Music's tracks in two stages: 1. Generates \"Tracks.sqlite\" using pytunes (needs https://github.com/hile/pytunes installed). 2. Caches an index at ~/.emacs.d/.musica.el." (interactive) (message "Indexing music... started") (let* ((now (current-time)) (name "Music indexing") (buffer (get-buffer-create (format "*%s*" name)))) (with-current-buffer buffer (delete-region (point-min) (point-max))) (set-process-sentinel (start-process name buffer (file-truename (expand-file-name invocation-name invocation-directory)) "--quick" "--batch" "--eval" (prin1-to-string `(progn (interactive) (require 'cl-lib) (require 'seq) (require 'map) (message "Generating Tracks.sqlite...") (process-lines "pytunes" "update-index") ;; Generates Tracks.sqlite (message "Generating Tracks.sqlite... done") (defun parse-tags (path) (with-temp-buffer (if (eq 0 (call-process "ffprobe" nil t nil "-v" "quiet" "-print_format" "json" "-show_format" path)) (map-elt (json-parse-string (buffer-string) :object-type 'alist) 'format) (message "Warning: Couldn't read track metadata for %s" path) (message "%s" (buffer-string)) (list (cons 'filename path))))) (let* ((paths (process-lines "sqlite3" (concat (expand-file-name "~/") "Music/Music/Music Library.musiclibrary/Tracks.sqlite") "select path from tracks")) (total (length paths)) (n 0) (records (seq-map (lambda (path) (let ((tags (parse-tags path))) (message "%d/%d %s" (setq n (1+ n)) total (or (map-elt (map-elt tags 'tags) 'title) "No title")) tags)) paths))) (with-temp-buffer (prin1 records (current-buffer)) (write-file "~/.emacs.d/.musica.el" nil)))))) (lambda (process state) (if (= (process-exit-status process) 0) (message "Indexing music... finished (%.3fs)" (float-time (time-subtract (current-time) now))) (message "Indexing music... failed, see %s" buffer))))))
Once media is indexed, we can feed it to ivy for that narrowing-down fuzzy-searching goodness! It's worth mentioning the truncate-string-to-width function. Super handy for truncating strings to a fixed width and visually organizing search results in columns.
(defun musica-search () (interactive) (cl-assert (executable-find "pytunes") nil "pytunes not installed") (let* ((c1-width (round (* (- (window-width) 9) 0.4))) (c2-width (round (* (- (window-width) 9) 0.3))) (c3-width (- (window-width) 9 c1-width c2-width))) (ivy-read "Play: " (mapcar (lambda (track) (let-alist track (cons (format "%s %s %s" (truncate-string-to-width (or .tags.title (file-name-base .filename) "No title") c1-width nil ?\s "…") (truncate-string-to-width (propertize (or .tags.artist "") 'face '(:foreground "yellow")) c2-width nil ?\s "…") (truncate-string-to-width (propertize (or .tags.album "") 'face '(:foreground "cyan1")) c3-width nil ?\s "…")) track))) (musica--index)) :action (lambda (selection) (let-alist (cdr selection) (process-lines "pytunes" "play" .filename) (message "Playing: %s [%s] %s" (or .tags.title (file-name-base .filename) "No title") (or .tags.artist "No artist") (or .tags.album "No album"))))))) (defun musica--index () (with-temp-buffer (insert-file-contents "~/.emacs.d/.musica.el") (read (current-buffer))))
The remaining bits are straigtforward. We add a few interactive functions to control playback:
(defun musica-info () (interactive) (let ((raw (process-lines "pytunes" "info"))) (message "%s [%s] %s" (string-trim (string-remove-prefix "Title" (nth 3 raw))) (string-trim (string-remove-prefix "Artist" (nth 1 raw))) (string-trim (string-remove-prefix "Album" (nth 2 raw)))))) (defun musica-play-pause () (interactive) (cl-assert (executable-find "pytunes") nil "pytunes not installed") (process-lines "pytunes" "play") (musica-info)) (defun musica-play-next () (interactive) (cl-assert (executable-find "pytunes") nil "pytunes not installed") (process-lines "pytunes" "next")) (defun musica-play-next-random () (interactive) (cl-assert (executable-find "pytunes") nil "pytunes not installed") (process-lines "pytunes" "shuffle" "enable") (let-alist (seq-random-elt (musica--index)) (process-lines "pytunes" "play" .filename)) (musica-info)) (defun musica-play-previous () (interactive) (cl-assert (executable-find "pytunes") nil "pytunes not installed") (process-lines "pytunes" "previous"))
Finally, if we want some convenient keybindings, we can add something like:
(global-set-key (kbd "C-c m SPC") #'musica-play-pause) (global-set-key (kbd "C-c m i") #'musica-info) (global-set-key (kbd "C-c m n") #'musica-play-next) (global-set-key (kbd "C-c m p") #'musica-play-previous) (global-set-key (kbd "C-c m r") #'musica-play-next-random) (global-set-key (kbd "C-c m s") #'musica-search)
Hooray! Controlling music is now an Emacs keybinding away: \o/
comments on twitter.
UPDATE1: Installing pytunes with pip3 install pytunes didn't just work for me. Instead, I cloned and installed as:
git clone https://github.com/hile/pytunes pip3 install file:///path/to/pytunes pip3 install pytz brew install libmagic
UPDATE2: Checked in to dot files.