diff --git a/auth.lisp b/auth.lisp new file mode 100644 index 0000000..e6a34ed --- /dev/null +++ b/auth.lisp @@ -0,0 +1,54 @@ +;;; auth.lisp + +(in-package :seanut) + +(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" + (json-request (format-url domain "Users/AuthenticateByName") + (generate-authorization "") + :method :post + :content `(("Username" . ,(getf options :username)) + ("Pw" . ,(getf options :password))))))) + +(defun quick-connect-dance (domain) + (let* ((auth (generate-authorization "")) + (qc-session (handler-case + (json-request (format-url domain "QuickConnect/Initiate") auth) + (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)) + auth))) + (setf authed (gethash "Authenticated" state) + counter (1+ counter))) + + :finally + (when (> counter 20) + (error "QuickConnect session timed out."))) + (gethash "AccessToken" + (json-request (format-url domain "Users/AuthenticateWithQuickConnect") auth + :method :post + :content (jzon:stringify (alist-hash-table `(("Secret" . ,(gethash "Secret" qc-session))))) + :extra-headers '(("Content-Type" . "application/json")))))) diff --git a/command-line.lisp b/command-line.lisp new file mode 100644 index 0000000..0d97ad1 --- /dev/null +++ b/command-line.lisp @@ -0,0 +1,50 @@ +;;; command-line.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")) diff --git a/seanut.asd b/seanut.asd index 3437786..2535e8d 100644 --- a/seanut.asd +++ b/seanut.asd @@ -11,6 +11,9 @@ #:alexandria) :components ((:file "package") (:file "util") + (:file "web") + (:file "auth") + (:file "command-line") (:file "seanut")) :entry-point "seanut::main" :build-operation "program-op" diff --git a/seanut.lisp b/seanut.lisp index a1e897d..6f19dbf 100644 --- a/seanut.lisp +++ b/seanut.lisp @@ -2,74 +2,13 @@ (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) + "prompt the user with y-or-n-p and download ITEM" + ;; debug print statements lol (format t "~A~%" auth) (y-or-n-p) + + (labels ((generate-root-name (&optional add-trailing) (format nil "~A (~A)~@[ Season ~A~]~@[/~]" (gethash "Name" item) @@ -125,57 +64,7 @@ if DESTINATION is non-nil, dumps media into that directory, otherwise it uses CW (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" diff --git a/util.lisp b/util.lisp index 9b0ff5a..52f4c97 100644 --- a/util.lisp +++ b/util.lisp @@ -2,8 +2,7 @@ (in-package :seanut) -(declaim (inline seanut-version generate-authorization ensure-scheme - json-request)) +(declaim (inline seanut-version)) (defun maybe-parse-integer (str) (or (parse-integer str :junk-allowed t) -1)) @@ -25,17 +24,6 @@ (string-to-octets str)) 'list))) -(defun generate-authorization (token) - "generates a properly formatted authorization header" - (format nil *authorization-format* - (seanut-version) (uiop:hostname) (md5-string (uiop:hostname)) - (seanut-version) token)) - -(defun format-url (str slug &rest args) - (format nil "~:[https://~;~]~A/~A" - (uiop:string-prefix-p "https://" str) - str (apply #'format `(nil ,slug ,@args)))) - (defmacro quit-with-message (code message &rest args) `(progn (format t (concatenate 'string ,message "~&") ,@args) @@ -47,5 +35,4 @@ (user-abort () (uiop:quit 0)) ,@extra-cases)) -(defun json-request (url auth &key (method :get)) - (parse (dex:request url :method method :headers `(("X-Emby-Authorization" . ,auth))))) + diff --git a/web.lisp b/web.lisp new file mode 100644 index 0000000..df78173 --- /dev/null +++ b/web.lisp @@ -0,0 +1,45 @@ +;;; web.lisp + +(in-package :seanut) + +(eval-when (:compile-toplevel) + (declaim (inline json-request format-url generate-authorization download-media))) + +(defun generate-authorization (token) + "generates a properly formatted authorization header" + (format nil *authorization-format* + (seanut-version) (uiop:hostname) (md5-string (uiop:hostname)) + (seanut-version) token)) + +(defun format-url (domain slug &rest args) + "formats DOMAIN into a url, ensures we include the url scheme + +SLUG is a format-coded string that represents the path for the query +ARGS are the arguments for the SLUG format string" + (format nil "~:[https://~;~]~A/~A" + (uiop:string-prefix-p "https://" domain) + domain (apply #'format `(nil ,slug ,@args)))) + +(defun json-request (url auth &key (method :get) extra-headers content) + "makes a request to URL, using AUTH as the X-Emby-Authorization header and METHOD as the http method (defaults to get) and parses the returned value with jzon:parse + +if EXTRA-HEADERS is non-nil, includes them in the headers alongside the X-Emby-Authorization one +if CONTENT is non-nil, passes that along to the request" + (parse (dex:request url :method method + :content content + :headers `(("X-Emby-Authorization" . ,auth) + ,@extra-headers)))) + +(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))))