mirror of
https://github.com/kiranshila/Doplarr.git
synced 2026-07-22 05:21:40 -04:00
Merge pull request #17 from kiranshila/overseerr
Finalized Overseerr integration!
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
.github/
|
||||
logos/
|
||||
screenshots/
|
||||
.gitignore
|
||||
LICENSE
|
||||
README.md
|
||||
14
.github/workflows/main.yml
vendored
14
.github/workflows/main.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -6,3 +6,5 @@ pom.xml.asc
|
||||
.nrepl-port
|
||||
.cpcache/
|
||||
.lsp/
|
||||
.clj-kondo/
|
||||
target/
|
||||
|
||||
44
README.md
44
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
|
||||
<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
|
||||
|
||||
@@ -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}))
|
||||
|
||||
7
deps.edn
7
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}}}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
25
src/doplarr/config.clj
Normal 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))))
|
||||
@@ -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
193
src/doplarr/discord.clj
Normal 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)))
|
||||
144
src/doplarr/interaction_state_machine.clj
Normal file
144
src/doplarr/interaction_state_machine.clj
Normal 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
143
src/doplarr/overseerr.clj
Normal 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))))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
Reference in New Issue
Block a user