|
|
|
|
@ -11,6 +11,13 @@
|
|
|
|
|
: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"
|
|
|
|
|
@ -23,12 +30,18 @@
|
|
|
|
|
:meta-var "TYPE"
|
|
|
|
|
:arg-parser #'validate-media-type
|
|
|
|
|
:description "media type to base our query on")
|
|
|
|
|
(:name :token
|
|
|
|
|
:short #\t
|
|
|
|
|
:long "token"
|
|
|
|
|
:meta-var "TOKEN"
|
|
|
|
|
(:name :username
|
|
|
|
|
:short #\u
|
|
|
|
|
:long "username"
|
|
|
|
|
:meta-var "USERNAME"
|
|
|
|
|
:arg-parser #'identity
|
|
|
|
|
:description "access token for specified jellyfin server")
|
|
|
|
|
: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"
|
|
|
|
|
@ -36,26 +49,175 @@
|
|
|
|
|
:arg-parser #'maybe-parse-integer
|
|
|
|
|
:description "specify specific season to download, if downloading a show"))
|
|
|
|
|
|
|
|
|
|
(defun build-search-query ())
|
|
|
|
|
(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 (name url header &optional destination)
|
|
|
|
|
(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"
|
|
|
|
|
(let ((output (or destination #P"./")))
|
|
|
|
|
(dexador:fetch url (merge-pathnames name output)
|
|
|
|
|
:if-exists nil
|
|
|
|
|
:headers `(("X-Emby-Authorization" . ,header)))))
|
|
|
|
|
(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"
|
|
|
|
|
(multiple-value-bind (opts args) (get-opts)
|
|
|
|
|
(when (getf opts :help)
|
|
|
|
|
(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)))
|
|
|
|
|
(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)))
|
|
|
|
|
(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))))
|
|
|
|
|
|