reorganized things a bit

main
a. fox 2 years ago
parent 303d2d242f
commit a839ca60b0

@ -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"))))))

@ -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"))

@ -11,6 +11,9 @@
#:alexandria) #:alexandria)
:components ((:file "package") :components ((:file "package")
(:file "util") (:file "util")
(:file "web")
(:file "auth")
(:file "command-line")
(:file "seanut")) (:file "seanut"))
:entry-point "seanut::main" :entry-point "seanut::main"
:build-operation "program-op" :build-operation "program-op"

@ -2,74 +2,13 @@
(in-package #:seanut) (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) (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) (format t "~A~%" auth)
(y-or-n-p) (y-or-n-p)
(labels ((generate-root-name (&optional add-trailing) (labels ((generate-root-name (&optional add-trailing)
(format nil "~A (~A)~@[ Season ~A~]~@[/~]" (format nil "~A (~A)~@[ Season ~A~]~@[/~]"
(gethash "Name" item) (gethash "Name" item)
@ -125,57 +64,7 @@ if DESTINATION is non-nil, dumps media into that directory, otherwise it uses CW
(ensure-download-dir) (ensure-download-dir)
(download-item-or-children item)))) (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 () (defun main ()
"binary entry point" "binary entry point"

@ -2,8 +2,7 @@
(in-package :seanut) (in-package :seanut)
(declaim (inline seanut-version generate-authorization ensure-scheme (declaim (inline seanut-version))
json-request))
(defun maybe-parse-integer (str) (defun maybe-parse-integer (str)
(or (parse-integer str :junk-allowed t) -1)) (or (parse-integer str :junk-allowed t) -1))
@ -25,17 +24,6 @@
(string-to-octets str)) (string-to-octets str))
'list))) '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) (defmacro quit-with-message (code message &rest args)
`(progn `(progn
(format t (concatenate 'string ,message "~&") ,@args) (format t (concatenate 'string ,message "~&") ,@args)
@ -47,5 +35,4 @@
(user-abort () (uiop:quit 0)) (user-abort () (uiop:quit 0))
,@extra-cases)) ,@extra-cases))
(defun json-request (url auth &key (method :get))
(parse (dex:request url :method method :headers `(("X-Emby-Authorization" . ,auth)))))

@ -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))))
Loading…
Cancel
Save