DEV Community

vindarel
vindarel

Posted on

Common Lisp script: search and transform music files to mp3 with ffmpeg

We leverage some cool libraries:

$ ciel -s ffmpeg delux flac

[…]

    TITLE           : Cria de Favela
    track           : 12
    TRACKTOTAL      : 12
  Duration: 00:02:51.60, start: 0.000000, bitrate: 954 kb/s
  Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
  Stream #0:1: Video: mjpeg (Progressive), yuvj420p(pc, bt470bg/unknown/unknown), 1280x1280 [SAR 120:120 DAR 1:1], 90k tbr, 90k tbn, 90k tbc (attached pic)
    Metadata:
      comment         : Cover (front)
      title           : D:\Música em alta resolução\Album\Criolo\[2017] Espiral de Ilusão - Deluxe Edition\Espiral de Ilusão - Deluxe Edition.jpg
Stream mapping:
  Stream #0:1 -> #0:0 (mjpeg (native) -> png (native))
  Stream #0:0 -> #0:1 (flac (native) -> mp3 (libmp3lame))
Press [q] to stop, [?] for help

done for files: 
"/home/vince/zique/[2017] Espiral de Ilusão - Deluxe Edition/01 - Criolo - Lá Vem Você.flac"
"/home/vince/zique/[2017] Espiral de Ilusão - Deluxe Edition/02 - Criolo - Dilúvio de Solidão.flac"
"/home/vince/zique/[2017] Espiral de Ilusão - Deluxe Edition/03 - Criolo - Menino Mimado.flac"
Enter fullscreen mode Exit fullscreen mode

Easy!

We find files with file-finder:

(finder:finder*
   :root root
   ;; This "and"s the params:
   :predicates (apply #'finder:every-path~ params)
   ;; This would do a "or":
   ;; :predicates (apply #'finder:path~ params)
   )
Enter fullscreen mode Exit fullscreen mode

This searches for files recursively on a root directory and it "ands" our list of search terms.

We could be more precise in asking it to check that "flac" is the file extension, but it's ok for us here.

This gives us a list of "file-finder:file" objects, actually not lisp pathnames, not strings. To get file names as strings, we use finder:path file.

To get a file name with the mp3 extension, we simply replace in a string:

(let ((extension (pathname-type file)))
    (when extension
      (values
       (str:replace-all extension "mp3" file)
       extension)))
Enter fullscreen mode Exit fullscreen mode

However, TIL. We have directory names that give the year in between […] characters, and uiop:file-exists-p chokes at it (it returns nil). We escape those characters:

(defun escape-file-name (name)
  "Escape [ and ] with double \\,

  otherwise uiop:file-exists-p returns NIL for an existing file."
  ;; This works on upstream file-finder <2025-09-09>
  ;; (when (finder:file? name)
    ;; (setf name (finder:path name)))
  (str:replace-using '("[" "\\["
                       "]" "\\]")
                     name))
Enter fullscreen mode Exit fullscreen mode

(edit) OK, those are wildcard characters. This pattern correctly returns T when a file exists:

  (probe-file (make-pathname :name name :type extension))
Enter fullscreen mode Exit fullscreen mode

so I might adapt the script.

And that's pretty it. We run ffmpeg with

(uiop:run-program (list "ffmpeg"
                                "-i"
                                (finder:path file)
                                target)
                          :output :interactive
                          :error-output t)
Enter fullscreen mode Exit fullscreen mode

Here's the full listing.

Find it also on https://github.com/ciel-lang/CIEL/discussions/89 and in CIEL's scripts directory.

  • https://github.com/ciel-lang/CIEL/ - a batteries-included Common Lisp. Here we only rely on the built-in file-finder library, and cl-str. And the easy way to run a script from the terminal.

(in-package :ciel-user)

;;;
;;; Search files and transform them to mp3 with ffmpeg.
;;;
;;; Usage:
;;;
;;; ciel src/scripts/ffmpeg search terms
;;;
;;; Todo:
;;; - choose search directories (do we even search on the current directory?)
;;; - choose output format, etc.
;;;
;;; TIL: we need to escape file names if they contain characters such as [ ].
;;;

(defparameter *directories* '("~/Music/" "~/Downloads/" "~/zique/"))

(defvar *music-type-extensions* '("aac" "ac3" "aiff" "amr" "ape" "dts" "f4a" "f4b" "flac" "gsm"
         "m3u" "m4a" "midi" "mlp" "mka" "mp2" "mp3" "oga" "ogg" "opus" "pva"
                                  "ra" "ram" "raw" "rf64" "spx" "tta" "wav" "wavpack" "wma" "wv")
  "A bunch of audio extensions. Should be supported by ffmpeg. Thanks ready-player.el.")

(defun music-file-p (file)
  "Return non-NIL if this file (pathname) has a music file extension as of *music-type-extensions*."
  (find (pathname-type file) *music-type-extensions* :test #'equalp))


(defun find-on-directory (root params)
  "Search on default directories.

  PARAMS (list)"
  (finder:finder*
   :root root
   ;; This "and"s the params:
   :predicates (apply #'finder:every-path~ params)
   ;; This would do a "or":
   ;; :predicates (apply #'finder:path~ params)
   ))

(defun find-files (&optional params)
  "Find files matching PARAMS (a list of strings) on the default directories.

  PARAMS is an 'OR'. I would prefer to 'and' the matches actually (see find-on-directory)."
  (unless params
    (format *error-output* "No search terms supplied.~&Usage: finder.lisp search terms.~&")
    (return-from find-files))
  (let ((str:*ignore-case* t)
        (params (ensure-list params)))
    (flatten
     (loop for root in *directories*
           collect
           (find-on-directory root params)))))

(defun pprint-for-shell (list)
  "Pretty-print this list of files (with full path), one per line."
  (mapcar (lambda (p)
            (format t "~s~&" (finder:path p)))
          list)
  (terpri))

(defun change-extension (file)
  (when (finder:file? file)
    (setf file (finder:path file)))
  (let ((extension (pathname-type file)))
    (when extension
      (values
       (str:replace-all extension "mp3" file)
       extension))))

;; warn!
(defun escape-file-name (name)
  "Escape [ and ] with double \\,

  otherwise uiop:file-exists-p returns NIL for an existing file."
  ;; This works on upstream file-finder <2025-09-09>
  ;; (when (finder:file? name)
    ;; (setf name (finder:path name)))
  (str:replace-using '("[" "\\["
                       "]" "\\]")
                     name))

(defun run-ffmpeg (file)
  "Run ffmpeg on FILE, transform to mp3."
  (let ((target (change-extension file)))
    (if (uiop:file-exists-p (escape-file-name target))
        (progn
          (format t "~&mp3 already exists: ~a~&" target)
          target)
        (uiop:run-program (list "ffmpeg"
                                "-i"
                                (finder:path file)
                                target)
                          :output :interactive
                          :error-output t))))

(defun ffmpeg-on-files (files)
  "Transform files to mp3 with ffmpeg.

  Search on the default directories by AND-ing the search terms."
  (loop for file in files
        for path = (finder:path file)
        when (and (music-file-p path)
                  (uiop:file-exists-p (escape-file-name path)))
          do (format t "~&transforming: ~a~&" file)
             (run-ffmpeg file)
          and collect file into processed
        finally
           (format t "~&~%done for files: ~&")
           (pprint-for-shell processed)))

#+ciel
(ffmpeg-on-files (find-files (rest *script-args*)))
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
vindarel profile image
vindarel

oh, we also use flatten from Alexandria.