diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index a523df9..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,6 +0,0 @@
-.github/
-logos/
-screenshots/
-.gitignore
-LICENSE
-README.md
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index ed8e24a..9eae5d0 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -20,16 +20,18 @@ jobs:
uses: actions/setup-java@v2
with:
distribution: "adopt"
- java-version: "11"
+ java-version: "16"
- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@3.5
with:
- cli: latest
+ cli: 1.10.3.943
- - name: Build bot
- run: clojure -T:build uberjar
+ - name: Build bot and copy out jar for docker
shell: bash
+ run: |
+ clojure -T:build uber
+ cp target/doplarr.jar docker
- name: Release tagged version
uses: softprops/action-gh-release@v1
@@ -37,7 +39,7 @@ jobs:
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: |
- target/Doplarr.jar
+ target/doplarr.jar
config.edn
- name: Log in to the Container registry
@@ -56,7 +58,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
- context: .
+ context: docker
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.gitignore b/.gitignore
index 2ad4888..8284308 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ pom.xml.asc
.nrepl-port
.cpcache/
.lsp/
+.clj-kondo/
+target/
diff --git a/README.md b/README.md
index 3cf415a..d008015 100644
--- a/README.md
+++ b/README.md
@@ -21,12 +21,13 @@
### Caveats
I wanted a clean app for the sole purpose of requesting movies/TV shows.
-I personally didn't need Siri, Overseerr, or Ombi integration - so those are missing in this bot (for now).
+I personally didn't need Siri integration, support for old API versions, Ombi,
+etc., so those features are missing here.
+If you need Ombi support (for managing many people requesting), I suggest you check out Overseerr instead.
There is only a boolean permission (role gated) for who has access to the bot, nothing fancy.
-If any of these don't suit your fancy, check out [Requestrr](https://github.com/darkalfx/requestrr)
-
-Please consider this software "beta enough" as some of the error handling is pretty rough around the edges - however I've been using this for a bit now on my own server without any issues, but of course your mileage may vary.
+If any of these don't suit your fancy, check out
+[Requestrr](https://github.com/darkalfx/requestrr)
### Screenshots
@@ -34,11 +35,27 @@ Please consider this software "beta enough" as some of the error handling is pre
+### FAQ
+
+#### Will you support Lidarr/Readarr/\*arr
+
+Not yet. The idea is that one can work directly with the collection managers or
+work through a request manager (Overseerr). As Overseerr doesn't support
+collections managers other than radarr/sonarr and I want feature-parity, those
+other managers will be left out until Overseerr supports them.
+
+#### Why are the commands greyed out?
+
+Due to how slash command permissions work in Discord, every user that intends to
+use the bot must have the assigned role you created. That _includes_ the server
+owner/admins. Make sure that you assigned the role to yourself and the role ID
+you copied is correct.
+
## Setup
### Java
-If you are running without Docker, you need to have at least Java 11 installed, such as [adoptopenjdk](https://adoptopenjdk.net/)
+If you are running without Docker, you need to have at least Java 11 installed, such as [adoptium](https://adoptium.net/)
### Discord
@@ -56,10 +73,26 @@ To do this:
1. Enable Developer Mode (User Settings -> Advanced -> Developer Mode)
2. Under your server settings, go to Roles, find the role and "Copy ID"
+Every user that you wish to have access to the slash commands needs to be
+assigned this role (even the server owner/admins).
+
### Sonarr/Radarr
1. Copy out your API keys from Settings -> General
+### Overseerr
+
+Sonarr/Radarr and Overseerr are mutually exclusive - you only need to configure
+one. If you are using Overseerr, your users must have associated discord IDs, or
+the request will fail.
+
+This bot isn't meant to wrap the entirety of what Overseerr can do, just the
+necessary bits for requesting with optional 4K and quota support. Just use the
+web interface to Overseerr if you need more features.
+
+In the config, you replace `SONARR_URL`, `SONARR_API`, `RADARR_URL`,
+`RADARR_API` with `OVERSEERR_URL` and `OVERSEERR_API`.
+
## Running with Docker
Simply run with
@@ -73,7 +106,6 @@ docker run \
-e BOT_TOKEN='bot_token' \
-e ROLE_ID='role_id' \
--name doplarr ghcr.io/kiranshila/doplarr:latest
-
```
## Building and Running Locally
diff --git a/build/build.clj b/build/build.clj
index 270198e..139e1ea 100644
--- a/build/build.clj
+++ b/build/build.clj
@@ -1,17 +1,8 @@
(ns build
(:require
- [clojure.tools.build.api :as b]
- [hf.depstar :as depstar]))
+ [org.corfield.build :as bb]))
-(defn uberjar [params]
- (b/delete {:path "target"})
- (-> (merge {:jar "Doplarr.jar"
- :aot true
- :sync-pom true
- :target-dir "target"
- :main-class "doplarr.core"}
- params)
- (depstar/aot)
- (depstar/pom)
- (assoc :jar-type :uber)
- (depstar/build)))
+(defn uber [_]
+ (bb/clean nil)
+ (bb/uber {:uber-file "target/doplarr.jar"
+ :main 'doplarr.core}))
diff --git a/deps.edn b/deps.edn
index bab0665..175a3de 100644
--- a/deps.edn
+++ b/deps.edn
@@ -5,14 +5,15 @@
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.suskalo/discljord {:mvn/version "1.3.0-SNAPSHOT"}
org.clojure/core.async {:mvn/version "1.3.618"}
cheshire/cheshire {:mvn/version "5.10.1"}
+ fmnoise/flow {:mvn/version "4.1.0"}
hato/hato {:mvn/version "0.8.2"}}
:jvm-opts ["-Dconfig=config.edn"]
:aliases {:build {:extra-paths ["build"]
- :deps {io.github.clojure/tools.build {:git/tag "v0.1.6" :git/sha "5636e61"}
- com.github.seancorfield/depstar {:mvn/version "2.1.278"}}
+ :deps {io.github.seancorfield/build-clj
+ {:git/tag "v0.3.1" :git/sha "996ddfa"}}
:ns-default build}}}
diff --git a/Dockerfile b/docker/Dockerfile
similarity index 75%
rename from Dockerfile
rename to docker/Dockerfile
index 70dfa09..2e4e308 100644
--- a/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,4 @@
-FROM adoptopenjdk/openjdk11:alpine-jre
+FROM adoptopenjdk/openjdk16:alpine-jre
WORKDIR /app
@@ -10,7 +10,7 @@ RUN \
COPY . /app
ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["java","-jar","/app/target/Doplarr.jar"]
+CMD ["java","-jar","/app/doplarr.jar"]
LABEL "maintainer"="Kiran Shila "
LABEL "org.opencontainers.image.source"="https://github.com/kiranshila/Doplarr"
diff --git a/src/doplarr/arr_utils.clj b/src/doplarr/arr_utils.clj
index bf4ca74..a5e03fa 100644
--- a/src/doplarr/arr_utils.clj
+++ b/src/doplarr/arr_utils.clj
@@ -2,35 +2,43 @@
(:require
[clojure.tools.logging :as log]
[clojure.core.async :as a]
+ [fmnoise.flow :as flow :refer [then else]]
[hato.client :as hc]))
+(defn fatal-error [ex]
+ (log/fatal ex)
+ #_(System/exit -1))
+
+(defn deep-merge [a & maps]
+ (if (map? a)
+ (apply merge-with deep-merge a maps)
+ (apply merge-with deep-merge maps)))
+
(defn http-request [method url key & [params]]
- (let [chan (a/chan)]
+ (let [chan (a/chan)
+ put-close #(do
+ (a/put! chan %)
+ (a/close! chan))]
(hc/request
- (merge
+ (deep-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 %)))
+ put-close
+ put-close)
chan))
(defn rootfolder [base-url key]
- (let [chan (a/promise-chan)]
- (a/pipeline
- 1
- chan
- (map (comp :path first :body))
- (http-request
- :get
- (str base-url "/rootfolder")
- key))
- chan))
+ (a/go
+ (->> (a/ {}
- ephemeral? (assoc :flags 64)
- content (assoc :content content)
- components (assoc :components components)
- embeds (assoc :embeds embeds))))
-
-(defn followup-repsonse [interaction-token & {:keys [ephermeral? content components embeds]}]
- (m/create-followup-message!
- (:messaging @state)
- (:id @state)
- interaction-token
- (cond-> {}
- ephermeral? (assoc :flags 64)
- content (assoc :content content)
- components (assoc :components components)
- embeds (assoc :embeds embeds))))
-
-(defn update-interaction-response [interaction-token & {:keys [content components embeds]}]
- (m/edit-original-interaction-response!
- (:messaging @state)
- (:id @state)
- interaction-token
- :content content
- :components components
- :embeds embeds))
-
-(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))])
-
-(defn interaction-data [interaction]
- {:id (:id interaction)
- :type (interaction-types (:type interaction))
- :token (:token interaction)
- :payload
- {:component-type (component-types (get-in interaction [:data :component-type]))
- :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?]
- {:type 2
- :style 1
- :disabled (not enabled?)
- :custom_id (str "request:" uuid)
- :label "Request"})
-
-(defn select-menu-option [index result]
- {:label (:title result)
- :description (:year result)
- :value index})
-
-(defn dropdown [content id options]
- {:content content
- :components [{:type 1
- :components [{:type 3
- :custom_id id
- :options options}]}]})
-
-(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 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 (s/select-one [request-type :term] search)
- chan (a/chan)]
- ; Send the in-progress response
- (interaction-response id token 5 :ephemeral? true)
- ; 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/ {}
+ ephemeral? (assoc :flags 64)
+ content (assoc :content content)
+ components (assoc :components components)
+ embeds (assoc :embeds embeds))))
+
+(defn followup-repsonse [interaction-token & {:keys [ephermeral? content components embeds]}]
+ (m/create-followup-message!
+ (:messaging @state)
+ (:id @state)
+ interaction-token
+ (cond-> {}
+ ephermeral? (assoc :flags 64)
+ content (assoc :content content)
+ components (assoc :components components)
+ embeds (assoc :embeds embeds))))
+
+(defn update-interaction-response [interaction-token & {:keys [content components embeds]}]
+ (m/edit-original-interaction-response!
+ (:messaging @state)
+ (:id @state)
+ interaction-token
+ :content content
+ :components components
+ :embeds embeds))
+
+(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))])
+
+(defn interaction-data [interaction]
+ {:id (:id interaction)
+ :type (interaction-types (:type interaction))
+ :token (:token interaction)
+ :user-id (s/select-one [:member :user :id] interaction)
+ :payload
+ {:component-type (component-types (get-in interaction [:data :component-type]))
+ :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?]
+ {:type 2
+ :style 1
+ :disabled (not enabled?)
+ :custom_id (str "request:" uuid)
+ :label "Request"})
+
+(defn request-4k-button [uuid enabled?]
+ {:type 2
+ :style 1
+ :disabled (not enabled?)
+ :custom_id (str "request-4k:" uuid)
+ :label "Request 4K"})
+
+(defn select-menu-option [index result]
+ {:label (or (:title result) (:name result))
+ :description (:year result)
+ :value index})
+
+(defn dropdown [content id options]
+ {:content content
+ :components [{:type 1
+ :components [{:type 3
+ :custom_id id
+ :options options}]}]})
+
+(defn search-response [results uuid]
+ (if (empty? results)
+ {:content "Search result returned no hits"}
+ (dropdown "Choose one of the following results"
+ (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 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
+ [(when profile
+ {: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 (filterv identity [(request-button uuid true)
+ (when (:backend-4k selection)
+ (request-4k-button uuid true))])}]})
+
+(defn request-alert [selection & {:keys [season profile]}]
+ {: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
new file mode 100644
index 0000000..a77c69a
--- /dev/null
+++ b/src/doplarr/interaction_state_machine.clj
@@ -0,0 +1,144 @@
+(ns doplarr.interaction-state-machine
+ (:require
+ [doplarr.overseerr :as ovsr]
+ [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/> (a/> (a/ (- now last-modified) discord/channel-timeout)
+ ; Update interaction with timeout message
+ (discord/update-interaction-response token (discord/content-response "Request timed out, please try again."))
+ ; Move through the state machine to update cache side effecting new components
+ (do
+ (swap! discord/cache assoc-in [uuid :last-modified] now)
+ (process-event event interaction uuid))))))
diff --git a/src/doplarr/overseerr.clj b/src/doplarr/overseerr.clj
new file mode 100644
index 0000000..83307ac
--- /dev/null
+++ b/src/doplarr/overseerr.clj
@@ -0,0 +1,143 @@
+(ns doplarr.overseerr
+ (:require
+ [com.rpl.specter :as s]
+ [clojure.core.async :as a]
+ [config.core :refer [env]]
+ [fmnoise.flow :as flow :refer [then else]]
+ [doplarr.arr-utils :as utils]))
+
+(def base-url (delay (str (:overseerr-url env) "/api/v1")))
+(def api-key (delay (:overseerr-api env)))
+
+(def poster-path "https://image.tmdb.org/t/p/w500")
+
+(def status [:unknown :pending :processing :partially-available :available])
+
+(defn GET [endpoint & [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))
+
+(defn backend-4k? [media-type]
+ (a/go
+ (->> (a/> (:body %)
+ (map :is4k)
+ (some identity)))
+ (else utils/fatal-error))))
+
+(defn search [term media-type]
+ (a/go
+ (->> (a/> (a/> (a/> (s/select-one [:body :results] %)
+ (map :id)
+ (into [])))
+ (else utils/fatal-error))))
+
+(defn discord-id [ovsr-id]
+ (a/go
+ (->> (a/> (a/ {:mediaType (:mediaType selection)
+ :mediaId (:id selection)
+ :is4k is4k}
+ (= "tv" (:mediaType selection)) (assoc :seasons (if (= -1 season)
+ (into [] (range 1 (:seasonCount selection)))
+ [season]))))
+
+(defn selection-to-embedable [selection]
+ (as-> selection s
+ (assoc s :seasonCount (:numberOfSeasons s))
+ (assoc s :description (:overview s))
+ (assoc s :remotePoster (str poster-path (:posterPath s)))))
+
+(defn post-process-selection [selection]
+ (a/go
+ (let [details (a/> (a/> (a/> (:body %)
+ :partialRequestsEnabled))
+ (else utils/fatal-error))))
diff --git a/src/doplarr/radarr.clj b/src/doplarr/radarr.clj
index c1140da..836e42a 100644
--- a/src/doplarr/radarr.clj
+++ b/src/doplarr/radarr.clj
@@ -2,11 +2,13 @@
(:require
[config.core :refer [env]]
[doplarr.arr-utils :as utils]
+ [fmnoise.flow :as flow :refer [then else]]
[clojure.core.async :as a]))
(def base-url (delay (str (:radarr-url env) "/api/v3")))
(def api-key (delay (:radarr-api env)))
-(def rootfolder (delay (utils/rootfolder @base-url @api-key)))
+
+(defn rootfolder [] (utils/rootfolder @base-url @api-key))
(defn GET [endpoint & [params]]
(utils/http-request
@@ -22,33 +24,38 @@
@api-key
params))
-(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 search [term]
+ (a/go
+ (->> (a/> (a/> (:body %)
+ (map utils/quality-profile-data)))
+ (else utils/fatal-error))))
(defn request [movie & {:keys [profile-id]}]
(a/go
- (POST
- "/movie"
- {:form-params (merge movie
- {:qualityProfileId profile-id
- :monitored true
- :minimumAvailability "announced"
- :rootFolderPath (a/> (a/> (a/> (a/> (:body %)
+ (map utils/quality-profile-data)))
+ (else utils/fatal-error))))
(defn request-options [profile-id]
(a/go
{:profileId profile-id
:monitored true
:seasonFolder true
- :rootFolderPath (a/> (a/> (a/> (a/> (a/> (a/> (a/> (:body %)
+ (merge series)))
+ (else utils/fatal-error))
+ series)))
+
+(defn season-status [series & {:keys [season]}]
+ (let [ssn (->> (:seasons series)
+ (filter (comp (partial = season) :seasonNumber))
+ first)]
+ (when-let [stats (:statistics ssn)]
+ (when (:monitored ssn)
+ (cond
+ (> 100.0 (:percentOfEpisodes stats)) :processing
+ :else :available)))))