diff --git a/deps.edn b/deps.edn index 1472a06..34fde64 100644 --- a/deps.edn +++ b/deps.edn @@ -10,6 +10,7 @@ cheshire/cheshire {:mvn/version "5.10.1"} fmnoise/flow {:mvn/version "4.1.0"} hato/hato {:mvn/version "0.8.2"} + integrant/integrant {:mvn/version "0.8.0"} camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"} org.clojure/alpha.spec {:git/url "https://github.com/clojure/spec-alpha2.git" :sha "c087ded910b3532a938b37e853df79fc3b9c48c1"}} diff --git a/src/doplarr/backends/overseerr.clj b/src/doplarr/backends/overseerr.clj new file mode 100644 index 0000000..7c13ca6 --- /dev/null +++ b/src/doplarr/backends/overseerr.clj @@ -0,0 +1,5 @@ +(ns doplarr.backends.overseerr) + +(defn search []) + +(defn request []) diff --git a/src/doplarr/backends/overseerr/specs.clj b/src/doplarr/backends/overseerr/specs.clj new file mode 100644 index 0000000..7810e05 --- /dev/null +++ b/src/doplarr/backends/overseerr/specs.clj @@ -0,0 +1 @@ +(ns doplarr.backends.overseerr.specs) diff --git a/src/doplarr/backends/radarr.clj b/src/doplarr/backends/radarr.clj new file mode 100644 index 0000000..913ff33 --- /dev/null +++ b/src/doplarr/backends/radarr.clj @@ -0,0 +1,45 @@ +(ns doplarr.backends.radarr + (:require + [config.core :refer [env]] + [clojure.spec.alpha :as spec] + [doplarr.utils :as utils] + [doplarr.backends.radarr.impl :as impl] + [doplarr.backends.radarr.specs :as specs] + [doplarr.backends.specs :as bs] + [clojure.core.async :as a])) + +(defn search [term] + (utils/request-and-process-body + impl/GET + #(into [] (map utils/process-search-result %)) + "/movie/lookup" + {:query-params {:term term}})) +(spec/fdef search + :args (spec/cat :term string?)) + +(defn request [payload] + (utils/request-and-process-body + impl/POST + (constantly nil) + "/movie" + {:form-params (utils/to-camel payload) + :content-type :json})) +(spec/fdef request + :args (spec/cat :payload ::specs/payload)) + +(defn additional-options [result] + (a/go + (let [quality-profiles (a/> quality-profiles + first + :id) + default-quality-profile (->> quality-profiles + (filter #(= default-quality-profile (:name %))) + first + :id) + :else quality-profiles)}))) +(spec/fdef additional-options + :args (spec/cat :result ::bs/result)) diff --git a/src/doplarr/backends/radarr/impl.clj b/src/doplarr/backends/radarr/impl.clj index 4421d3c..de945c7 100644 --- a/src/doplarr/backends/radarr/impl.clj +++ b/src/doplarr/backends/radarr/impl.clj @@ -11,64 +11,41 @@ (def api-key (delay (:radarr-api env))) (defn GET [endpoint & [params]] - (utils/http-request - :get - (str @base-url endpoint) - @api-key - params)) + (utils/http-request :get (str @base-url endpoint) @api-key params)) (defn POST [endpoint & [params]] - (utils/http-request - :post - (str @base-url endpoint) - @api-key - params)) + (utils/http-request :post (str @base-url endpoint) @api-key params)) (def rootfolder (delay (a/ result + (select-keys [:title :tmdb-id]) + (assoc :quality-profile-id quality-profile-id + :monitored true + :root-folder-path @rootfolder + :add-options {:search-for-movie true}))) +(spec/fdef request-payload + :args (spec/cat :result ::bs/result + :quality-profile-id ::bs/quality-profile-id) + :ret ::specs/payload) -(spec/fdef request-payload :args (spec/cat :arg ::bs/result :arg ::bs/quality-profile-id)) - -(defn request [payload] - (utils/request-and-process-body - POST - (constantly nil) - "/movie" - {:form-params (utils/to-camel payload) - :content-type :json})) - -(spec/fdef request :args (spec/cat :arg ::specs/payload)) - -(def payload {:title "Making 'The Matrix'" - :tmdb-id 684431 - :quality-profile-id 1 - :root-folder-path (a/> (:seasons details) + (filterv #(pos? (:season-number %))) + (mapv #(let [ssn (:season-number %)] + (hash-map :id ssn :name (str ssn))))) + {:keys [default-language-profile + default-quality-profile + partial-seasons]} env] + {:season (cond + (= 1 (count seasons)) (->> seasons + first + :id) + (false? partial-seasons) -1 + :else seasons) + :quality-profile-id (cond + (= 1 (count quality-profiles)) (->> quality-profiles + first + :id) + default-quality-profile (->> quality-profiles + (filter #(= default-quality-profile (:name %))) + first + :id) + :else quality-profiles) + :language-profile-id (cond + (= 1 (count language-profiles)) (->> language-profiles + first + :id) + default-language-profile (->> language-profiles + (filter #(= default-language-profile (:name %))) + first + :id) + :else language-profiles)}))) +(spec/fdef additional-options + :args (spec/cat :result ::bs/result)) diff --git a/src/doplarr/backends/sonarr/impl.clj b/src/doplarr/backends/sonarr/impl.clj index 7ceed73..cc3239b 100644 --- a/src/doplarr/backends/sonarr/impl.clj +++ b/src/doplarr/backends/sonarr/impl.clj @@ -1,6 +1,5 @@ (ns doplarr.backends.sonarr.impl (:require - [com.rpl.specter :as s] [clojure.core.async :as a] [config.core :refer [env]] [fmnoise.flow :as flow :refer [then else]] @@ -9,50 +8,41 @@ (def base-url (delay (str (:sonarr-url env) "/api/v3"))) (def api-key (delay (:sonarr-api env))) -(defn rootfolder [] (utils/rootfolder @base-url @api-key)) - (defn GET [endpoint & [params]] - (utils/http-request - :get - (str @base-url endpoint) - @api-key - params)) + (utils/http-request :get (str @base-url endpoint) @api-key params)) (defn POST [endpoint & [params]] - (utils/http-request - :post - (str @base-url endpoint) - @api-key - params)) + (utils/http-request :post (str @base-url endpoint) @api-key params)) (defn PUT [endpoint & [params]] - (utils/http-request - :put - (str @base-url endpoint) - @api-key - params)) + (utils/http-request :put (str @base-url endpoint) @api-key params)) -(defn search [term] - (a/go - (->> (a/> (a/> (:body %) - (map utils/quality-profile-data))) - (else utils/fatal-error)))) + (utils/request-and-process-body + GET + #(into [] (map utils/process-profile %)) + "/qualityProfile")) -(defn request-options [profile-id] - (a/go - {:profileId profile-id - :monitored true - :seasonFolder true - :rootFolderPath (a/> (a/> (a/> (a/> (a/> (:body %) - (merge series))) - (else utils/fatal-error)) - series))) - -(defn season-status [series & {:keys [season]}] +(defn status [series season] (let [ssn (->> (:seasons series) (filter (comp (partial = season) :seasonNumber)) first)] diff --git a/src/doplarr/backends/sonarr/specs.clj b/src/doplarr/backends/sonarr/specs.clj index f1c1a2a..d2c25e0 100644 --- a/src/doplarr/backends/sonarr/specs.clj +++ b/src/doplarr/backends/sonarr/specs.clj @@ -1,2 +1,13 @@ (ns doplarr.backends.sonarr.specs - (:require [clojure.spec.alpha :as spec])) + (:require [clojure.spec.alpha :as spec] + [doplarr.backends.specs :as bs])) + +(spec/def ::payload (spec/keys :req-un [::bs/title + ::bs/tvdb-id + ::bs/root-folder-path + ::bs/seasons + ::bs/monitored + ::bs/add-options + ::bs/quality-profile-id + ::bs/language-profile-id] + :opt-un [::bs/id])) diff --git a/src/doplarr/backends/specs.clj b/src/doplarr/backends/specs.clj index 82c997e..7aab6a1 100644 --- a/src/doplarr/backends/specs.clj +++ b/src/doplarr/backends/specs.clj @@ -1,14 +1,34 @@ (ns doplarr.backends.specs (:require [clojure.spec.alpha :as spec])) +; Generic (spec/def ::title string?) (spec/def ::year int?) -(spec/def ::id int?) -(spec/def ::tmdb-id int?) -(spec/def ::tvdb-id int?) +(spec/def ::id pos-int?) (spec/def ::root-folder-path string?) -(spec/def ::quality-profile-id (spec/and int? pos?)) +(spec/def ::quality-profile-id pos-int?) +(spec/def ::language-profile-id pos-int?) +(spec/def ::monitored boolean?) -(spec/def ::result (spec/keys - :req-un [::title ::year] - :opt-un [::id ::tvdb-id ::tmdb-id])) +; Movie +(spec/def ::tmdb-id pos-int?) + +; Series +(spec/def ::season-number int?) +(spec/def ::season (spec/keys :req-un [::season-number ::monitored])) +(spec/def ::seasons (spec/coll-of ::season)) +(spec/def ::tvdb-id pos-int?) +(spec/def ::ignore-episodes-with-files boolean?) +(spec/def ::search-for-missing-episodes boolean?) +(spec/def ::add-options (spec/keys :req-un [::ignore-episodes-with-files ::search-for-missing-episodes])) + +; Searching +(spec/def ::result (spec/keys :req-un [::title ::year + (or ::id ::tvdb-id ::tmdb-id)])) + +; Doplarr Internals +(spec/def ::status #{:unknown :pending :processing :partially-available :available}) +(spec/def ::name string?) +(spec/def ::option (spec/keys :req-un [::name ::id])) +(spec/def ::options (spec/coll-of ::option)) +(spec/def ::additional-options (spec/keys :req-un [::name ::options])) diff --git a/src/doplarr/config.clj b/src/doplarr/config.clj index 849d042..6660416 100644 --- a/src/doplarr/config.clj +++ b/src/doplarr/config.clj @@ -1,24 +1,15 @@ (ns doplarr.config (:require - [config.core :refer [env]])) + [config.core :refer [env]] + [doplarr.config.specs :as specs] + [clojure.spec.alpha :as spec])) -(def bot-requirements #{:bot-token}) +(defn validate-config [] + (spec/explain-data ::specs/config env)) -(def direct-requirements #{:sonarr-url - :sonarr-api - :radarr-url - :radarr-api}) - -(def overseerr-requirements #{:overseerr-url - :overseerr-api}) - -;; Default to overseerr if both are configured -(defn backend [] - (cond - (every? env overseerr-requirements) :overseerr - (every? env direct-requirements) :direct - :else nil)) - -(defn validate-env [] - (and (every? env bot-requirements) - (keyword? (backend)))) +(defn available-backends [] + (let [{:keys [sonarr-url radarr-url overseerr-url]} env] + (cond-> #{} + sonarr-url (conj :sonarr) + radarr-url (conj :radarr) + overseerr-url (conj :overseerr)))) diff --git a/src/doplarr/config/specs.clj b/src/doplarr/config/specs.clj new file mode 100644 index 0000000..53463a5 --- /dev/null +++ b/src/doplarr/config/specs.clj @@ -0,0 +1,31 @@ +(ns doplarr.config.specs + (:require [clojure.spec.alpha :as spec])) + +; Backend endpoints +(spec/def ::sonarr-url string?) +(spec/def ::radarr-url string?) +(spec/def ::overseerr-url string?) + +; Backend API keys +(spec/def ::sonarr-api string?) +(spec/def ::radarr-api string?) +(spec/def ::overseerr-api string?) + +; Discord bot token +(spec/def ::bot-token string?) + +; Optional settings +(spec/def ::partial-seasons boolean?) +(spec/def ::default-quality-profile string?) +(spec/def ::default-language-profile string?) +(spec/def ::role-id string?) + +; Complete Config +(spec/def ::config (spec/keys :req-un [(or (and ::sonarr-url ::sonarr-api) + (and ::radarr-url ::radarr-api) + (and ::overseerr-url ::overseerr-api)) + ::bot-token] + :opt-un [::partial-seasons + ::default-quality-profile + ::default-language-profile + ::role-id])) diff --git a/src/doplarr/core.clj b/src/doplarr/core.clj index aa2e86d..ecb0589 100644 --- a/src/doplarr/core.clj +++ b/src/doplarr/core.clj @@ -52,8 +52,8 @@ (defn -main [& _] - (when-not (config/validate-env) - (println "Error in configuration") + (when-let [config-error (config/validate-config)] + (ex-info "Error in configuration" {:spec-error config-error}) (System/exit -1)) (run) (shutdown-agents)) diff --git a/src/doplarr/discord.clj b/src/doplarr/discord.clj index 4902b66..8f8df5f 100644 --- a/src/doplarr/discord.clj +++ b/src/doplarr/discord.clj @@ -10,9 +10,9 @@ (def channel-timeout 600000) -(def request-command +(defn request-command [] {:name "request" - :description "Request a series or movie" + :description "Request media" :default_permission (boolean (not (:role-id env))) :options [{:type 1 @@ -150,10 +150,13 @@ (str "result-select:" uuid) (map-indexed select-menu-option results)))) -(defn select-profile [profiles uuid] - (dropdown "Which quality profile?" - (str "profile-select:" uuid) - (map #(hash-map :label (:name %) :value (:id %)) profiles))) +(defn option-dropdown [name options uuid] + (dropdown (str "Which " name "?") + (str "option-select:" uuid) + (map #(hash-map :label (:name %) :value (:id %)) options))) + +(defn dropdown-result [interaction] + (Integer/parseInt (s/select-one [:payload :values 0] interaction))) (defn selection-embed [selection & {:keys [season profile]}] {:title (:title selection) @@ -182,12 +185,3 @@ {:content "This has been requested!" :embeds [(selection-embed selection :season season :profile profile)]}) -(defn select-season [series uuid] - (dropdown "Which season?" - (str "season-select:" uuid) - (conj (map #(hash-map :label (str "Season: " %) :value %) - (range 1 (inc (:seasonCount series)))) - {:label "All Seasons" :value "-1"}))) - -(defn dropdown-index [interaction] - (Integer/parseInt (s/select-one [:payload :values 0] interaction))) diff --git a/src/doplarr/interaction_state_machine.clj b/src/doplarr/interaction_state_machine.clj index 5d6e9c4..efc4ceb 100644 --- a/src/doplarr/interaction_state_machine.clj +++ b/src/doplarr/interaction_state_machine.clj @@ -1,48 +1,10 @@ (ns doplarr.interaction-state-machine (:require - [doplarr.overseerr :as ovsr] [config.core :refer [env]] [doplarr.discord :as discord] [clojure.core.async :as a] [com.rpl.specter :as s] - [fmnoise.flow :as flow :refer [then else]] - [clojure.string :as str] - [doplarr.config :as config] - [doplarr.sonarr :as sonarr] - [doplarr.radarr :as radarr])) - -(def backend (delay (config/backend))) - -(def search-fn {:overseerr {:series #'ovsr/search-series - :movie #'ovsr/search-movie} - :direct {:series #'sonarr/search - :movie #'radarr/search}}) - -(def profiles-fn {:overseerr {:series #(a/go nil) - :movie #(a/go nil)} - :direct {:series #'sonarr/quality-profiles - :movie #'radarr/quality-profiles}}) - -(def process-selection-fn {:overseerr {:series #'ovsr/post-process-selection - :movie #'ovsr/post-process-selection} - :direct {:series #'sonarr/post-process-series - :movie (fn [movie] (a/go movie))}}) - -(def request-selection-fn {:overseerr #'ovsr/selection-to-request - :direct (fn [selection & _] selection)}) - -(def account-id-fn {:overseerr #(a/go ((a/> (a/ (case @backend :overseerr 1 :direct 2) (count (:seasons selection))) - partial-seasons? (if (nil? (:partial-seasons env)) - (case @backend :overseerr (a/> (a/> (select-keys profile ["id" "name"]) from-camel))