photo-store.lisp 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. (in-package #:photo-store)
  2. (eval-when (:compile-toplevel :load-toplevel :execute)
  3. (restas::register-pkgmodule-traits 'photo-store)
  4. (restas:reconnect-all-routes))
  5. ;; photos: id | album_id | filename | size | taken | width | height | lat | lon
  6. ;; albums: id | parent_id | path | date | name | description | cover_id | accessibility
  7. (defvar *db-path* "db.sqlite" "SQLite database path")
  8. (defmacro with-db ((db) &body body)
  9. `(sqlite:with-open-database (,db *db-path* :busy-timeout 10)
  10. (sqlite:execute-non-query ,db "PRAGMA foreign_keys = ON")
  11. ,@body))
  12. (defun db-init ()
  13. (with-db (db)
  14. (sqlite:execute-non-query db "create table if not exists photos (id INTEGER PRIMARY KEY, path, size, taken, width, height, lat, lon)")
  15. (sqlite:execute-non-query db "create table if not exists albums (id INTEGER PRIMARY KEY, name, description, cover_id REFERENCES photos, parent_id REFERENCES albums)")
  16. (sqlite:execute-non-query db "create table if not exists album_photos (album_id REFERENCES albums NOT NULL, photo_id REFERENCES photos NOT NULL, description, idx, hidden)")))
  17. (defun db/add-photo (db info)
  18. (let* ((path (namestring (uiop:subpathp (aget :path info) *photo-storage-path*)))
  19. (size (aget :length info))
  20. (taken (local-time:timestamp-to-unix (aget :created-at info)))
  21. (dim (aget :dim info))
  22. (w (first dim)) (h (second dim))
  23. (loc (aget :location info))
  24. (lat (and loc (geo:latitude-deg loc)))
  25. (lon (and loc (geo:longitude-deg loc))))
  26. (sqlite:execute-non-query
  27. db "insert into photos (path, size, taken, width, height, lat, lon) values (?, ?, ?, ?, ?, ?, ?)"
  28. path size taken w h lat lon)
  29. (sqlite:last-insert-rowid db)))
  30. (defun db/add-album (db name &optional description cover-id)
  31. (sqlite:execute-non-query
  32. db "insert into albums (name, description, cover_id) values (?, ?, ?)"
  33. name description cover-id)
  34. (sqlite:last-insert-rowid db))
  35. (defun db/add-album-photo (db album-id photo-id &optional description idx hidden)
  36. (sqlite:execute-non-query
  37. db "insert into album_photos (album_id, photo_id, description, idx, hidden) values (?, ?, ?, ?, ?)"
  38. album-id photo-id description idx hidden))
  39. (defvar *photo-storage-url* nil "base url for photo storage")
  40. (defun db-load-album (album-id &optional full)
  41. (with-db (db)
  42. (multiple-value-bind (name cover-id)
  43. (sqlite:execute-one-row-m-v db "select id, name, cover_id from albums where id = ?" album-id)
  44. (when name
  45. (let ((photos (iter (for (id path title w h hidden)
  46. in-sqlite-query "select p.id, p.path, ap.description, p.width, p.height, ap.hidden from photos p inner join album_photos ap on ap.photo_id = p.id where ap.album_id = ? and (ap.hidden is null or ? is not null) order by ap.idx"
  47. on-database db
  48. with-parameters (album-id full))
  49. (collect (list (cons :id id)
  50. (cons :src (concatenate 'string *photo-storage-url* path))
  51. (cons :title (format nil "~A~:[~; [hidden]~]" title hidden))
  52. (cons :w w) (cons :h h)
  53. (cons :hidden hidden))))))
  54. (list (cons :id album-id)
  55. (cons :title name)
  56. (cons :cover (find cover-id photos :key (agetter :id)))
  57. (cons :photos photos)))))))
  58. (defun db-load-albums (&optional parent-id)
  59. (with-db (db)
  60. (iter (for (id title cover-path cover-taken count)
  61. in-sqlite-query "select a.id, a.name, c.path, c.taken, (select count(photo_id) from album_photos where album_id=a.id) cnt from albums a left join photos c on a.cover_id=c.id where cnt > 0 and ((? is null and parent_id is null) or (parent_id=?)) order by c.taken desc" on-database db with-parameters (parent-id parent-id))
  62. (collect (list (cons :id id)
  63. (cons :title title)
  64. (cons :count count)
  65. (cons :date cover-taken)
  66. (cons :cover (concatenate 'string
  67. *photo-storage-url* cover-path)))))))
  68. (defun db-set-album-cover (album-id cover-id)
  69. (with-db (db)
  70. (sqlite:execute-non-query
  71. db "update albums set cover_id = ? where id = ?"
  72. cover-id album-id)))
  73. (defun db-set-album-photo-hidden (album-id photo-id hidden)
  74. (with-db (db)
  75. (sqlite:execute-non-query
  76. db "update album_photos set hidden=? where album_id=? and photo_id=?"
  77. hidden album-id photo-id)))
  78. (defun dirname (directory)
  79. (car (last (pathname-directory directory))))
  80. (defun guess-album-title (directory photos)
  81. (declare (ignore photos))
  82. (dirname directory))
  83. (defvar *album-path-format*
  84. '(:year "/" :year "-" (:month 2) "-" (:day 2) "-" :name "/"))
  85. (defun guess-album-path (directory photos)
  86. (let ((from (reduce #'local-time:timestamp-minimum photos :key (agetter :created-at)))
  87. (format (sublis `((:name . ,(dirname directory)))
  88. *album-path-format*)))
  89. (local-time:format-timestring nil from :format format)))
  90. (defun middle (seq)
  91. (elt seq (floor (length seq) 2)))
  92. (defun imagep (path)
  93. (member (string-downcase (pathname-type path)) '("jpg" "png") :test #'equal))
  94. (defun directory-images (directory)
  95. (remove-if-not 'imagep (uiop:directory-files directory)))
  96. (defun photo-item (info)
  97. `((:src . ,(map-path (aget :path info)))
  98. (:path . ,(namestring (aget :path info)))
  99. (:title . ,(aget :name info))
  100. (:w . ,(first (aget :dim info)))
  101. (:h . ,(second (aget :dim info)))))
  102. (defun load-album (directory)
  103. (let ((photos (mapcar 'load-photo-info (directory-images directory))))
  104. (when photos
  105. `((:title . ,(guess-album-title directory photos))
  106. (:cover . ,(photo-item (middle photos)))
  107. (:dest . ,(guess-album-path directory photos))
  108. (:photos . ,(mapcar 'photo-item photos))))))
  109. (defun fs-load-albums (directory)
  110. (delete nil (list* (load-album directory)
  111. (loop for subdir in (uiop:subdirectories directory)
  112. append (fs-load-albums subdir)))))
  113. (defvar *path-url-mapping* nil "alist of path-2-url mapping")
  114. (defun map-path (path)
  115. (loop for (base-path . base-url) in *path-url-mapping*
  116. for rel-path = (uiop:subpathp path base-path)
  117. when rel-path do (return (concatenate 'string base-url (namestring rel-path)))
  118. finally (error "Can't map path ~S" path)))
  119. (defun other-images (directory images)
  120. (remove-if #'(lambda (img) (member img images :test #'equal))
  121. (directory-images directory)))
  122. (defun directories (files)
  123. (delete-duplicates (mapcar #'uiop:pathname-directory-pathname files) :test #'equal))
  124. (defun move-file-to (file dest)
  125. (let ((destpath (uiop:make-pathname* :defaults file :directory (pathname-directory dest))))
  126. (when (probe-file destpath)
  127. (error "File ~S exists" destpath))
  128. (uiop:rename-file-overwriting-target file destpath)
  129. destpath))
  130. (defun move-images-with-folders (files dest &optional after-move-func)
  131. (labels ((move (files dest)
  132. (dolist (f files)
  133. (funcall after-move-func (move-file-to f dest)))))
  134. (let ((dirs (directories files)))
  135. (unless after-move-func (setf after-move-func #'identity))
  136. (ensure-directories-exist dest)
  137. (move files dest)
  138. ;; Move all non-image files from original folders if no images left there.
  139. ;; Do not call after-move-func on them
  140. (setf after-move-func #'identity)
  141. (ignore-errors
  142. (dolist (dir dirs)
  143. (unless (other-images dir files)
  144. (move (uiop:directory-files dir) dest)
  145. (uiop:delete-empty-directory dir)))))))
  146. (defun import-album (name dest paths)
  147. (let (added-photos
  148. (files (remove-if-not #'(lambda (p) (uiop:subpathp p *incoming-path*))
  149. (mapcar #'probe-file paths))))
  150. (when files
  151. (with-db (db)
  152. (sqlite:with-transaction db
  153. (labels ((handle-moved (path)
  154. (let ((info (load-photo-info path)))
  155. (push (cons (db/add-photo db info) (aget :name info)) added-photos)
  156. (log:info "Moved" (aget :path info)))))
  157. (move-images-with-folders files dest #'handle-moved)
  158. (let ((album-id (db/add-album db name nil (car (middle added-photos)))))
  159. (loop for (photo-id . name) in (reverse added-photos)
  160. for idx from 1
  161. do (db/add-album-photo db album-id photo-id name idx))
  162. album-id)))))))
  163. (restas:define-route main ("")
  164. (asdf:system-relative-pathname :photo-store "index.html"))
  165. (restas:define-route incoming ("incoming/")
  166. (asdf:system-relative-pathname :photo-store "incoming.html"))
  167. ;; Assets file path
  168. (defparameter *assets-path*
  169. (asdf:system-relative-pathname :photo-store "assets/"))
  170. (restas:define-route assets/css ("css/:file" :content-type "text/css")
  171. (merge-pathnames file *assets-path*))
  172. (restas:define-route assets/js ("js/:file" :content-type "application/x-javascript")
  173. (merge-pathnames file *assets-path*))
  174. (restas:define-route assets/fonts ("fonts/:file")
  175. (merge-pathnames file *assets-path*))
  176. (defun json (object)
  177. (json:encode-json-to-string object))
  178. (defvar *incoming-path* nil "Path for incoming photos")
  179. (restas:define-route api/incoming ("api/incoming/"
  180. :content-type "application/json")
  181. (json (fs-load-albums *incoming-path*)))
  182. (restas:define-route api/incoming-import ("api/incoming/import/"
  183. :method :post
  184. :content-type "application/json")
  185. (let* ((album (json:decode-json-from-string (hunchentoot:raw-post-data :external-format :utf-8)))
  186. (name (aget :name album))
  187. (dest (aget :dest album))
  188. (paths (aget :paths album)))
  189. (if (uiop:relative-pathname-p dest)
  190. (let ((new-id (import-album name (uiop:merge-pathnames* dest *photo-storage-path*) paths)))
  191. (json (db-load-album new-id)))
  192. 400)))
  193. (restas:define-route api/albums ("api/albums/"
  194. :content-type "application/json")
  195. (json (db-load-albums)))
  196. (restas:define-route api/album ("api/albums/:album-id/"
  197. :content-type "application/json")
  198. (:sift-variables (album-id 'integer))
  199. (json (db-load-album album-id (hunchentoot:get-parameter "full"))))
  200. (restas:define-route api/album-set-cover ("api/albums/:album-id/cover/"
  201. :method :post
  202. :content-type "application/json")
  203. (:sift-variables (album-id 'integer))
  204. (let* ((data (json:decode-json-from-string (hunchentoot:raw-post-data :external-format :utf-8)))
  205. (cover-id (aget :id data)))
  206. (db-set-album-cover album-id cover-id)
  207. "OK"))
  208. (restas:define-route api/album-hide ("api/albums/:album-id/hide/"
  209. :method :post
  210. :content-type "application/json")
  211. (:sift-variables (album-id 'integer))
  212. (let* ((data (json:decode-json-from-string (hunchentoot:raw-post-data :external-format :utf-8)))
  213. (photo-id (aget :id data))
  214. (hidden (aget :hidden data)))
  215. (db-set-album-photo-hidden album-id photo-id hidden)
  216. "OK"))
  217. (defvar *photo-storage-path* nil "Destination storage path")
  218. (defun start (&key (address "0.0.0.0") (port 8800))
  219. (let ((config (asdf:system-relative-pathname :photo-store "config.lisp")))
  220. (when (probe-file config)
  221. (load config)))
  222. (restas:start '#:photo-store :address address :port port))