Merge pull request #17 from kiranshila/overseerr

Finalized Overseerr integration!
This commit is contained in:
Kiran Shila
2021-09-22 14:45:59 -05:00
committed by GitHub
15 changed files with 714 additions and 393 deletions

View File

@@ -1,6 +0,0 @@
.github/
logos/
screenshots/
.gitignore
LICENSE
README.md

View File

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

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ pom.xml.asc
.nrepl-port
.cpcache/
.lsp/
.clj-kondo/
target/

View File

@@ -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
<img src="https://raw.githubusercontent.com/kiranshila/Doplarr/main/screenshots/Selection.png" width="400">
<img src="https://raw.githubusercontent.com/kiranshila/Doplarr/main/screenshots/button.png" width="400">
### 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

View File

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

View File

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

View File

@@ -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 <me@kiranshila.com>"
LABEL "org.opencontainers.image.source"="https://github.com/kiranshila/Doplarr"

View File

@@ -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/<! (http-request
:get
(str base-url "/rootfolder")
key))
(then (comp :path first :body))
(else fatal-error))))
(defn quality-profile-data [profile]
{:name (:name profile)

25
src/doplarr/config.clj Normal file
View File

@@ -0,0 +1,25 @@
(ns doplarr.config
(:require
[config.core :refer [env]]))
(def bot-requirements #{:bot-token
:role-id})
(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))))

View File

@@ -1,284 +1,41 @@
(ns doplarr.core
(:require
[com.rpl.specter :as s]
[doplarr.sonarr :as sonarr]
[doplarr.radarr :as radarr]
[doplarr.config :as config]
[doplarr.interaction-state-machine :as ism]
[doplarr.discord :as discord]
[discljord.messaging :as m]
[discljord.connections :as c]
[discljord.events :as e]
[config.core :refer [env]]
[clojure.core.async :as a]
[clojure.core.cache.wrapped :as cache]
[clojure.string :as str])
[clojure.core.async :as a])
(:gen-class))
(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"
:description "Requests a series or movie"
:default_permission false
:options
[{:type 1
:name "series"
:description "Requests a series"
:options
[{:type 3
:name "term"
:description "Search term"
:required true}]}
{:type 1
:name "movie"
:description "Requests a movie",
:options
[{:type 3
:name "term"
: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!
(:messaging @state)
(:id @state)
guild-id
[request-command]))
(defn set-permission [guild-id command-id]
(m/edit-application-command-permissions!
(:messaging @state)
(:id @state)
guild-id
command-id
[{:id (:role-id env)
:type 1
:permission true}]))
(defn interaction-response [interaction-id interaction-token type & {:keys [ephemeral? content components embeds]}]
(m/create-interaction-response!
(:messaging @state)
interaction-id
interaction-token
type
:data
(cond-> {}
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/<!
(into [] (take @max-results)))]
; Results selection
(a/<! (update-interaction-response token (search-response results uuid)))
(when-some [selection-interaction (a/<! (await-interaction chan token))]
(let [selection-id (Integer/parseInt (s/select-one [:payload :values 0] selection-interaction))
profiles (->> ((profiles-fn request-type))
a/<!
(into []))]
; Profile selection
(a/<! (update-interaction-response token (select-profile profiles uuid)))
(when-some [profile-interaction (a/<! (await-interaction chan token))]
(let [selection (nth results selection-id)
profile-id (Integer/parseInt (s/select-one [:payload :values 0] profile-interaction))
profile (s/select-one [s/ALL (comp (partial = profile-id) :id) :name] profiles)
season-id (when (= request-type :series)
; Optional season selection for TV shows
(a/<! (update-interaction-response token (select-season selection uuid)))
(when-some [season-interaction (a/<! (await-interaction chan token))]
(Integer/parseInt (s/select-one [:payload :values 0] season-interaction))))]
; Verify request
(a/<! (update-interaction-response token (request selection uuid :season season-id :profile profile)))
; Wait for the button press, we don't care about the actual interaction
(a/<! (await-interaction chan token))
; Send public followup and actually perform request
(a/<! (followup-repsonse token (request-alert selection :season season-id :profile profile)))
((request-fn request-type)
selection
{:season season-id
:profile-id profile-id})
(update-interaction-response token {:content "Requested!"
:components []})))))))))
(defn continue-request [interaction]
(let [[_ uuid] (str/split (s/select-one [:payload :component-id] interaction) #":")]
(interaction-response (:id interaction) (:token interaction) 6)
(a/offer! (get @cache uuid) interaction)))
;;;;;;;;;;;;;;;;;;;;;;;; Gateway event handlers
(defmulti handle-event
(fn [event-type event-data]
(fn [event-type _]
event-type))
(defmethod handle-event :interaction-create
[_ data]
(let [interaction (interaction-data data)]
(let [interaction (discord/interaction-data data)]
(case (:type interaction)
:application-command (make-request interaction) ; These will all be requests as that is the only top level command
:message-component (continue-request interaction))))
:application-command (ism/start-interaction interaction)
:message-component (ism/continue-interaction interaction))))
(defmethod handle-event :ready
[event-type {{id :id} :user}]
(swap! state assoc :id id))
[_ {{id :id} :user}]
(swap! discord/state assoc :id id))
(defmethod handle-event :guild-create
[event-type {:keys [id]}]
[_ {:keys [id]}]
(let [guild-id id
[{command-id :id}] @(register-commands guild-id)]
(set-permission guild-id command-id)))
[{command-id :id}] @(discord/register-commands guild-id)]
(discord/set-permission guild-id command-id)))
(defmethod handle-event :default
[event-type event-data])
[_ _])
;; Bot startup and entry point
;;;;;;;;;;;;;;;;;;;;; Bot startup and entry point
(defn run []
(let [event-ch (a/chan 100)
connection-ch (c/connect-bot! (:bot-token env) event-ch :intents #{:guilds})
@@ -286,32 +43,16 @@
init-state {:connection connection-ch
:event event-ch
:messaging messaging-ch}]
(reset! state init-state)
(reset! discord/state init-state)
(try (e/message-pump! event-ch handle-event)
(finally
(m/stop-connection! messaging-ch)
(a/close! event-ch)))))
(defn check-config-entry [entry]
(when (nil? (entry env))
(throw (Exception. (str "Double check the configuration of" entry)))))
(defn validate-config []
(let [entries [:sonarr-url
:sonarr-api
:radarr-url
:radarr-api
:bot-token
:role-id]]
(doseq [entry entries]
(try
(check-config-entry entry)
(catch Exception e
(println e)
(System/exit -1))))))
(defn -main
[& _]
(validate-config)
(when-not (config/validate-env)
(println "Error in configuration")
(System/exit -1))
(run)
(shutdown-agents))

193
src/doplarr/discord.clj Normal file
View File

@@ -0,0 +1,193 @@
(ns doplarr.discord
(:require
[config.core :refer [env]]
[discljord.messaging :as m]
[clojure.core.cache.wrapped :as cache]
[com.rpl.specter :as s]))
(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)
(def request-command
{:name "request"
:description "Request a series or movie"
:default_permission false
:options
[{:type 1
:name "series"
:description "Request a series"
:options
[{:type 3
:name "term"
:description "Search term"
:required true}]}
{:type 1
:name "movie"
:description "Request a movie",
:options
[{:type 3
:name "term"
:description "Search term"
:required true}]}]})
(defn content-response [content]
{:content content
:components []})
(def interaction-types {1 :ping
2 :application-command
3 :message-component})
(def component-types {1 :action-row
2 :button
3 :select-menu})
(def max-results (delay (:max-results env 10)))
(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!
(:messaging @state)
(:id @state)
guild-id
[request-command]))
(defn set-permission [guild-id command-id]
(m/edit-application-command-permissions!
(:messaging @state)
(:id @state)
guild-id
command-id
[{:id (:role-id env)
:type 1
:permission true}]))
(defn interaction-response [interaction-id interaction-token type & {:keys [ephemeral? content components embeds]}]
(m/create-interaction-response!
(:messaging @state)
interaction-id
interaction-token
type
:data
(cond-> {}
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)))

View File

@@ -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/<! (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}})
(defn start-interaction [interaction]
(let [uuid (str (java.util.UUID/randomUUID))
id (:id interaction)
token (:token interaction)
payload-opts (:options (:payload interaction))
request-type (first (keys payload-opts))
query (s/select-one [request-type :term] payload-opts)]
(discord/interaction-response id token 5 :ephemeral? true)
(a/go
(let [results (->> (a/<! (((search-fn @backend) request-type) query))
(into [] (take @discord/max-results)))]
(swap! discord/cache assoc uuid {:results results
:request-type request-type
:token token
: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 (case @backend
:overseerr (if (a/<! (ovsr/partial-seasons?))
(discord/update-interaction-response token (discord/select-season selection uuid))
(do
(swap! discord/cache assoc-in [uuid :season] -1)
(discord/update-interaction-response token (discord/request selection uuid :season -1))))
:direct (discord/update-interaction-response token (discord/select-season selection 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)]
; Send the ack
(discord/interaction-response (:id interaction) (:token interaction) 6)
; Check last modified
(let [{:keys [token last-modified]} (get @discord/cache uuid)]
(if (> (- 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))))))

143
src/doplarr/overseerr.clj Normal file
View File

@@ -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/<! (GET (str "/settings/" (if (= media-type "tv") "sonarr" "radarr"))))
(then #(->> (:body %)
(map :is4k)
(some identity)))
(else utils/fatal-error))))
(defn search [term media-type]
(a/go
(->> (a/<! (GET (str "/search?query=" term)))
(then (fn [resp] (s/select-one [:body
:results
(s/filterer :mediaType (s/pred= media-type))
(s/transformed s/ALL #(assoc % :year (.getYear
(java.time.LocalDate/parse
(if (empty? (or (:firstAirDate %)
(:releaseDate %)))
"0000-01-01"
(or (:firstAirDate %)
(:releaseDate %)))))))]
resp)))
(else utils/fatal-error))))
(defn num-users []
(a/go
(->> (a/<! (GET "/user" {:query-params {:take 1}}))
(then #(s/select-one [:body :pageInfo :results] %))
(else utils/fatal-error))))
(defn all-users []
(a/go
(->> (a/<! (GET "/user" {:query-params {:take (a/<! (num-users))}}))
(then #(->> (s/select-one [:body :results] %)
(map :id)
(into [])))
(else utils/fatal-error))))
(defn discord-id [ovsr-id]
(a/go
(->> (a/<! (GET (str "/user/" ovsr-id)))
(then #(s/select-one [:body :settings :discordId] %))
(else utils/fatal-error))))
(defn discord-users []
(a/go-loop [ids (a/<! (all-users))
users {}]
(if (empty? ids)
users
(let [id (first ids)]
(recur (rest ids) (assoc users (a/<! (discord-id id)) id))))))
(defn search-movie [term]
(search term "movie"))
(defn search-series [term]
(search term "tv"))
(defn details
([selection] (details (:id selection) (:mediaType selection)))
([id media-type]
(a/go
(->> (a/<! (GET (str "/" media-type "/" id)))
(then :body)
(else utils/fatal-error)))))
(defn series-status [selection & {:keys [is4k]}]
(when-let [info (:mediaInfo selection)]
(status (dec ((if is4k :status4k :status) info)))))
(defn season-status [selection & {:keys [season is4k]}]
(when-let [ss (series-status selection :is4k is4k)]
(if (= ss :partially-available)
(when-let [seasons (seq (:seasons (:mediaInfo selection)))]
(status (dec ((if is4k :status4k :status) (nth seasons (dec season))))))
ss)))
(defn movie-status [selection & {:keys [is4k]}]
(when-let [info (:mediaInfo selection)]
(status (dec ((if is4k :status4k :status) info)))))
(defn selection-to-request [selection & {:keys [season is4k]}]
(cond-> {: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/<! (details selection))
fourK-backend? (a/<! (backend-4k? (:mediaType selection)))]
(selection-to-embedable (merge details selection {:backend-4k fourK-backend?})))))
(defn request [body & {:keys [ovsr-id]}]
(a/go
(->> (a/<! (POST "/request" {:form-params body
:content-type :json
:headers {"X-API-User" (str ovsr-id)}}))
(then (constantly nil)))))
(defn partial-seasons? []
(a/go
(->> (a/<! (GET "/settings/main"))
(then #(->> (:body %)
:partialRequestsEnabled))
(else utils/fatal-error))))

View File

@@ -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/<! (GET "/movie/lookup" {:query-params {:term term}}))
(then :body)
(else utils/fatal-error))))
(defn quality-profiles []
(let [chan (a/promise-chan)]
(a/pipeline
1
chan
(map (comp (partial map utils/quality-profile-data) :body))
(GET "/qualityProfile"))
chan))
(a/go
(->> (a/<! (GET "/qualityProfile"))
(then #(->> (: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/<! @rootfolder)
:addOptions {:searchForMovie true}})
:content-type :json}))
nil)
(->> (a/<! (POST
"/movie"
{:form-params (merge movie
{:qualityProfileId profile-id
:monitored true
:minimumAvailability "announced"
:rootFolderPath (a/<! (rootfolder))
:addOptions {:searchForMovie true}})
:content-type :json}))
(then (constantly nil)))))
(defn movie-status [movie & _]
(cond
(and (:hasFile movie)
(:isAvailable movie)
(:monitored movie)) :available
(and (not (:hasFile movie))
(:isAvailable movie)
(:monitored movie)) :processing
:else nil))

View File

@@ -3,11 +3,13 @@
[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 (:sonarr-url env) "/api")))
(def api-key (delay (:sonarr-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
@@ -30,39 +32,49 @@
@api-key
params))
(defn search [search-term]
(let [chan (a/promise-chan)]
(a/pipeline
1
chan
(map :body)
(GET "/series/lookup" {:query-params {:term search-term}}))
chan))
(defn search [term]
(a/go
(->> (a/<! (GET "/series/lookup" {:query-params {:term term}}))
(then :body)
(else utils/fatal-error))))
(defn quality-profiles []
(let [chan (a/promise-chan)]
(a/pipeline
1
chan
(map (comp (partial map utils/quality-profile-data) :body))
(GET "/profile"))
chan))
(a/go
(->> (a/<! (GET "/profile"))
(then #(->> (: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/<! @rootfolder)
:addOptions {:searchForMissingEpisodes true}}))
:rootFolderPath (a/<! (rootfolder))
:addOptions {:ignoreEpisodesWithFiles true
:searchForMissingEpisodes true}}))
(defn started-aquisition? [series]
(contains? series :path))
(defn execute-command [command & {:as opts}]
(a/go
(->> (a/<! (POST "/command" {:form-params (merge {:name command} opts)
:content-type :json}))
(then (constantly nil)))))
(defn search-season [series-id season]
(a/go
(->> (a/<! (execute-command "SeasonSearch" {:seriesId series-id
:seasonNumber season})))
(then (constantly nil))))
(defn search-series [series-id]
(a/go
(->> (a/<! (execute-command "SeriesSearch" {:seriesId series-id})))
(then (constantly nil))))
(defn request-all [series profile-id]
(a/go
(let [started? (started-aquisition? series)
series (if started?
(let [id (:id series)
series (if id
(s/multi-transform
(s/multi-path
[:seasons
@@ -74,23 +86,28 @@
(s/terminal-val profile-id)])
series)
(merge series (a/<! (request-options profile-id))))]
((if started? PUT POST)
"/series"
{:form-params series
:content-type :json})))
nil)
(->> (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 [started? (started-aquisition? series)
series (if started?
(let [id (:id series)
series (if id
(s/multi-transform
(s/multi-path
[:seasons
s/ALL
(comp (partial = season) :seasonNumber)
:monitored
(s/terminal-val true)]
(s/multi-path
[:monitored
(s/terminal-val true)]
[:statistics
(s/terminal #(assoc % :episodeCount (:totalEpisodeCount %)))])]
[:profileId
(s/terminal-val profile-id)])
series)
@@ -100,13 +117,34 @@
:monitored]
false series)
(a/<! (request-options profile-id))))]
((if started? PUT POST)
"/series"
{:form-params series
:content-type :json})))
nil)
(->> (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]}]
(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)))))