diff --git a/README.md b/README.md index 049c6ec..9e3f542 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.lisp b/package.lisp index adf5454..e1e8a0b 100644 --- a/package.lisp +++ b/package.lisp @@ -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")) diff --git a/seanut.asd b/seanut.asd index d57c604..3437786 100644 --- a/seanut.asd +++ b/seanut.asd @@ -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")) diff --git a/seanut.lisp b/seanut.lisp index a818e9c..a1e897d 100644 --- a/seanut.lisp +++ b/seanut.lisp @@ -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)))) diff --git a/util.lisp b/util.lisp index af80d21..9b0ff5a 100644 --- a/util.lisp +++ b/util.lisp @@ -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)))))