More generalizations

This commit is contained in:
Kiran Shila
2021-09-26 15:42:59 -07:00
parent 3fff7f624c
commit 6c8be304fd
16 changed files with 291 additions and 308 deletions

View File

@@ -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"}}

View File

@@ -0,0 +1,5 @@
(ns doplarr.backends.overseerr)
(defn search [])
(defn request [])

View File

@@ -0,0 +1 @@
(ns doplarr.backends.overseerr.specs)

View File

@@ -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/<! (impl/quality-profiles))
{:keys [default-quality-profile
partial-seasons]} env]
{: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)})))
(spec/fdef additional-options
:args (spec/cat :result ::bs/result))

View File

@@ -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/<!! (utils/request-and-process-body GET #(get (first %) "path") "/rootfolder"))))
(defn search [term]
(utils/request-and-process-body
GET
#(into [] (map utils/process-search-result %))
"/movie/lookup"
{:query-params {:term term}}))
(defn quality-profiles []
(utils/request-and-process-body
GET
#(into [] (map utils/process-quality-profile %))
#(into [] (map utils/process-profile %))
"/qualityProfile"))
(defn status [movie & _]
(defn status [result]
(cond
(and (:has-file movie)
(:is-available movie)
(:monitored movie)) :available
(and (not (:has-file movie))
(:is-available movie)
(:monitored movie)) :processing
(and (:has-file result)
(:is-available result)
(:monitored result)) :available
(and (not (:has-file result))
(:is-available result)
(:monitored result)) :processing
:else nil))
(spec/fdef status
:args (spec/cat :result ::bs/result)
:ret ::bs/status)
(defn request-payload [result quality-profile-id]
(merge result
{:quality-profile-id quality-profile-id
:monitored true
:rootFolderPath @rootfolder
:add-options {:search-for-movie true}}))
(-> 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/<!! (rootfolder))})

View File

@@ -0,0 +1,67 @@
(ns doplarr.backends.sonarr
(:require
[config.core :refer [env]]
[doplarr.utils :as utils]
[doplarr.backends.sonarr.impl :as impl]
[clojure.spec.alpha :as spec]
[doplarr.backends.sonarr.specs :as specs]
[doplarr.backends.specs :as bs]
[clojure.core.async :as a]))
(defn search [term]
(utils/request-and-process-body
impl/GET
#(mapv utils/process-search-result %)
"/series/lookup"
{:query-params {:term term}}))
(spec/fdef search
:args (spec/cat :term string?))
(defn request [payload]
(utils/request-and-process-body
(if (:id payload) impl/PUT impl/POST)
(constantly nil)
"/series"
{: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/<! (impl/quality-profiles))
language-profiles (a/<! (impl/language-profiles))
details (a/<! (impl/get-from-tvdb (:tvdb-id result)))
seasons (->> (: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))

View File

@@ -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/<! (GET "/series/lookup" {:query-params {:term term}}))
(then :body)
(else utils/fatal-error))))
(def rootfolder (delay (a/<!! (utils/request-and-process-body GET #(get (first %) "path") "/rootfolder"))))
(defn quality-profiles []
(a/go
(->> (a/<! (GET "/qualityprofile"))
(then #(->> (: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/<! (rootfolder))
:addOptions {:ignoreEpisodesWithFiles true
:searchForMissingEpisodes true}}))
(defn language-profiles []
(utils/request-and-process-body
GET
#(into [] (map utils/process-profile %))
"/languageProfile"))
(defn get-from-tvdb [tvdb-id]
(utils/request-and-process-body
GET
(comp utils/from-camel first)
"/series/lookup"
{:query-params {:term (str "tvdbId:" tvdb-id)}}))
(defn get-from-id [id]
(utils/request-and-process-body
GET
utils/from-camel
(str "/series/" id)))
(defn execute-command [command & {:as opts}]
(a/go
@@ -71,75 +61,7 @@
(->> (a/<! (execute-command "SeriesSearch" {:seriesId series-id})))
(then (constantly nil))))
(defn request-all [series profile-id]
(a/go
(let [id (:id series)
series (if id
(s/multi-transform
(s/multi-path
[:seasons
s/ALL
(comp pos? :seasonNumber)
:monitored
(s/terminal-val true)]
[:profileId
(s/terminal-val profile-id)])
series)
(merge series (a/<! (request-options profile-id))))]
(->> (a/<! ((if id PUT POST)
"/series"
{:form-params series
:content-type :json}))
(then (fn [_]
(when id
(search-series id))))))))
(defn request-season [series season profile-id]
(a/go
(let [id (:id series)
series (if id
(s/multi-transform
(s/multi-path
[:seasons
s/ALL
(comp (partial = season) :seasonNumber)
(s/multi-path
[:monitored
(s/terminal-val true)]
[:statistics
(s/terminal #(assoc % :episodeCount (:totalEpisodeCount %)))])]
[:profileId
(s/terminal-val profile-id)])
series)
(merge (s/setval [:seasons
s/ALL
(comp (partial not= season) :seasonNumber)
:monitored]
false series)
(a/<! (request-options profile-id))))]
(->> (a/<! ((if id PUT POST)
"/series"
{:form-params series
:content-type :json}))
(then (fn [_]
(when id
(search-season id season))))))))
(defn request [series & {:keys [season profile-id]}]
(if (= -1 season)
(request-all series profile-id)
(request-season series season profile-id)))
(defn post-process-series [series]
(a/go
(if-let [id (:id series)]
(->> (a/<! (GET (str "/series/" id)))
(then #(->> (: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)]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<! (ovsr/discord-users)) %))
:direct (fn [_] (a/go 1))}) ; Dummy id to get around account check
(def request-fn {:overseerr {:series #'ovsr/request
:movie #'ovsr/request}
:direct {:series #'sonarr/request
:movie #'radarr/request}})
(def content-status-fn {:overseerr {:series #'ovsr/season-status
:movie #'ovsr/movie-status}
:direct {:series #'sonarr/season-status
:movie #'radarr/movie-status}})
[clojure.string :as str]))
(defn start-interaction [interaction]
(let [uuid (str (java.util.UUID/randomUUID))
@@ -61,78 +23,6 @@
:last-modified (System/currentTimeMillis)})
(discord/update-interaction-response token (discord/search-response results uuid))))))
(defmulti process-event (fn [event _ _] event))
(defmethod process-event "result-select" [_ interaction uuid]
(a/go
(let [{:keys [results request-type token]} (get @discord/cache uuid)
selection-id (discord/dropdown-index interaction)
profiles (->> (a/<! (((profiles-fn @backend) request-type)))
(into []))
selection (a/<! (((process-selection-fn @backend) request-type) (nth results selection-id)))]
(case request-type
:series (let [one-season? (> (case @backend :overseerr 1 :direct 2) (count (:seasons selection)))
partial-seasons? (if (nil? (:partial-seasons env))
(case @backend :overseerr (a/<! (ovsr/partial-seasons?)) :direct true)
(:partial-seasons env))]
(if (and partial-seasons? (not one-season?))
(discord/update-interaction-response token (discord/select-season selection uuid))
(let [season (if one-season? 1 -1)]
(swap! discord/cache assoc-in [uuid :season] season)
(case @backend
:overseerr (discord/update-interaction-response token (discord/request selection uuid :season season))
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid))))))
:movie (case @backend
:overseerr (discord/update-interaction-response token (discord/request selection uuid))
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid))))
(swap! discord/cache assoc-in [uuid :profiles] profiles)
(swap! discord/cache assoc-in [uuid :selection] selection))))
(defmethod process-event "season-select" [_ interaction uuid]
(let [{:keys [token selection profiles]} (get @discord/cache uuid)
season (discord/dropdown-index interaction)]
(case @backend
:overseerr (discord/update-interaction-response token (discord/request selection uuid :season season))
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid)))
(swap! discord/cache assoc-in [uuid :season] season)))
(defmethod process-event "profile-select" [_ interaction uuid]
(let [{:keys [token profiles season selection]} (get @discord/cache uuid)
profile-id (discord/dropdown-index interaction)
profile (s/select-one [s/ALL (comp (partial = profile-id) :id) :name] profiles)]
(discord/update-interaction-response token (discord/request selection uuid :season season :profile profile))
(swap! discord/cache assoc-in [uuid :profile-id] profile-id)))
(defmethod process-event "request" [_ interaction uuid]
(a/go
(let [{:keys [token selection season profile request-type profile-id is4k]} (get @discord/cache uuid)
user-id (:user-id interaction)
backend-id (a/<! ((account-id-fn @backend) user-id))]
(if (nil? backend-id)
(discord/update-interaction-response token (discord/content-response "You do not have an associated account on Overseerr"))
(case (((content-status-fn @backend) request-type) selection :season season :is4k is4k)
:pending (discord/update-interaction-response token (discord/content-response "This has been requested, and the request is pending."))
:processing (discord/update-interaction-response token (discord/content-response "This is currently processing and should be available soon."))
:available (discord/update-interaction-response token (discord/content-response "This is already available!"))
(->> (a/<! (((request-fn @backend) request-type)
((request-selection-fn @backend) selection :season season :is4k (boolean is4k))
:season season
:ovsr-id backend-id
:profile-id profile-id))
(then (fn [_]
(discord/update-interaction-response token (discord/content-response "Requested!"))
(discord/followup-repsonse token (discord/request-alert selection :season season :profile profile))))
(else (fn [e]
(let [{:keys [status body] :as data} (ex-data e)
msg (second (re-matches #"\{\"message\":\"(.+)\"\}" body))] ; Not sure why this JSON didn't get parsed
(cond
(= status 403) (discord/update-interaction-response token (discord/content-response msg))
:else (throw (ex-info "Non 403 error on request" data))))))))))))
(defmethod process-event "request-4k" [_ interaction uuid]
(swap! discord/cache assoc-in [uuid :is4k] true)
(process-event "request" interaction uuid))
(defn continue-interaction [interaction]
(let [[event uuid] (str/split (s/select-one [:payload :component-id] interaction) #":")
now (System/currentTimeMillis)]

28
src/doplarr/system.clj Normal file
View File

@@ -0,0 +1,28 @@
(ns doplarr.system
(:require [integrant.core :as ig]
[doplarr.config :as config]))
(def backends [:radarr :sonarr :overseerr])
(def backend-fns [:search :request :additional-options])
(def media-backends {:movie [:overseerr :radarr]
:series [:overseerr :sonarr]
; :book [:readarr]
; :music [:lidarr]
})
(defn derive-backend! [backend]
(derive (keyword "backend" (name backend)) :doplarr/backend))
; Generate Parent-Child Relationships
(run! derive-backend! backends)
(def config
(into {} (for [b backends]
[(keyword "backend" (name b)) {:ns b}])))
(defmethod ig/init-key :doplarr/backend [_ {:keys [ns]}]
(zipmap backend-fns (for [f backend-fns
:let [ns (str "doplarr.backends." (name ns))
sym (symbol ns (name f))]]
(requiring-resolve sym))))

View File

@@ -45,7 +45,7 @@
(select-keys ["title" "year" "id" "tvdbId" "tmdbId"])
from-camel))
(defn process-quality-profile [profile]
(defn process-profile [profile]
(->> (select-keys profile ["id" "name"])
from-camel))