leibowitz
Experimental Common Lisp object storage abstraction for Unix file systems
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")))