From b23161f36ef0edf006324102acad2524dbaa682a Mon Sep 17 00:00:00 2001 From: Kiran Shila Date: Tue, 17 Aug 2021 15:40:17 -0700 Subject: [PATCH] Major refactoring Uses async in all the the *arr interactions Env vars are cached instead of function calls Specter now used in all get ins Adds profile selection, fixes #14 Shows all results by default, requesting a second time updates profile,fixes #13 Much better error handling from the HTTP request side of things, fixes #7 --- README.md | 10 +- deps.edn | 1 + src/doplarr/arr_utils.clj | 43 ++++--- src/doplarr/core.clj | 250 ++++++++++++++++++++------------------ src/doplarr/radarr.clj | 78 +++++++----- src/doplarr/sonarr.clj | 169 +++++++++++++++----------- 6 files changed, 302 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index 73527f2..dddaa4b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Why does this exist - Uses modern Discord slash commands and components, which provides a clean, performant UI on desktop and mobile -- Simple codebase, <1k lines of code which *should* make it easy to maintain +- Simple codebase, <1k lines of code which _should_ make it easy to maintain - Simple configuration, no need to have a whole web frontend - Powered by Clojure and [Discljord](https://github.com/IGJoshua/discljord), a markedly good language 😛 @@ -87,14 +87,6 @@ To skip the build, just download `Doplarr.jar` and `config.edn` from the release ### Optional Settings -By default, the profile for which requests will be made will be the -lowest-indexed (first) in your list of profiles. For me, this is the default -Any. You can change this by adding the config option `:radarr-profile-id` (or -`RADARR_PROFILE_ID` if you are using environment variables (docker) instead of -the config file) to the integer of the profile. This is however not exposed in the GUI for some reason, -so you'll have to use the Radarr API tool to find it. I plan on making this -nicer as this is creating some bugs. - Also, I'm limiting the size of the results in the drop down to 10, this can be set with `:max-results` in the config file of `MAX_RESULTS` as an environment variable. diff --git a/deps.edn b/deps.edn index 1cf865f..bab0665 100644 --- a/deps.edn +++ b/deps.edn @@ -3,6 +3,7 @@ :deps {org.clojure/clojure {:mvn/version "1.11.0-alpha1"} org.clojure/core.cache {:mvn/version "1.0.217"} yogthos/config {:mvn/version "1.1.8"} + com.rpl/specter {:mvn/version "1.1.3"} ch.qos.logback/logback-classic {:mvn/version "1.2.5"} org.suskalo/discljord {:git/url "https://github.com/IGJoshua/discljord" :sha "a417b0d543c68820ce0633b31d7c98c6b328c857"} org.clojure/core.async {:mvn/version "1.3.618"} diff --git a/src/doplarr/arr_utils.clj b/src/doplarr/arr_utils.clj index 1829385..bf4ca74 100644 --- a/src/doplarr/arr_utils.clj +++ b/src/doplarr/arr_utils.clj @@ -1,25 +1,36 @@ (ns doplarr.arr-utils (:require - [config.core :refer [env]] + [clojure.tools.logging :as log] + [clojure.core.async :as a] [hato.client :as hc])) -(defn http-request [method url key & params] - (let [response (hc/request - (apply merge - {:method method - :url url - :as :json - :headers {"X-API-Key" key}} - params))] - (when (:debug env) - (clojure.pprint/pprint response)) - response)) +(defn http-request [method url key & [params]] + (let [chan (a/chan)] + (hc/request + (merge + {:method method + :url url + :as :json + :async? true + :headers {"X-API-Key" key}} + params) + #(do + (a/put! chan %) + (a/close! chan)) + #(log/error % (ex-data %))) + chan)) (defn rootfolder [base-url key] - (->> (http-request :get (str base-url "/rootfolder") key) - :body - first - :path)) + (let [chan (a/promise-chan)] + (a/pipeline + 1 + chan + (map (comp :path first :body)) + (http-request + :get + (str base-url "/rootfolder") + key)) + chan)) (defn quality-profile-data [profile] {:name (:name profile) diff --git a/src/doplarr/core.clj b/src/doplarr/core.clj index 90c1dd9..c5fd00a 100644 --- a/src/doplarr/core.clj +++ b/src/doplarr/core.clj @@ -1,5 +1,6 @@ (ns doplarr.core (:require + [com.rpl.specter :as s] [doplarr.sonarr :as sonarr] [doplarr.radarr :as radarr] [discljord.messaging :as m] @@ -14,6 +15,8 @@ (defonce state (atom nil)) (defonce cache (cache/ttl-cache-factory {} :ttl 900000)) ; 15 Minute cache expiration, coinciding with the interaction token +(def channel-timeout 600000) + ;; Slash command setup (def request-command {:name "request" @@ -37,6 +40,31 @@ :description "Search term" :required true}]}]}) +(def max-results (delay (:max-results env 10))) + +(def search-fn {:series sonarr/search + :movie radarr/search}) + +(def profiles-fn {:series sonarr/quality-profiles + :movie radarr/quality-profiles}) + +(def request-fn {:series sonarr/request + :movie radarr/request}) + +(def timed-out-response {:content "Request timed out, please try again"}) + +(def interaction-types {1 :ping + 2 :application-command + 3 :message-component}) + +(def component-types {1 :action-row + 2 :button + 3 :select-menu}) + +(def request-thumbnail + {:series "https://thetvdb.com/images/logo.png" + :movie "https://i.imgur.com/44ueTES.png"}) + ;; Discljord setup (defn register-commands [guild-id] (m/bulk-overwrite-guild-application-commands! @@ -88,20 +116,6 @@ :components components :embeds embeds)) -(defn delete-interaction-response [interaction-token] - (m/delete-original-interaction-response! - (:messaging @state) - (:id @state) - interaction-token)) - -(def interaction-types {1 :ping - 2 :application-command - 3 :message-component}) - -(def component-types {1 :action-row - 2 :button - 3 :select-menu}) - (defn application-command-interaction-option-data [app-com-int-opt] [(keyword (:name app-com-int-opt)) (into {} (map (juxt (comp keyword :name) :value)) (:options app-com-int-opt))]) @@ -112,9 +126,9 @@ :token (:token interaction) :payload {:component-type (component-types (get-in interaction [:data :component-type])) - :component-id (get-in interaction [:data :custom-id]) - :name (get-in interaction [:data :name]) - :values (get-in interaction [:data :values]) + :component-id (s/select-one [:data :custom-id] interaction) + :name (s/select-one [:data :name] interaction) + :values (s/select-one [:data :values] interaction) :options (into {} (map application-command-interaction-option-data) (get-in interaction [:data :options]))}}) (defn request-button [uuid enabled?] @@ -124,124 +138,122 @@ :custom_id (str "request:" uuid) :label "Request"}) -(defn generate-select-menu-option [index result] +(defn select-menu-option [index result] {:label (:title result) :description (:year result) :value index}) -(defn generate-search-response [results uuid] - (if (empty? results) - {:content "Search result returned no hits"} - {:content "Choose one of the following results:" - :components [{:type 1 - :components [{:type 3 - :custom_id (str "select:" uuid) - :options (into [] (map-indexed generate-select-menu-option results))}]}]})) - -(def request-thumbnail - {:series "https://thetvdb.com/images/logo.png" - :movie "https://i.imgur.com/44ueTES.png"}) - -(defn selection-embed [selection request-type & {:keys [season]}] - (cond-> {:title (:title selection) - :description (:overview selection) - :image {:url (:remotePoster selection)} - :thumbnail {:url (request-thumbnail request-type)}} - season (assoc :fields [{:name "Season" - :value (if (= season -1) - "All" - season)}]))) - -(defn request [selection uuid & {:keys [season]}] - (let [request-type (get-in @cache [uuid :type])] - {:content (str "Request this " (name request-type) " ?") - :embeds [(selection-embed selection request-type :season season)] - :components [{:type 1 :components [(request-button uuid true)]}]})) - -(defn request-alert [selection uuid & {:keys [season]}] - (let [request-type (get-in @cache [uuid :type])] - {:content "This has been requested!" - :embeds [(selection-embed selection request-type :season season)]})) - -(defn select-season [series uuid] - {:content "Which season?" +(defn dropdown [content id options] + {:content content :components [{:type 1 :components [{:type 3 - :custom_id (str "select_season:" uuid) - :options (conj (map #(hash-map :label (str "Season: " %) :value %) (sonarr/missing-seasons series)) - {:label "All Seasons" :value "-1"})}]}]}) + :custom_id id + :options options}]}]}) -(defn max-results [] - (or (:max-results env) - 10)) +(defn search-response [results uuid] + (if (empty? results) + {:content "Search result returned no hits"} + (dropdown "Choose one of the following results" + (str "select:" uuid) + (map-indexed select-menu-option results)))) + +(defn selection-embed [selection & {:keys [season profile]}] + {:title (:title selection) + :description (:overview selection) + :image {:url (:remotePoster selection)} + :thumbnail {:url (request-thumbnail (if season :series :movie))} + :fields (filterv + identity + [{:name "Profile" + :value profile} + (when season + {:name "Season" + :value (if (= season -1) + "All" + season)})])}) + +(defn request [selection uuid & {:keys [season profile]}] + {:content (str "Request this " (if season "series" "movie") " ?") + :embeds [(selection-embed selection :season season :profile profile)] + :components [{:type 1 :components [(request-button uuid true)]}]}) + +(defn request-alert [selection & {:keys [season profile]}] + {:content "This has been requested!" + :embeds [(selection-embed selection :season season :profile profile)]}) -(defn start-request [interaction] +(defn select-season [series uuid] + (dropdown "Which season?" + (str "select_season:" uuid) + (conj (map #(hash-map :label (str "Season: " %) :value %) + (range 1 (inc (:seasonCount series)))) + {:label "All Seasons" :value "-1"}))) + +(defn select-profile [profiles uuid] + (dropdown "Which quality profile?" + (str "select_profile:" uuid) + (map #(hash-map :label (:name %) :value (:id %)) profiles))) + +(defn await-interaction [chan token] + (a/go + (a/alt! + (a/timeout channel-timeout) (do + (update-interaction-response token timed-out-response) + nil) + chan ([v] v)))) + +(defn make-request [interaction] (let [uuid (str (java.util.UUID/randomUUID)) id (:id interaction) token (:token interaction) search (:options (:payload interaction)) request-type (first (keys search)) - request-term (get-in search [request-type :term])] - ; Create the cache entry with the data we have so far - (swap! cache assoc-in [uuid :token] token) - (swap! cache assoc-in [uuid :type] request-type) + request-term (s/select-one [request-type :term] search) + chan (a/chan)] ; Send the in-progress response (interaction-response id token 5 :ephemeral? true) - ; Fetch the request - (let [perform-search (case request-type - :series sonarr/search - :movie radarr/search) - filter-aquired (case request-type - :series sonarr/aquired-all-seasons? - :movie :monitored) - results (->> (perform-search request-term) - (filter (complement filter-aquired)) - (take (max-results)) - (into []))] - ; Update the cache with these results - (swap! cache assoc-in [uuid :results] results) - ; Generate the results selector and update the thing - (update-interaction-response token (generate-search-response results uuid))))) - -(defn component-ack [interaction-id interaction-token] - (interaction-response interaction-id interaction-token 6)) - -(defn perform-request [uuid] - (let [selection (get-in @cache [uuid :selection]) - season (get-in @cache [uuid :season]) - type (get-in @cache [uuid :type])] - (case type - :movie (radarr/request selection) - :series (if (= -1 season) - (sonarr/request-all selection) - (sonarr/request-season selection season))))) + ; Create this command's channel + (swap! cache assoc uuid chan) + (a/go + (let [results (->> ((search-fn request-type) request-term) + a/> ((profiles-fn request-type)) + a/> (quality-profiles) - (sort-by :id) - first - :id))) +(defn search [search-term] + (let [chan (a/promise-chan)] + (a/pipeline + 1 + chan + (map :body) + (GET "/movie/lookup" {:query-params {:term search-term}})) + chan)) -(defn default-options [] - {:qualityProfileId (determine-quality-profile) - :monitored true - :minimumAvailability "announced" - :rootFolderPath (rootfolder (endpoint) (:radarr-api env)) - :addOptions {:searchForMovie true}}) +(defn quality-profiles [] + (let [chan (a/promise-chan)] + (a/pipeline + 1 + chan + (map (comp (partial map utils/quality-profile-data) :body)) + (GET "/qualityProfile")) + chan)) -(defn request [movie] - (http-request - :post - (str (endpoint) "/movie") - (:radarr-api env) - {:form-params (merge movie (default-options)) - :content-type :json})) +(defn request [movie & {:keys [profile-id]}] + (a/go + (POST + "/movie" + {:form-params (merge movie + {:qualityProfileId profile-id + :monitored true + :minimumAvailability "announced" + :rootFolderPath (a/> (quality-profiles) - (sort-by :id) - first - :id))) +(defn POST [endpoint & [params]] + (utils/http-request + :post + (str @base-url endpoint) + @api-key + params)) -(defn default-options [] - {:profileId (determine-quality-profile) - :monitored true - :seasonFolder true - :rootFolderPath (rootfolder (endpoint) (:sonarr-api env)) - :addOptions {:searchForMissingEpisodes true}}) +(defn PUT [endpoint & [params]] + (utils/http-request + :put + (str @base-url endpoint) + @api-key + params)) (defn search [search-term] - (:body (http-request - :get - (str (endpoint) "/series/lookup") - (:sonarr-api env) - {:query-params {:term search-term}}))) - -(defn started-aquisition? [series] - (contains? series :path)) - -(defn aquired-season? [series season] - (and (started-aquisition? series) - (get-in series [:seasons season :monitored]))) + (let [chan (a/promise-chan)] + (a/pipeline + 1 + chan + (map :body) + (GET "/series/lookup" {:query-params {:term search-term}})) + chan)) -(defn missing-seasons [series] - (if (started-aquisition? series) - (map :seasonNumber (filter (complement :monitored) (rest (:seasons series)))) - (rest (range (:seasonCount series))))) +(defn quality-profiles [] + (let [chan (a/promise-chan)] + (a/pipeline + 1 + chan + (map (comp (partial map utils/quality-profile-data) :body)) + (GET "/profile")) + chan)) -(defn aquired-all-seasons? [series] - (empty? (missing-seasons series))) +(defn request-options [profile-id] + (a/go + {:profileId profile-id + :monitored true + :seasonFolder true + :rootFolderPath (a/> (for [ssn (range (inc (:seasonCount series)))] - {:seasonNumber ssn - :monitored (= ssn season)}) - (into []) - (assoc series :seasons)) - (default-options)))] - (http-request - (if started? :put :post) - (str (endpoint) "/series") - (:sonarr-api env) - {:form-params series - :content-type :json}))) +(defn request-season [series season profile-id] + (a/go + (let [started? (started-aquisition? series) + series (if started? + (s/multi-transform + (s/multi-path + [:seasons + s/ALL + (comp (partial = season) :seasonNumber) + :monitored + (s/terminal-val true)] + [:profileId + (s/terminal-val profile-id)]) + series) + (merge (s/setval [:seasons + s/ALL + (comp (partial not= season) :seasonNumber) + :monitored] + false series) + (a/