ulthar.xyz > Repos

leibowitz

Experimental Common Lisp object storage abstraction for Unix file systems
About Files Commits git clone https://ulthar.xyz/repos/leibowitz/leibowitz.git

leibowitz/lib/thumbnailer/thumbnailer.lisp

Download raw file: lib/thumbnailer/thumbnailer.lisp

(defpackage :thumbnailer
  (:use #:cl)
  (:export #:*thumbnail-cache-dir*
           #:*mutool-exe*
           #:*ffmpeg-exe*
           #:*imagemagick-exe*
           #:get-thumbnail
           #:unsupported-file-type
           #:source-file-not-accessible
           #:thumbnail-creation-failed))

(in-package :thumbnailer)

(defparameter *mutool-exe* "mutool")
(defparameter *ffmpeg-exe* "ffmpeg")
(defparameter *imagemagick-exe* "magick")
(defparameter *thumbnail-cache-dir*
  (ensure-directories-exist
   (merge-pathnames
    (pathname (format NIL "thumbnailer_cache_dir-tmp~36R/" (random (expt 36 8))))
    (uiop:temporary-directory))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Entrypoint

(defun get-thumbnail (path mime &key (async NIL))
  "Return a path to the thumbnail of PATH in `*thumbnail-cache-dir*',
generating it if it doesn't exist or if PATH was modified after the
thumbnail was last generated."
  (setf path (etypecase path
               (string (uiop:parse-native-namestring path))
               (pathname path)))
  (unless (probe-file path)
    (error 'source-file-not-accessible :path path))
  (let* ((cached-path (get-cached-path path))
         (generate-thumbnail-p (or (not (probe-file cached-path))
                                   (> (file-write-date path)
                                      (file-write-date cached-path)))))
    (when generate-thumbnail-p
      (restart-case
          (dispatch-thumbnailer path cached-path mime async)
        (skip-file () :report "Skip this file"
          (return-from get-thumbnail (values NIL NIL)))))
    (values cached-path generate-thumbnail-p)))

(define-condition unsupported-file-type (error)
  ((mime :initarg :mime)
   (path :initarg :path))
  (:report (lambda (c s)
             (with-slots (mime path) c
               (format s "Unsupported mime type ~S for file~%  Path: ~S"
                       mime path)))))

(define-condition source-file-not-accessible (error)
  ((path :initarg :path))
  (:report (lambda (c s)
             (with-slots (path) c
               (format s "Failed to create thumbnail for ~S, file doesn't exist."
                       path)))))

(define-condition thumbnail-creation-failed (error)
  ((path :initarg :path)
   (mime :initarg :mime)
   (why  :initarg :why))
  (:report (lambda (c s)
             (with-slots (mime path why) c
               (format s "Failed to create thumbnail for ~S file~%  Path: ~S~%  Reason: ~A"
                       mime path why)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Generators

(defun imagemagick-generate-thumbnail (original-path cached-path)
  (uiop:run-program (list *imagemagick-exe*
                          (uiop:native-namestring original-path)
                          "-format" "jpg"
                          "-thumbnail" "300x300"
                          "-strip"
                          (uiop:native-namestring cached-path))
                    :error-output T))

(defun imagemagick-generate-document-thumbnail (original-path cached-path)
  (uiop:run-program (list *imagemagick-exe*
                          "convert"
                          "-resize" "300x300"
                          (format NIL "~A[0]" (uiop:native-namestring original-path))
                          (uiop:native-namestring cached-path))))

(defun ffmpeg-generate-thumbnail (original-path cached-path)
  (uiop:run-program (list *ffmpeg-exe*
                          "-i" (uiop:native-namestring original-path)
                          "-vf" "select=eq(n\,34)"
                          "-vf" "scale=300:-2"
                          "-vframes" "1"
                          (uiop:native-namestring cached-path))
                    :error-output T))

(defun mupdf-generate-thumbnail (original-path cached-path)
  (uiop:run-program (list *mutool-exe*
                          "draw"
                          "-q"
                          "-w" "300"
                          "-h" "300"
                          "-o" (uiop:native-namestring cached-path)
                          (uiop:native-namestring original-path)
                          "1")
                    :error-output T))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Helpers

(defun get-cached-path (path)
  "Given an absolute path, return the path that would reference it in
the thumbnail cache."
  (let* ((path (if (uiop:absolute-pathname-p path)
                   path
                   (merge-pathnames path)))
         (path (uiop:native-namestring path))
         (path (pathname (subseq path 1 (length path))))
         (path (merge-pathnames path *thumbnail-cache-dir*)))
    (ensure-directories-exist (directory-namestring path))
    (pathname (concatenate 'string (uiop:native-namestring path) ".jpg"))))

(defun dispatch-thumbnailer (path cached-path mime async)
  (let ((func (cond ((or (equal (subseq mime 0 6) "video/")
                         (and (equal (subseq mime 0 6) "audio/")
                              (not (equal mime "audio/flac")))
                         (equal mime "image/gif"))
                     #'ffmpeg-generate-thumbnail)
                    ((equal (subseq mime 0 6) "image/")
                     #'imagemagick-generate-thumbnail)
                    ((or (equal mime "application/pdf")
                         (equal mime "application/postscript")
                         (if (libreoffice-available-p)
                             (mime-is-fancy-office-format mime)
                             NIL))
                     #'imagemagick-generate-document-thumbnail)
                    ((or (equal mime "application/epub+zip")
                         (equal mime "application/vnd.comicbook+zip")
                         (equal mime "application/fictionbook2+zip")
                         (equal mime "application/fictionbook3+zip")
                         (equal mime "application/x-mobipocket-ebook")
                         (equal mime "application/vnd.ms-xpsdocument")
                         (equal mime "application/oxps"))
                     #'mupdf-generate-thumbnail)
                    (T (error 'unsupported-file-type :mime mime :path path)))))
    (handler-case
        (if async
            (bt:make-thread (lambda () (funcall func path cached-path))
                            :name "Thumbnailer worker")
            (funcall func path cached-path))
      (uiop:subprocess-error (c)
        (error 'thumbnail-creation-failed :path path :mime mime
                                          :why (format NIL "~S" c))))))

(defun libreoffice-available-p ()
  "Check if libreoffice is available as imagemagick uses it under the
hood to convert office formats to PDFs."
  #-windows(handler-case
               (progn (uiop:run-program '("which" "libreoffice")) T)
             (uiop:subprocess-error () NIL))
  #+windows(error "thumbnailer doesn't support windows yet :("))

(defun mime-is-fancy-office-format (mime)
  (or
   ;; Microsoft Word
   (equal mime "application/msword")
   (equal mime "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
   ;; Microsoft Powerpoint
   (equal mime "application/vnd.ms-powerpoint")
   (equal mime "application/vnd.openxmlformats-officedocument.presentationml.presentation")
   ;; Libreoffice Writer
   (equal mime "application/vnd.oasis.opendocument.text")))
Generated 2024-06-10 19:24:14 -0700 by RepoRat