Sorting albums by duration


Published

Sometimes I listen to albums in my music collection as a sort of timer. It’s pretty neat: I know about one hour has passed when the album Youthfull (koyori) is over (54 minutes), and I don’t have to have a clock on screen to be able to know that.

To do this, I want to sort the albums by duration so I can pick one that’s at the right length. However, (at least on Linux) seemingly no music player supports it. Some just don’t have a table view for sorting anything (GNOME Music), some don’t have a list view for albums (Lollypop, elementary Music), some allow sorting albums but not by duration (Elisa)…

So I ended up hacking it together myself in Emacs.

The whole thing:

Then, breaking it up:

Get the duration of one file

Goal: (k/song-duration "G4L - Instrumental.wav") should return the length of the audio file G4L - Instrumental.wav.

(defun k/song-duration (song-file)
  "Return duration of SONG-FILE in seconds."
  (with-temp-buffer
    (call-process
     "ffprobe" nil '(t nil) nil
     "-v" "quiet"
     "-print_format" "json"
     "-show_streams"
     song-file)
    (goto-char (point-min))
    (-some--> (json-parse-buffer
               :object-type 'alist)
      (map-elt it 'streams)
      (seq-find (lambda (elem)
                  (equal (map-elt elem 'codec_type)
                         "audio"))
                it)
      (map-elt it 'duration)
      string-to-number)))

First open a temporary buffer to do call-process in…

(with-temp-buffer

Then I use ffprobe to get information about an audio file. ffprobe is able to return JSON, so I ask it to do so.

(The first nil is which file to use as stdin, nil for nothing; the second nil is specifying that we don’t want to display this buffer. The '(t nil) means insert stdout into this buffer and toss stderr away. I have to check the docstring everytime I use it.)

(call-process
 "ffprobe" nil '(t nil) nil
 "-v" "quiet"
 "-print_format" "json"
 "-show_streams"
 song-file)

The JSON output looks something like this (irrelevant fields are removed here)

{
  "streams": [
    {
      "index": 0,
      "codec_name": "mp3",
      "codec_long_name": "MP3 (MPEG audio layer 3)",
      "codec_type": "audio",
      "duration_ts": 1136148480,
      "duration": "80.509388"
    },
    {
      "index": 1,
      "codec_name": "mjpeg",
      "codec_long_name": "Motion JPEG",
      "codec_type": "video",
      "duration_ts": 7245845,
      "duration": "80.509389"
    }
  ]
}

Before we parse the JSON, we have to first go back to the beginning of the buffer as json-parse-buffer parses from the current cursor location, and call-process has moved it.

(goto-char (point-min))

Now parse the JSON output, then extract the field I want.

I use -some--> so that if we can’t find a valid field, it’ll just return nil.

I ask json-parse-buffer to parse JSON objects into alists because I felt like this is faster. I’m not sure, though.

I also specify that I only want an audio stream. Selecting the image stream (used for embedding album art) in an audio file is fine as it will also have the same duration. However, this is still needed because ffprobe also returns data from pure jpeg files, and it counts them as having a duration of 0.04 seconds if I remember correctly. Counting only audio streams prevents counting image files.

ffprobe returns the duration as seconds in a string, so I use string-to-number to finally convert it to a number.

(-some--> (json-parse-buffer
           :object-type 'alist)
  (map-elt it 'streams)
  (seq-find (lambda (elem)
              (equal (map-elt elem 'codec_type)
                     "audio"))
            it)
  (map-elt it 'duration)
  string-to-number)

Get the duration of an entire album

Instead of trying to list albums by looking at metadata of each song, I simply rely on albums being represented by folders.

This allows the implementation to be quite simple:

(defun k/folder-duration (folder)
  "Return duration of all songs in FOLDER."
  (--> (directory-files folder t)
    (mapcar #'k/song-duration it)
    -non-nil
    (apply #'+ it)))

This simply runs k/song-duration (the function from the previous section) on every immediate member of folder, removes the invalid values, then adds it all up.

Creating a listing based on tabulated-list-mode

The interactive statement

(interactive (list (xdg-user-dir "MUSIC")))

This marks the function as an interactive command so that it shows up in M-x and can be bound to a key chord.

The “input” to the interactive form tells Emacs that when run as an interactive command (through M-x, a key bind, or call-interactively), it should evaluate (list (xdg-user-dir "MUSIC")) and use that as the argument list, making (xdg-user-dir "MUSIC") the first and only argument.

Collecting duration

First collect the durations into an alist mapping folder paths to their total durations.

(dolist-with-progress-reporter (folder (f-directories dir))
    "Probing folders..."
  (push (cons folder (k/folder-duration folder)) folders))

dolist-with-progress-reporter is a nicer version of dolist that reports the progress as it goes. k/folder-duration as defined in the previous section is actually quite slow, and because we're running it sequentially on all music files, it builds up.

The result (saved into the folders variable, which has been defined by a surrounding let) is an alist that looks like this:

(let (folders)
  (dolist-with-progress-reporter (folder (f-directories (xdg-user-dir "MUSIC")))
      "Probing folders..."
    (push (cons folder (k/folder-duration folder)) folders))
  folders)

Setting up a tabulated-list buffer

We need to:

  1. Enter a new buffer to display the data collected in folders

  2. Start tabulated-list-mode. (Usually this is done by defining a new major mode.)

  3. Set up tabulated-list-format

  4. Render the header with tabulated-list-init-header

  5. Put our data into tabulated-list-entries in the right format

  6. Run revert-buffer to trigger tabulated-list-mode's rendering mechanism

The most confusing thing for me has been figuring out what's the right shape for tabulated-list-format and tabulated-list-entries. Providing some examples probably helps:

  • tabulated-list-format is a vector in this shape:

    [("folder" 70 t)
     ("duration" 20 my-sort-function)
     ("another column" 30 t :right-align t)
     ...]

    Each element in this vector represents a column; each column is specified as (<name> <width> <sort> [prop] [value] [prop] [value] …). So the third element above specifies a column with a name “another column”, a width of 30, sorting by string comparison, and aligns to the right. Its docstring describes it better:

    • NAME is a string describing the column. This is the label for the column in the header line. Different columns must have non-equal names.

    • WIDTH is the width to reserve for the column. For the final element, its numerical value is ignored.

    • SORT specifies how to sort entries by this column. If nil, this column cannot be used for sorting. If t, sort by comparing the string value printed in the column. Otherwise, it should be a predicate function suitable for sort, accepting arguments with the same form as the elements of tabulated-list-entries.

    • PROPS is a plist of additional column properties. Currently supported properties are:

      • :right-align: If non-nil, the column should be right-aligned.

      • :pad-right: Number of additional padding spaces to the right of the column (defaults to 1 if omitted).

    Docstring of the variable tabulated-list-format

    SORT, if provided as a function, always accepts two entries that look like (<id> [<column1> <column2> …]), regardless of which column it is assigned to. As the docstring said, this is the same format that goes into tabulated-list-entries.

  • tabulated-list-entries is a list in this shape:

    ([("id1" ["column 1" "column 2" "column 3" ])]
     [("id2" ["column 1" "column 2" "column 3" ])])

    The ID can also be nil, but providing it allows tabulated-list to keep the cursor on the current item when sorting. If it is provided, it can be any unique object (defined as not being equal to any other entries in this list).

    An entry's values for every column are called “column descriptors” in the docstring; they can be strings (as shown above) or a list like ("label" :key 123 :key2 456). The latter form is useful in sort functions, as you can store eg. numerical data there to compare with < without needing to use string-to-number.

With that out of the way:

(with-current-buffer (get-buffer-create "*k/music folders*") ; Step 1
  (when (= 0 (buffer-size))
    (tabulated-list-mode) ; Step 2
    ;; Step 3
    (setq tabulated-list-format
          (vector
           '("folder" 70 t) ; first column
           (list "duration" 20 ; second column
                 (lambda (a b)
                   ;; A and B look like
                   ;; (ID [<folder> ("00:00:10" :seconds 10.0)])
                   ;; - cadr -> grab the vector
                   ;; - elt 1 -> skip <folder> and grab ("10.0" :seconds 10.0)
                   ;; - cdr -> remove the label
                   ;; - plist-get :seconds -> extract the numerical
                   ;;   value stored below, when we set up tabulated-list-entries
                   (< (-> (cadr a) (elt 1) cdr (plist-get :seconds))
                      (-> (cadr b) (elt 1) cdr (plist-get :seconds)))))))
    (tabulated-list-init-header)) ; Step 4
  ;; Step 5
  (dolist (folder folders)
    ;; Goal: each entry is something like
    ;; (nil ["deconstructing nature" ("00:46:28" :seconds 2788.2840810000002)])
    (push (list nil (vector (f-base (car folder))
                            (list (format-seconds "%.2h:%.2m:%.2s" (cdr folder))
                                  :seconds (cdr folder))))
          tabulated-list-entries))
  ;; Step 6
  (revert-buffer))