near feature complete (not tested :3c)

main
a. fox 2 years ago
parent 1e576d8443
commit 303d2d242f

@ -11,7 +11,9 @@ a command line utility to bulk download media from jellyfin servers (e.g., shows
## Running
`$ ./seanut -t your_Cool&Token -m MusicAlbum -o ~/Downloads/Jellyfin/Media https://your.jellyfin.domain "My Cool Album"`
`$ ./seanut -u Username -p hunter2 -m MusicAlbum -o ~/Downloads/Jellyfin/Media https://your.jellyfin.domain "My Cool Album"`
`$ ./seanut -q -m MusicAlbum -o ~/Downloads/Jellyfin/Media https://your.jellyfin.domain "My Cool Album"`
## License

@ -4,6 +4,8 @@
(:use #:cl #:with-user-abort)
(:local-nicknames (:jzon :com.inuoe.jzon))
(:import-from :alexandria
:alist-hash-table)
(:import-from :quri
:url-encode
:url-decode)
@ -23,6 +25,5 @@
"MediaBrowser Client=\"Seanut v~A\", Device=\"~A\", DeviceId=\"~A\", Version=\"~A\", Token=\"~A\"")
(defvar *valid-media-types*
'("AggregateFolder" "Audio" "AudioBook" "Book"
"BoxSet" "Movie" "MusicAlbum" "MusicArtist" "MusicGenre"
"MusicVideo" "Playlist" "Season" "Series" "Trailer"))
'("Book" "BoxSet" "Movie" "MusicAlbum" "MusicArtist"
"MusicGenre" "Playlist" "Season" "Series"))

@ -7,7 +7,8 @@
:version "0.0.1"
:serial t
:depends-on (#:dexador #:with-user-abort #:unix-opts
#:com.inuoe.jzon #:babel #:ironclad #:quri)
#:com.inuoe.jzon #:babel #:ironclad #:quri
#:alexandria)
:components ((:file "package")
(:file "util")
(:file "seanut"))

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

@ -2,7 +2,8 @@
(in-package :seanut)
(declaim (inline seanut-version generate-authorization))
(declaim (inline seanut-version generate-authorization ensure-scheme
json-request))
(defun maybe-parse-integer (str)
(or (parse-integer str :junk-allowed t) -1))
@ -30,7 +31,21 @@
(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 ,message ,@args)
(format t (concatenate 'string ,message "~&") ,@args)
(uiop:quit ,code)))
(defmacro handle-user-abort (form &rest extra-cases)
`(handler-case
(with-user-abort ,form)
(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)))))

Loading…
Cancel
Save