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: ø/
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.