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