;;;; seanut.lisp (in-package #:seanut) (define-opts (:name :help :short #\h :long "help" :description "prints this help") (:name :version :short #\v :long "version" :description "prints the version") (:name :assume-yes :long "no-prompt" :description "assumes yes for all download prompts") (:name :quick-connect-p :short #\q :long "quick-connect" :description "alternative login method to providing username/password - times out after ~1min") (:name :output :short #\o :long "output" :meta-var "DIR" :arg-parser #'uiop:ensure-directory-pathname :description "location to save downloaded media") (:name :media-type :short #\m :long "media-type" :meta-var "TYPE" :arg-parser #'validate-media-type :description "media type to base our query on") (:name :username :short #\u :long "username" :meta-var "USERNAME" :arg-parser #'identity :description "username for the jellyfin server") (:name :password :short #\p :long "password" :meta-var "PASSOWRD" :arg-parser #'identity :description "passowrd for the jellyfin server") (:name :season-number :short #\s :long "season" :meta-var "SEASON" :arg-parser #'maybe-parse-integer :description "specify specific season to download, if downloading a show")) (defun run-search-query (domain auth type name) (gethash "Items" (json-request (format-url domain "Items?fields=Path&includeItemTypes=~A&recursive=true&searchTerm=~A" type name) auth))) (defun download-media (path url auth) "downloads the media at URL, using HEADER as the authorization header. if DESTINATION is non-nil, dumps media into that directory, otherwise it uses CWD" (dex:fetch url path :if-exists nil :headers `(("X-Emby-Authorization" . ,auth)))) ;; needs to check the options and see if we're searching for a "Series". if we are ;; we need to use different APIs for getting and downloading the episodes. this at ;; least makes it a bit easier to ensure that we're only getting episodes from the ;; correct seasons without much extra processing (defun prompt-and-download (domain auth item opts &optional assume-yes) (format t "~A~%" auth) (y-or-n-p) (labels ((generate-root-name (&optional add-trailing) (format nil "~A (~A)~@[ Season ~A~]~@[/~]" (gethash "Name" item) (gethash "ProductionYear" item) (getf opts :season-number) add-trailing)) (ensure-download-dir (&optional new-path) (ensure-directories-exist (merge-pathnames (or new-path "") (merge-pathnames (generate-root-name 'add-slash) (getf opts :output #P"./"))))) (download-item-or-children (item &optional parents) ;; PARENTS is a list of all parent names with FIRST being ;; the oldest grandparent (for building complete download path) (let ((children (if (string= (gethash "Type" item) "Season") (json-request (format-url domain "Shows/~A/Episodes?fields=Path~@[&season=~A~]" (gethash "Id" item) (getf opts :season-number)) auth) (json-request (format-url domain "Items?fields=Path&parentId=~A" (gethash "Id" item)) auth)))) (if (zerop (length children)) ;; download single file ;; to get the extension type i think we may need to include ;; "Path" field in the api request, then use that to get the extension (download-media (format nil "~A~A~{~A~^/~}~A.~A" (getf opts :output #P"./") (generate-root-name 'trailing) parents (gethash "Name" item) (pathname-type (gethash "Path" item))) (format-url domain "Items/~A/Download" (gethash "Id" item)) auth) ;; if the item has children we need to download them. ;; to accomplish this we get the list of children ;; and loop over them, recursing for each one ;; ensure that a directory exists for Parent ;; then recurse with children (progn (ensure-download-dir (format nil "~A~A~{~A/~}~A" (getf opts :output #P"./") (generate-root-name 'trailing) parents (gethash "Name" item))) (loop :for child :in children :do (apply #'download-item-or-children `(,child (,@parents ,(gethash "Name" item)))))))))) (when (or assume-yes (y-or-n-p "Download ~A" (generate-root-name))) (ensure-download-dir) (download-item-or-children item)))) (defun get-access-token (domain options) (if (getf options :quick-connect-p) ;; go through the whole quick connect rigamarole (quick-connect-dance domain) ;; authenticates the user via username and password (gethash "AccessToken" (parse (dex:post (format-url domain "Users/AuthenticateByName") :content `(("Username" . ,(getf options :username)) ("Pw" . ,(getf options :password))) :headers `(("X-Emby-Authorization" . ,(generate-authorization "")))))))) (defun quick-connect-dance (domain) (let ((qc-session (handler-case (json-request (format-url domain "QuickConnect/Initiate") (generate-authorization "")) (dex:http-request-unauthorized () (error "QuickConnect not enabled on this server."))))) ;; initiate quick connect session ;; display code to user ;; sleep 5 seconds ;; poll QuickConnect/Connect?secret=~A ;; until "Authenticated" is t or we've looped 20 times (~1min) ;; if we time out signal an error ;; else POST to Users/AuthenticateWithQuickConnect with "Secret" ;; return "AccessToken" (format t "QuickConnect Code: ~A~%" (gethash "Code" qc-session)) (force-output) (loop :with counter := 1 :with authed :until (or authed (> counter 20)) :do (sleep 5) (let ((state (json-request (format-url domain "QuickConnect/Connect?secret=~A" (gethash "Secret" qc-session)) (generate-authorization "")))) (setf authed (gethash "Authenticated" state) counter (1+ counter))) :finally (when (> counter 20) (error "QuickConnect session timed out."))) (gethash "AccessToken" (parse (dex:post (format-url domain "Users/AuthenticateWithQuickConnect") :content (jzon:stringify (alist-hash-table `(("Secret" . ,(gethash "Secret" qc-session))))) :headers `(("X-Emby-Authorization" . (generate-authorization "")) ("Content-Type" . "application/json"))))))) (defun main () "binary entry point" (handle-user-abort (multiple-value-bind (opts args) (get-opts) (when (or (getf opts :help) (and (every #'null args) (every #'null opts))) (opts:describe :usage-of "seanut" :args "DOMAIN MEDIA-NAME") (uiop:quit 0)) (when (getf opts :version) (quit-with-message 0 "seanut v~A" (seanut-version))) (unless (or (and (getf opts :username) (getf opts :password)) (getf opts :quick-connect-p)) (quit-with-message 1 "please provide username & password, or use quick connect")) (unless (getf opts :media-type) (quit-with-message 1 "Please specify media type to download.~%~A ~{~A~^, ~}" "Supported media types are:" *valid-media-types*)) (when (some #'null args) (quit-with-message 1 "domain and/or media name not provided")) (destructuring-bind (domain search-term) args (let* ((authorization (generate-authorization (get-access-token domain opts))) (results (run-search-query domain authorization (getf opts :media-type) (url-encode search-term)))) (if (< 0 (length results)) ;; FIXME: for some reason this access token is not "valid" enough to get ;; certain info? when we run the parentID search it craps out on us? ;; maybe its not something wrong with the token, but the auth string as a ;; whole? look into this more tomorrow (loop :for item :across results :do (prompt-and-download domain authorization item (getf opts :assume-yes))) (quit-with-message 0 "No results found for ~A" search-term))))) (error (e) (quit-with-message 1 "encountered error: ~A" e))))