music.lisp 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.music
  3. (:use :cl :chatikbot.common :alexandria))
  4. (in-package :chatikbot.plugins.music)
  5. (defvar *cookies* (cl-cookie:make-cookie-jar))
  6. (defvar *deluge-api* "http://localhost:8112/json")
  7. (defvar *deluge-password* "chads")
  8. (defvar *deluge-request-id* 1)
  9. (defvar *chad-music-stats-url* "http://localhost:5000/api/stats")
  10. (defvar *chad-music-rescan-url* "http://localhost:5000/api/rescan")
  11. (defvar *slskd-api* "http://localhost:5015/api/v0")
  12. (defvar *slskd-downloads-dir* "/data/uploads/batch2/")
  13. (defun jojo-request (url &rest args &key method parameters content headers basic-auth bearer cookie-jar keep-alive use-connection-pool timeout ssl-key-file ssl-cert-file ssl-key-password stream verbose proxy insecure ca-path user-agent (as :plist))
  14. (declare (ignore method parameters basic-auth cookie-jar keep-alive use-connection-pool timeout ssl-key-file ssl-cert-file ssl-key-password stream verbose proxy insecure ca-path user-agent))
  15. (remf args :as)
  16. (remf args :bearer)
  17. (when content
  18. (push (cons :content-type "application/json") headers))
  19. (when bearer
  20. (push (cons :authorization (format nil "Bearer ~a" bearer)) headers))
  21. (remf args :headers)
  22. (multiple-value-bind (body status headers uri)
  23. (handler-bind
  24. ((sb-int:broken-pipe #'(lambda (c) (declare (ignorable c))
  25. (invoke-restart (find-restart 'dexador:retry-request)))))
  26. (apply #'http-request url :headers headers args))
  27. (unless (stringp body)
  28. (setf body (trivial-utf-8:utf-8-bytes-to-string body)))
  29. (values (jojo:parse body :as as) status headers uri)))
  30. (defun deluge-request (method &optional params is-auth)
  31. (let* ((content (trivial-utf-8:string-to-utf-8-bytes
  32. (jojo:to-json `(:|id| ,(incf *deluge-request-id*)
  33. :|method| ,method
  34. :|params| ,params))))
  35. (response (jojo-request *deluge-api* :method :post
  36. :content content
  37. :cookie-jar *cookies*))
  38. (error-message (getf (getf response :|error|) :|message|)))
  39. (if error-message
  40. (if (and (null is-auth)
  41. (equal error-message "Not authenticated"))
  42. (progn
  43. (deluge-request "auth.login" (list *deluge-password*) t)
  44. (deluge-request method params t)) ;; Retry original request
  45. (error (format nil "Deluge error: ~a" error-message)))
  46. (getf response :|result|))))
  47. (defun infohash-magnet (info-hash)
  48. (concatenate 'string "magnet:?xt=urn:btih:" info-hash))
  49. (defun deluge-add-info-hash (info-hashes)
  50. (unless (listp info-hashes)
  51. (setf info-hashes (list info-hashes)))
  52. (let ((torrents (loop for ih in info-hashes
  53. collect (list :|path| (infohash-magnet ih)
  54. :|options| '(:|add_paused| :true)))))
  55. (deluge-request "web.add_torrents" (list torrents))))
  56. (defun deluge-get-torrents ()
  57. (deluge-request "core.get_session_state"))
  58. (defun deluge-get-torrent-status (info-hash fields)
  59. (deluge-request "core.get_torrent_status" (list info-hash fields)))
  60. (defun deluge-get-torrents-status (filter fields)
  61. (deluge-request "core.get_torrents_status" (list filter fields)))
  62. (defun deluge-remove-torrent (ih &optional from-disk)
  63. (deluge-request "core.remove_torrent" (list ih from-disk)))
  64. (defparameter +deluge-default-status-fields+ '("name" "state" "save_path" "files" "file_priorities" "total_size" "total_wanted" "total_done" "num_files"))
  65. (defun raw-pathname (pathspec)
  66. (values (cl-ppcre:regex-replace-all "\\[" pathspec "\\\\[")))
  67. (defun deluge-get-torrent-files (info-hash)
  68. (let* ((status (deluge-request "core.get_torrent_status" (list info-hash +deluge-default-status-fields+)))
  69. (save-path (getf status :|save_path|)))
  70. (loop for file in (getf status :|files|)
  71. for prio in (getf status :|file_priorities|)
  72. do (setf (getf file :|prio|) prio
  73. (getf file :|path|) (pathname (raw-pathname (format nil "~A/~A" save-path (getf file :|path|)))))
  74. collect file)))
  75. (defun deluge-add-torrent-magnet (uri &optional options)
  76. (deluge-request "core.add_torrent_magnet" (list uri options)))
  77. (defun get-non-skipped (files)
  78. (remove 0 files :key (lambda (f) (getf f :|prio|))))
  79. (defun get-files-folders (files)
  80. (delete-duplicates (mapcar (lambda (f) (uiop:pathname-directory-pathname (getf f :|path|))) files)))
  81. (defparameter +media-exts+ '("mp3" "flac" "ac3"))
  82. (defun media-p (path)
  83. (let* ((ext (subseq path (1+ (or (position #\. path :from-end t) (1- (length path)))))))
  84. (member (string-downcase ext) +media-exts+ :test #'equal)))
  85. (defun get-torrent-info (status)
  86. (labels ((f (field)
  87. (getf status field)))
  88. (let ((wanted-dirs (loop for file in (f :|files|)
  89. for prio in (f :|file_priorities|)
  90. for path = (getf file :|path|)
  91. when (media-p path)
  92. unless (= prio 0)
  93. collect (subseq path 0 (1+ (position #\/ path :from-end t))))))
  94. (list
  95. :name (f :|name|)
  96. :state (keyify (f :|state|))
  97. :save-path (f :|save_path|)
  98. :total-files (f :|num_files|)
  99. :total-size (f :|total_size|)
  100. :total-size-left (- (f :|total_size|) (f :|total_done|))
  101. :wanted-files (length wanted-dirs)
  102. :wanted-size (f :|total_wanted|)
  103. :media-dirs (remove-duplicates wanted-dirs :test #'equal)))))
  104. (defun get-import-notify (info action)
  105. (labels ((f (fl) (getf info fl)))
  106. (format nil "[[🎶]] ~a *~a*, ~a альбомов с ~a треками. Весит ~a.~@[ Осталось ~a треков, ~a~]" action
  107. (f :name)
  108. (length (f :media-dirs))
  109. (f :wanted-files)
  110. (format-size (f :wanted-size))
  111. (when (and (f :wanted-files-left) (positive-integer-p (f :wanted-files-left)))
  112. (f :wanted-files-left))
  113. (when (and (f :wanted-size-left) (positive-integer-p (f :wanted-size-left)))
  114. (format-size (f :wanted-size-left))))))
  115. (defun get-root-paths (info)
  116. (let ((roots (delete-duplicates (loop for dir in (getf info :media-dirs)
  117. collect (subseq dir 0 (1+ (position #\/ dir))))
  118. :test #'equal))
  119. (save (getf info :save-path)))
  120. (mapcar #'(lambda (r) (concatenate 'string save "/" r)) roots)))
  121. (defvar *beet-bin* '("sudo" "-u" "uploader" "/home/uploader/.local/bin/beet"))
  122. (defun beets-import-cmd (paths)
  123. (append *beet-bin*
  124. (list "import" "--move" "--quiet")
  125. paths))
  126. (defun run-import (paths &optional dry)
  127. (let ((cmd (beets-import-cmd paths)))
  128. (if dry (format t "Running ~{~a~^ ~}" cmd)
  129. (handler-case (uiop:run-program cmd)
  130. (error (e) (log:error "Error importing" paths e))))))
  131. (defun deluge-get-seeding-torrents ()
  132. (loop for (torrent state) on (deluge-get-torrents-status '(:|state| "Seeding") '("name")) by #'cddr
  133. collect (cons (string torrent) (getf state :|name|))))
  134. (defun deluge-pause-torrents (&rest torrents)
  135. (deluge-request "core.pause_torrent" (list torrents)))
  136. (defun deluge-delete-skipped (torrent &optional dry)
  137. (let* ((status (deluge-request "core.get_torrent_status" (list torrent +deluge-default-status-fields+)))
  138. (save-path (getf status :|save_path|)))
  139. (loop for file in (getf status :|files|)
  140. for prio in (getf status :|file_priorities|)
  141. for path = (pathname (raw-pathname (format nil "~A/~A" save-path (getf file :|path|))))
  142. when (zerop prio)
  143. when (probe-file path)
  144. do (if dry (format t "Deleting '~A'~%" path)
  145. (uiop:delete-file-if-exists path)))))
  146. (defun send-notify (msg &optional dry)
  147. (if dry (format t "~a~%" msg)
  148. (loop for *chat-id* in (lists-get :music-notify)
  149. do (bot-send-message msg :parse-mode "markdown"))))
  150. (defun request-chad-music-rescan ()
  151. (json-request *chad-music-rescan-url* :method :post))
  152. (defun process-downloaded-torrent (ih &optional dry)
  153. (let* ((status (deluge-get-torrent-status ih +deluge-default-status-fields+))
  154. (info (get-torrent-info status))
  155. (notify (get-import-notify info "Добавляем"))
  156. (paths (get-root-paths info)))
  157. (send-notify notify dry)
  158. (deluge-pause-torrents ih)
  159. (deluge-delete-skipped ih dry)
  160. (run-import paths dry)
  161. (request-chad-music-rescan)
  162. (if dry (format t "Removing torrent ~a" ih)
  163. (deluge-remove-torrent ih))))
  164. (defvar *slskd-bearer* nil)
  165. (defun slskd-request (path &key method parameters content is-auth)
  166. (let* ((content (when content (trivial-utf-8:string-to-utf-8-bytes
  167. (jojo:to-json content)))))
  168. (handler-case (jojo-request (format nil "~a/~a" *slskd-api* path)
  169. :bearer *slskd-bearer*
  170. :method method
  171. :content content
  172. :parameters parameters)
  173. (dex:http-request-unauthorized ()
  174. (unless is-auth
  175. (with-secret ((&optional username password) '(:slskd))
  176. (setf *slskd-bearer*
  177. (getf (slskd-request "session"
  178. :method :post
  179. :content `(:|username| ,username
  180. :|password| ,password)
  181. :is-auth t)
  182. :|token|)))
  183. (slskd-request path :method method :parameters parameters :content content))))))
  184. (defun slskd-get-download-directories ()
  185. (loop for dl in (slskd-request "transfers/downloads" :method :get)
  186. append (getf dl :|directories|)))
  187. (defun slskd-file-succeeded-p (file)
  188. (equal (getf file :|state|) "Completed, Succeeded"))
  189. (defun slskd-file-complete-error-p (file)
  190. (let ((state (getf file :|state|)))
  191. (and (not (slskd-file-succeeded-p file))
  192. (equal (subseq state 0 (min (length state) 11))
  193. "Completed, "))))
  194. (defun slskd-download-file (file)
  195. (jojo-request (format nil "~a/~a" *slskd-downloads-url* (getf file :|username|))
  196. :method :post
  197. :content (trivial-utf-8:string-to-utf-8-bytes
  198. (jojo:to-json `(:|filename| ,(getf file :|filename|)
  199. :|size| ,(getf file :|size|))))))
  200. (defun slskd-remove-download (file)
  201. (jojo-request (format nil "~a/~a/~a" *slskd-downloads-url*
  202. (getf file :|username|)
  203. (getf file :|id|))
  204. :method :delete
  205. :parameters `(("remove" . "true"))))
  206. (defun slskd-get-download-path (dir)
  207. (format nil "~a~a" *slskd-downloads-dir*
  208. (subseq (getf dir :|directory|)
  209. (1+ (position #\\ (getf dir :|directory|) :from-end t)))))
  210. (defun get-slskd-dir-info (dir)
  211. (labels ((f (field)
  212. (getf dir field)))
  213. (multiple-value-bind (total-files total-files-left total-size total-size-left wanted-files wanted-files-left wanted-size wanted-size-left)
  214. (loop for file in (f :|files|)
  215. for path = (getf file :|filename|)
  216. for file-left = (not (slskd-file-succeeded-p file))
  217. for size = (getf file :|size|)
  218. for size-left = (getf file :|bytesRemaining|)
  219. count file into total-files
  220. count file-left into total-files-left
  221. sum size into total-size
  222. sum size-left into total-size-left
  223. when (media-p path)
  224. count file into wanted-files and
  225. count file-left into wanted-files-left and
  226. sum size into wanted-size and
  227. sum size-left into wanted-size-left
  228. finally (return (values total-files total-files-left total-size total-size-left wanted-files wanted-files-left wanted-size wanted-size-left)))
  229. (list
  230. :name (f :|directory|)
  231. :save-path (slskd-get-download-path dir)
  232. :total-files total-files
  233. :total-files-left total-files-left
  234. :total-size total-size
  235. :total-size-left total-size-left
  236. :wanted-files wanted-files
  237. :wanted-files-left wanted-files-left
  238. :wanted-size wanted-size
  239. :wanted-size-left wanted-size-left
  240. :media-dirs (list (f :|directory|))))))
  241. (defun slskd-download-complete-p (dir)
  242. (every #'slskd-file-succeeded-p (getf dir :|files|)))
  243. (defun slskd-process-download-dir (dir)
  244. (let ((files (getf dir :|files|)))
  245. ;; Retry failed
  246. (when (some #'slskd-file-succeeded-p files)
  247. (loop for file in files
  248. when (slskd-file-complete-error-p file)
  249. do (slskd-download-file file)))
  250. ;; Import complete
  251. (when (every #'slskd-file-succeeded-p files)
  252. (send-notify (get-import-notify (get-slskd-dir-info dir) "Добавляем"))
  253. (run-import (slskd-get-download-path dir))
  254. (loop for file in files do (slskd-remove-download file))
  255. (request-chad-music-rescan))))
  256. (defun process-imports ()
  257. (loop
  258. (loop for (ih . name) in (deluge-get-seeding-torrents)
  259. do (handler-case (process-downloaded-torrent ih)
  260. (error (c) (log:error "Error processing torrent" name c))))
  261. (loop for dir in (slskd-get-download-directories)
  262. do (handler-case (slskd-process-download-dir dir)
  263. (error (c) (log:error "Error processing slskd" c))))
  264. (sleep 1)))
  265. (defvar *watcher* nil "Importer thread ")
  266. (defvar *lock* (bordeaux-threads:make-recursive-lock "Music watch"))
  267. (defun ensure-watcher ()
  268. (bt:with-recursive-lock-held (*lock*)
  269. (if (lists-get :music-watch)
  270. (unless *watcher*
  271. (setf *watcher* (bt:make-thread 'process-imports :name "music watch")))
  272. (when *watcher*
  273. (bt:destroy-thread *watcher*)
  274. (setf *watcher* nil))))
  275. (values))
  276. (defun handle-set-watch (enable)
  277. (lists-set-entry :music-watch *chat-id* enable)
  278. (ensure-watcher)
  279. (bot-send-message (if enable "[🎶] Импортим музло" "[🎶] Пусть мешки парятся")))
  280. (defun handle-status ()
  281. (let* ((db-stats (when *chad-music-stats-url*
  282. (json-request *chad-music-stats-url*)))
  283. (stats (loop
  284. for (torrent status) on (deluge-get-torrents-status nil '("state" "name" "total_wanted")) by #'cddr
  285. for state = (getf status :|state|)
  286. when (equal state "Downloading") counting t into down
  287. when (equal state "Seeding") counting t into seed
  288. summing (getf status :|total_wanted|) into size
  289. finally (return (list :down down :seed seed :size size)))))
  290. (bot-send-message (format nil "[[🎶]] Осталось скачать ~a, заимпортить ~a торрентов, общий объём ~a.~%Сейчас в базе ~a исполнителей с ~a альбомами и ~a треками, общей длительностью ~a"
  291. (getf stats :down) (getf stats :seed) (format-size (getf stats :size))
  292. (agets db-stats "artists")
  293. (agets db-stats "albums")
  294. (agets db-stats "tracks")
  295. (agets db-stats "duration"))
  296. :parse-mode "markdown")))
  297. (defparameter +magnet-regex+ (ppcre:create-scanner "magnet:\\?\\S+"))
  298. (defun handle-add-torrent (magnet)
  299. (let ((ih (deluge-add-torrent-magnet magnet)))
  300. (bot-send-message
  301. (if ih
  302. (let* ((status (deluge-get-torrent-status ih +deluge-default-status-fields+))
  303. (info (get-torrent-info status)))
  304. (get-import-notify info "Качаем"))
  305. "Не добавил. Может уже?"))))
  306. (def-message-cmd-handler handle-cmd-music (:music)
  307. (with-chat-in-list :music-admins
  308. (let ((arg (car *args*)))
  309. (cond
  310. ((and (= 1 (length *args*)) (member (string-downcase arg) '("on" "off") :test 'equal))
  311. (handle-set-watch (equal "on" arg)))
  312. ((ppcre:scan-to-strings +magnet-regex+ arg)
  313. (handle-add-torrent (ppcre:scan-to-strings +magnet-regex+ arg)))
  314. (:otherwise (handle-status))))))
  315. (add-hook :starting #'ensure-watcher)