mirror of
https://github.com/kiranshila/Doplarr.git
synced 2026-04-05 08:53:59 -04:00
63
README.md
63
README.md
@@ -10,27 +10,22 @@
|
||||
<img alt="Discord" src="https://img.shields.io/discord/890634173751119882?color=ff69b4&label=discord&style=for-the-badge">
|
||||
</p>
|
||||
|
||||
> A _Better_ Sonarr/Radarr Request Bot for Discord
|
||||
> An \*arr Request Bot for Discord
|
||||
|
||||
## Why does this exist
|
||||
|
||||
- Uses modern Discord slash commands and components, which provides a clean, performant UI on desktop and mobile
|
||||
- This has the added benifit of not requiring privileged intents, so this bot will _never_ look at message content
|
||||
- Simple codebase, <1k lines of code which makes it easier to maintain. [Code is not an asset](https://robinbb.com/blog/code-is-not-an-asset/)
|
||||
- Uses modern Discord slash commands and components, which provides a clean, performant UI on desktop and mobile.
|
||||
This has the added benefit of not requiring privileged intents, so this bot will _never_ look at message content
|
||||
- Small codebase as [code is not an asset](https://robinbb.com/blog/code-is-not-an-asset/)
|
||||
- Simple configuration, no need to have a whole web frontend just for configuration
|
||||
- Powered by Clojure and [Discljord](https://github.com/IGJoshua/discljord), a markedly good combination 😛
|
||||
|
||||
### Caveats
|
||||
|
||||
I wanted a clean app for the sole purpose of requesting movies/TV shows.
|
||||
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)
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src="https://raw.githubusercontent.com/kiranshila/Doplarr/main/screenshots/Request.png" width="400">
|
||||
@@ -41,10 +36,7 @@ If any of these don't suit your fancy, check out
|
||||
|
||||
#### 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.
|
||||
Soon™
|
||||
|
||||
#### Why are the commands greyed out?
|
||||
|
||||
@@ -101,8 +93,8 @@ 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`.
|
||||
In the config, you replace `SONARR__URL`, `SONARR__API`, `RADARR__URL`,
|
||||
`RADARR__API` with `OVERSEERR__URL` and `OVERSEERR__API`.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
@@ -110,11 +102,11 @@ Simply run with
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e SONARR_URL='http://localhost:8989' \
|
||||
-e RADARR_URL='http://localhost:7878' \
|
||||
-e SONARR_API='sonarr_api' \
|
||||
-e RADARR_API='radarr_api' \
|
||||
-e BOT_TOKEN='bot_token' \
|
||||
-e SONARR__URL='http://localhost:8989' \
|
||||
-e RADARR__URL='http://localhost:7878' \
|
||||
-e SONARR__API='sonarr_api' \
|
||||
-e RADARR__API='radarr_api' \
|
||||
-e DISCORD__TOKEN='bot_token' \
|
||||
--name doplarr ghcr.io/kiranshila/doplarr:latest
|
||||
```
|
||||
|
||||
@@ -122,14 +114,14 @@ Alternatively, use docker-compose:
|
||||
|
||||
```yaml
|
||||
doplarr:
|
||||
environment:
|
||||
- ‘SONARR_URL=http://localhost:8989’
|
||||
- ‘RADARR_URL=http://localhost:7878’
|
||||
- SONARR_API=sonarr_api
|
||||
- RADARR_API=radarr_api
|
||||
- BOT_TOKEN=bot_token
|
||||
container_name: doplarr
|
||||
image: ‘ghcr.io/kiranshila/doplarr:latest’
|
||||
environment:
|
||||
- SONARR__URL='http://localhost:8989’
|
||||
- RADARR__URL='http://localhost:7878’
|
||||
- SONARR__API=sonarr_api
|
||||
- RADARR__API=radarr_api
|
||||
- DISCORD__TOKEN=bot_token
|
||||
container_name: doplarr
|
||||
image: ‘ghcr.io/kiranshila/doplarr:latest’
|
||||
```
|
||||
|
||||
## Building and Running Locally
|
||||
@@ -147,11 +139,16 @@ To skip the build, just download `Doplarr.jar` and `config.edn` from the release
|
||||
|
||||
### Optional Settings
|
||||
|
||||
| Environment Variable (Docker) | Config File Keyword | Type | Description |
|
||||
| ----------------------------- | ------------------- | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `MAX_RESULTS` | `:max-results` | Integer | Sets the maximum size of the search results selection |
|
||||
| `ROLE_ID` | `:role-id` | String | The discord role id for users of the bot (omitting this lets everyone on the server use the bot) |
|
||||
| `PARTIAL_SEASONS` | `:partial-seasons` | Boolean | Sets whether users can request partial seasons. Defaults to true or setting in Overseer |
|
||||
| Environment Variable (Docker) | Config File Keyword | Type | Default Value | Description |
|
||||
| ----------------------------- | -------------------------- | ------- | ------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD__MAX_RESULTS` | `:discord/max-results` | Integer | `25` | Sets the maximum size of the search results selection |
|
||||
| `DISCORD__ROLE_ID` | `:discord/role-id` | String | N/A | The discord role id for users of the bot (omitting this lets everyone on the server use the bot) |
|
||||
| `SONARR__QUALITY_PROFILE` | `:sonarr/quality-profile` | String | N/A | The name of the quality profile to use by default for Sonarr |
|
||||
| `RADARR__QUALITY_PROFILE` | `:radarr/quality-profile` | String | N/A | The name of the quality profile to use by default for Radarr |
|
||||
| `SONARR__LANGUAGE_PROFILE` | `:sonarr/language-profile` | String | N/A | The name of the language profile to use by default for Radarr |
|
||||
| `OVERSEERR__DEFAULT_ID` | `:overseerr/default-id` | Integer | N/A | The Overseerr user id to use by default if there is no associated discord account for the requester |
|
||||
| `PARTIAL_SEASONS` | `:partial-seasons` | Boolean | `true` | Sets whether users can request partial seasons. |
|
||||
| `LOG_LEVEL` | `:log-level` | Keyword | `:info` | The log level for the logging backend. This can be changed for debugging purposes. |
|
||||
|
||||
### Setting up on Windows
|
||||
|
||||
|
||||
23
config.edn
23
config.edn
@@ -1,6 +1,17 @@
|
||||
{:sonarr-url "http://localhost:8989"
|
||||
:sonarr-api "sonarr_api_kei"
|
||||
:radarr-url "http://localhost:7878"
|
||||
:radarr-api "radarr_api_kei"
|
||||
:bot-token "bot_token"
|
||||
:role-id "role_id"}
|
||||
{:sonarr/url ""
|
||||
:sonarr/api ""
|
||||
:radarr/url ""
|
||||
:radarr/api ""
|
||||
; :overseerr/url ""
|
||||
; :overseerr/api ""
|
||||
:discord/token ""
|
||||
; -- Optional Settings
|
||||
; :partial-seasons false
|
||||
; :sonarr/quality-profile ""
|
||||
; :radarr/quality-profile ""
|
||||
; :sonarrr/language-profile ""
|
||||
; :discord/role-id ""
|
||||
; :discord/max-results 10
|
||||
; :overseerr/default-id 1
|
||||
; :log-level :trace
|
||||
}
|
||||
|
||||
19
deps.edn
19
deps.edn
@@ -1,19 +1,22 @@
|
||||
{:paths ["src" "resources"]
|
||||
|
||||
:deps {org.clojure/clojure {:mvn/version "1.11.0-alpha1"}
|
||||
org.clojure/core.cache {:mvn/version "1.0.217"}
|
||||
yogthos/config {:mvn/version "1.1.8"}
|
||||
:deps {org.clojure/clojure {:mvn/version "1.11.0-alpha3"}
|
||||
io.aviso/pretty {:mvn/version "1.1.1"}
|
||||
org.clojure/core.cache {:mvn/version "1.0.225"}
|
||||
yogthos/config {:mvn/version "1.1.9"}
|
||||
com.rpl/specter {:mvn/version "1.1.3"}
|
||||
ch.qos.logback/logback-classic {:mvn/version "1.2.5"}
|
||||
org.suskalo/discljord {:mvn/version "1.3.0-SNAPSHOT"}
|
||||
org.clojure/core.async {:mvn/version "1.3.618"}
|
||||
org.suskalo/discljord {:git/url "https://github.com/kiranshila/discljord"
|
||||
:git/sha "b6e29d4b8f3e77462016a6b60af01357e1415345"}
|
||||
org.clojure/core.async {:mvn/version "1.5.648"}
|
||||
cheshire/cheshire {:mvn/version "5.10.1"}
|
||||
fmnoise/flow {:mvn/version "4.1.0"}
|
||||
hato/hato {:mvn/version "0.8.2"}}
|
||||
hato/hato {:mvn/version "0.8.2"}
|
||||
com.taoensso/timbre {:mvn/version "5.1.2"}
|
||||
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"}}
|
||||
|
||||
:jvm-opts ["-Dconfig=config.edn"]
|
||||
|
||||
:aliases {:build {:extra-paths ["build"]
|
||||
:deps {io.github.seancorfield/build-clj
|
||||
{:git/tag "v0.3.1" :git/sha "996ddfa"}}
|
||||
{:git/tag "v0.6.5" :git/sha "972031a"}}
|
||||
:ns-default build}}}
|
||||
|
||||
58
doplarr.xml
58
doplarr.xml
@@ -1,58 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>doplarr</Name>
|
||||
<Repository>ghcr.io/kiranshila/doplarr:latest</Repository>
|
||||
<Registry>
|
||||
https://github.com/kiranshila/doplarr/pkgs/container/doplarr</Registry>
|
||||
<Network>bridge</Network>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://discord.gg/YSga2wxr</Support>
|
||||
<Project>https://github.com/kiranshila/Doplarr/</Project>
|
||||
<Overview>A Better Sonarr/Radarr Request Bot for
|
||||
Discord</Overview>
|
||||
<Shell>bash</Shell>
|
||||
<WebUI />
|
||||
<TemplateURL>
|
||||
https://github.com/kiranshila/Doplarr/raw/main/doplarr.xml</TemplateURL>
|
||||
<Icon>
|
||||
https://github.com/kiranshila/Doplarr/raw/main/logos/logo_cropped.png</Icon>
|
||||
<ExtraParams />
|
||||
<PostArgs />
|
||||
<DonateText />
|
||||
<DonateLink />
|
||||
<Config Name="Discord Bot Token" Target="BOT_TOKEN" Default=""
|
||||
Mode="" Description="Discord bot API token" Type="Variable"
|
||||
Display="always" Required="true" Mask="false" />
|
||||
<Config Name="Radarr URL" Target="RADARR_URL" Default=""
|
||||
Mode="" Description="URL of Radarr instance" Type="Variable"
|
||||
Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Radarr API Key" Target="RADARR_API" Default=""
|
||||
Mode="" Description="API key of Radarr instance" Type="Variable"
|
||||
Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Sonarr URL" Target="SONARR_URL" Default="" Mode=""
|
||||
Description="URL of Sonarr Instance" Type="Variable"
|
||||
Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Sonarr API Key" Target="SONARR_API" Default=""
|
||||
Mode="" Description="API key of Sonarr Instance" Type="Variable"
|
||||
Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Overseerr URL" Target="OVERSEERR_URL" Default=""
|
||||
Mode="" Description="URL of Overseerr instance" Type="Variable"
|
||||
Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Overseerr API Key" Target="OVERSEERR_API"
|
||||
Default="" Mode="" Description="API key of overseerr instance"
|
||||
Type="Variable" Display="always" Required="false" Mask="false" />
|
||||
<Config Name="Discord Role ID" Target="ROLE_ID" Default=""
|
||||
Mode="" Description="Optional role id for restricting bot access"
|
||||
Type="Variable" Display="always-hide" Required="false"
|
||||
Mask="false" />
|
||||
<Config Name="Maximum search results" Target="MAX_RESULTS"
|
||||
Default="10" Mode=""
|
||||
Description="Maximum number of results to show in the search results dropdown"
|
||||
Type="Variable" Display="always-hide" Required="false"
|
||||
Mask="false" />
|
||||
<Config Name="Allow partial seasons?" Target="PARTIAL_SEASONS"
|
||||
Default="true" Mode=""
|
||||
Description="If set to false, force requests of entire series"
|
||||
Type="Variable" Display="always-hide" Required="false"
|
||||
Mask="false" />
|
||||
</Container>
|
||||
@@ -1,12 +0,0 @@
|
||||
<configuration>
|
||||
<appender name="CONSOLE"
|
||||
class="ch.qos.logback.core.ConsoleAppender">
|
||||
<layout class="ch.qos.logback.classic.PatternLayout">
|
||||
<Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} -
|
||||
%msg%n</Pattern>
|
||||
</layout>
|
||||
</appender>
|
||||
<root level="error">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -1,45 +0,0 @@
|
||||
(ns doplarr.arr-utils
|
||||
(: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)
|
||||
put-close #(do
|
||||
(a/put! chan %)
|
||||
(a/close! chan))]
|
||||
(hc/request
|
||||
(deep-merge
|
||||
{:method method
|
||||
:url url
|
||||
:as :json
|
||||
:async? true
|
||||
:headers {"X-API-Key" key}}
|
||||
params)
|
||||
put-close
|
||||
put-close)
|
||||
chan))
|
||||
|
||||
(defn rootfolder [base-url key]
|
||||
(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)
|
||||
:id (:id profile)})
|
||||
64
src/doplarr/backends/overseerr.clj
Normal file
64
src/doplarr/backends/overseerr.clj
Normal file
@@ -0,0 +1,64 @@
|
||||
(ns doplarr.backends.overseerr
|
||||
(:require
|
||||
[doplarr.backends.overseerr.impl :as impl]
|
||||
[doplarr.utils :as utils]
|
||||
[config.core :refer [env]]
|
||||
[clojure.core.async :as a]))
|
||||
|
||||
(defn search [term media-type]
|
||||
(let [type (impl/media-type media-type)]
|
||||
(utils/request-and-process-body
|
||||
impl/GET
|
||||
(partial impl/process-search-result type)
|
||||
(str "/search?query=" term))))
|
||||
|
||||
; In Overseerr, the only additional option we'll need is which season,
|
||||
; if the request type is a series
|
||||
(defn additional-options [result media-type]
|
||||
(a/go
|
||||
(let [details (a/<! (impl/details (:id result) media-type))
|
||||
{:keys [partial-seasons]} env]
|
||||
(when (= media-type :series)
|
||||
(let [seasons (impl/seasons-list details)
|
||||
backend-partial-seasons? (a/<! (impl/partial-seasons?))]
|
||||
{:season (cond
|
||||
(= 1 (count seasons)) (:id (first seasons))
|
||||
(false? partial-seasons) -1
|
||||
(false? backend-partial-seasons?) -1
|
||||
:else (impl/seasons-list details))
|
||||
:season-count (count seasons)})))))
|
||||
|
||||
(defn request-embed [{:keys [title id season]} media-type]
|
||||
(a/go
|
||||
(let [fourk (a/<! (impl/backend-4k? media-type))
|
||||
details (a/<! (impl/details id media-type))]
|
||||
{:title title
|
||||
:overview (:overview details)
|
||||
:poster (str impl/poster-path (:poster-path details))
|
||||
:media-type media-type
|
||||
:request-formats (cond-> [""] fourk (conj "4K"))
|
||||
:season season})))
|
||||
|
||||
(defn request [payload media-type]
|
||||
(a/go
|
||||
(let [{:keys [format id season season-count discord-id]} payload
|
||||
{:overseerr/keys [default-id]} env
|
||||
details (a/<! (impl/details id media-type))
|
||||
ovsr-id ((a/<! (impl/discord-users)) discord-id)
|
||||
status (impl/media-status details media-type
|
||||
:is-4k? (= format :4K)
|
||||
:season season)
|
||||
body (cond-> {:mediaType (impl/media-type media-type)
|
||||
:mediaId id
|
||||
:is4k (= format :4K)}
|
||||
(= :series media-type)
|
||||
(assoc :seasons
|
||||
(if (= -1 season)
|
||||
(into [] (range 1 (inc season-count)))
|
||||
[season])))]
|
||||
(cond
|
||||
(contains? #{:unauthorized :pending :processing :available} status) status
|
||||
(and (nil? ovsr-id) (nil? default-id)) :unauthorized
|
||||
:else (a/<! (impl/POST "/request" {:form-params body
|
||||
:content-type :json
|
||||
:headers {"X-API-User" (str (or ovsr-id default-id))}}))))))
|
||||
116
src/doplarr/backends/overseerr/impl.clj
Normal file
116
src/doplarr/backends/overseerr/impl.clj
Normal file
@@ -0,0 +1,116 @@
|
||||
(ns doplarr.backends.overseerr.impl
|
||||
(:require
|
||||
[taoensso.timbre :refer [fatal]]
|
||||
[com.rpl.specter :as s]
|
||||
[clojure.core.async :as a]
|
||||
[config.core :refer [env]]
|
||||
[fmnoise.flow :as flow :refer [then else]]
|
||||
[doplarr.utils :as utils]
|
||||
[clojure.set :as set]))
|
||||
|
||||
(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 parse-year [result]
|
||||
(.getYear (java.time.LocalDate/parse
|
||||
(if (empty? (or (:first-air-date result)
|
||||
(:release-date result)))
|
||||
"0000-01-01"
|
||||
(or (:first-air-date result)
|
||||
(:release-date result))))))
|
||||
|
||||
(defn process-search-result [media-type-str body]
|
||||
(->> (utils/from-camel body)
|
||||
(s/select [:results
|
||||
s/ALL
|
||||
(s/selected? :media-type (s/pred= media-type-str))
|
||||
(s/view #(assoc % :year (parse-year %)))
|
||||
(s/submap [:title :id :year :name])])
|
||||
(map #(set/rename-keys % {:name :title}))))
|
||||
|
||||
(defn media-type [kw]
|
||||
(if (= :series kw)
|
||||
"tv"
|
||||
(name kw)))
|
||||
|
||||
(defn details [id media]
|
||||
(a/go
|
||||
(->> (a/<! (GET (str "/" (media-type media) "/" id)))
|
||||
(then (comp :body utils/from-camel))
|
||||
(else #(fatal % "Error requesting details on selection from Overseerr")))))
|
||||
|
||||
(defn seasons-list [details]
|
||||
(conj
|
||||
(for [season (:seasons details)
|
||||
:let [ssn (:season-number season)]
|
||||
:when (> ssn 0)]
|
||||
{:name (str ssn)
|
||||
:id ssn})
|
||||
{:name "All Seasons" :id -1}))
|
||||
|
||||
(defn backend-4k? [media]
|
||||
(a/go
|
||||
(->> (a/<! (GET (str "/settings/" (if (= (media-type media) "tv") "sonarr" "radarr"))))
|
||||
(then #(->> (utils/from-camel (:body %))
|
||||
(map :is-4k)
|
||||
(some identity)))
|
||||
(else #(fatal % "Exception on checking Overseeerr 4K backend support")))))
|
||||
|
||||
(defn partial-seasons? []
|
||||
(a/go
|
||||
(->> (a/<! (GET "/settings/main"))
|
||||
(then #(->> (utils/from-camel (:body %))
|
||||
:partial-requests-enabled))
|
||||
(else #(fatal % "Exception testing for partial seasons")))))
|
||||
|
||||
(defn media-status [details media-type & {:keys [is-4k? season]}]
|
||||
(when-let [info (:media-info details)]
|
||||
(let [primary-status (status (dec ((if is-4k? :status-4k :status) info)))]
|
||||
(case media-type
|
||||
:movie primary-status
|
||||
:series (if (or (= -1 season)
|
||||
(not= primary-status :partially-available))
|
||||
primary-status
|
||||
(when-let [seasons (seq (:seasons info))]
|
||||
(status (dec ((if is-4k? :status-4k :status) (nth seasons (dec season)))))))))))
|
||||
|
||||
(defn num-users []
|
||||
(a/go
|
||||
(->> (a/<! (GET "/user" {:query-params {:take 1}}))
|
||||
(then #(->> (utils/from-camel %)
|
||||
(s/select-one [:body :page-info :results])))
|
||||
(else #(fatal % "Exception on querying Overseerr users")))))
|
||||
|
||||
(defn all-users []
|
||||
(a/go
|
||||
(->> (a/<! (GET "/user" {:query-params {:take (a/<! (num-users))}}))
|
||||
(then #(->> (utils/from-camel %)
|
||||
(s/select-one [:body :results])
|
||||
(map :id)
|
||||
(into [])))
|
||||
(else #(fatal % "Exception on querying Overseerr users")))))
|
||||
|
||||
(defn discord-id [ovsr-id]
|
||||
(a/go
|
||||
(->> (a/<! (GET (str "/user/" ovsr-id)))
|
||||
(then #(->> (utils/from-camel %)
|
||||
(s/select-one [:body :settings :discord-id])))
|
||||
(else #(fatal % "Exception on querying Overseerr discord id")))))
|
||||
|
||||
(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))))))
|
||||
48
src/doplarr/backends/radarr.clj
Normal file
48
src/doplarr/backends/radarr.clj
Normal file
@@ -0,0 +1,48 @@
|
||||
(ns doplarr.backends.radarr
|
||||
(:require
|
||||
[taoensso.timbre :refer [warn]]
|
||||
[config.core :refer [env]]
|
||||
[fmnoise.flow :refer [then]]
|
||||
[doplarr.utils :as utils]
|
||||
[doplarr.backends.radarr.impl :as impl]
|
||||
[clojure.core.async :as a]))
|
||||
|
||||
(defn search [term _]
|
||||
(utils/request-and-process-body
|
||||
impl/GET
|
||||
#(map utils/process-search-result %)
|
||||
"/movie/lookup"
|
||||
{:query-params {:term term}}))
|
||||
|
||||
(defn additional-options [_ _]
|
||||
(a/go
|
||||
(let [quality-profiles (a/<! (impl/quality-profiles))
|
||||
{:keys [radarr/quality-profile]} env
|
||||
default-profile-id (utils/profile-name-id quality-profiles quality-profile)]
|
||||
(when (and quality-profile (nil? default-profile-id))
|
||||
(warn "Default quality profile in config doesn't exist in backend, check spelling"))
|
||||
{:quality-profile-id
|
||||
(cond
|
||||
default-profile-id default-profile-id
|
||||
(= 1 (count quality-profiles)) (:id (first quality-profiles))
|
||||
:else quality-profiles)})))
|
||||
|
||||
(defn request-embed [{:keys [title quality-profile-id tmdb-id]} _]
|
||||
(a/go
|
||||
(let [quality-profiles (a/<! (impl/quality-profiles))
|
||||
details (a/<! (impl/get-from-tmdb tmdb-id))]
|
||||
{:title title
|
||||
:overview (:overview details)
|
||||
:poster (:remote-poster details)
|
||||
:media-type :movie
|
||||
:request-formats [""]
|
||||
:quality-profile (utils/profile-id-name quality-profiles quality-profile-id)})))
|
||||
|
||||
(defn request [payload _]
|
||||
(a/go
|
||||
(let [status (impl/status (a/<! (impl/get-from-tmdb (:tmdb-id payload))))]
|
||||
(if status
|
||||
status
|
||||
(->> (a/<! (impl/POST "/movie" {:form-params (utils/to-camel (impl/request-payload payload))
|
||||
:content-type :json}))
|
||||
(then (constantly nil)))))))
|
||||
46
src/doplarr/backends/radarr/impl.clj
Normal file
46
src/doplarr/backends/radarr/impl.clj
Normal file
@@ -0,0 +1,46 @@
|
||||
(ns doplarr.backends.radarr.impl
|
||||
(:require
|
||||
[config.core :refer [env]]
|
||||
[doplarr.utils :as utils]
|
||||
[clojure.core.async :as a]))
|
||||
|
||||
(def base-url (delay (str (:radarr/url env) "/api/v3")))
|
||||
(def api-key (delay (:radarr/api env)))
|
||||
|
||||
(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))
|
||||
|
||||
(def rootfolder (delay (a/<!! (utils/request-and-process-body GET #(get (first %) "path") "/rootfolder"))))
|
||||
|
||||
(defn quality-profiles []
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
#(map utils/process-profile %)
|
||||
"/qualityProfile"))
|
||||
|
||||
(defn get-from-tmdb [tmdb-id]
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
(comp utils/from-camel first)
|
||||
"/movie/lookup"
|
||||
{:query-params {:term (str "tmdbId:" tmdb-id)}}))
|
||||
|
||||
(defn status [details]
|
||||
(cond
|
||||
(and (:has-file details)
|
||||
(:is-available details)
|
||||
(:monitored details)) :available
|
||||
(and (not (:has-file details))
|
||||
(:is-available details)
|
||||
(:monitored details)) :processing
|
||||
:else nil))
|
||||
|
||||
(defn request-payload [payload]
|
||||
(-> payload
|
||||
(select-keys [:title :tmdb-id :quality-profile-id])
|
||||
(assoc :monitored true
|
||||
:root-folder-path @rootfolder
|
||||
:add-options {:search-for-movie true})))
|
||||
76
src/doplarr/backends/sonarr.clj
Normal file
76
src/doplarr/backends/sonarr.clj
Normal file
@@ -0,0 +1,76 @@
|
||||
(ns doplarr.backends.sonarr
|
||||
(:require
|
||||
[taoensso.timbre :refer [warn]]
|
||||
[config.core :refer [env]]
|
||||
[doplarr.utils :as utils]
|
||||
[fmnoise.flow :refer [then]]
|
||||
[doplarr.backends.sonarr.impl :as impl]
|
||||
[clojure.core.async :as a]))
|
||||
|
||||
(defn search [term _]
|
||||
(utils/request-and-process-body
|
||||
impl/GET
|
||||
#(mapv utils/process-search-result %)
|
||||
"/series/lookup"
|
||||
{:query-params {:term term}}))
|
||||
|
||||
(defn additional-options [result _]
|
||||
(a/go
|
||||
(let [quality-profiles (a/<! (impl/quality-profiles))
|
||||
language-profiles (a/<! (impl/language-profiles))
|
||||
details (a/<! (impl/get-from-tvdb (:tvdb-id result)))
|
||||
seasons (->> (:seasons details)
|
||||
(filter #(pos? (:season-number %)))
|
||||
(map #(let [ssn (:season-number %)]
|
||||
(hash-map :id ssn :name (str ssn)))))
|
||||
{:keys [sonarr/language-profile
|
||||
sonarr/quality-profile
|
||||
partial-seasons]} env
|
||||
default-profile-id (utils/profile-name-id quality-profiles quality-profile)
|
||||
default-language-id (utils/profile-name-id language-profiles language-profile)]
|
||||
(when (and quality-profile (nil? default-profile-id))
|
||||
(warn "Default quality profile in config doesn't exist in backend, check spelling"))
|
||||
(when (and language-profile (nil? default-language-id))
|
||||
(warn "Default language profile in config doesn't exist in backend, check spelling"))
|
||||
{:season (cond
|
||||
(= 1 (count seasons)) (:id (first seasons))
|
||||
(false? partial-seasons) -1
|
||||
:else (conj seasons {:name "All Seasons" :id -1}))
|
||||
:quality-profile-id (cond
|
||||
quality-profile default-profile-id
|
||||
(= 1 (count quality-profiles)) (:id (first quality-profiles))
|
||||
:else quality-profiles)
|
||||
:language-profile-id (cond
|
||||
language-profile default-language-id
|
||||
(= 1 (count language-profiles)) (:id (first language-profiles))
|
||||
:else language-profiles)})))
|
||||
|
||||
(defn request-embed [{:keys [title quality-profile-id language-profile-id tvdb-id season]} _]
|
||||
(a/go
|
||||
(let [quality-profiles (a/<! (impl/quality-profiles))
|
||||
language-profiles (a/<! (impl/language-profiles))
|
||||
details (a/<! (impl/get-from-tvdb tvdb-id))]
|
||||
{:title title
|
||||
:overview (:overview details)
|
||||
:poster (:remote-poster details)
|
||||
:media-type :series
|
||||
:season season
|
||||
:request-formats [""]
|
||||
:quality-profile (:name (first (filter #(= quality-profile-id (:id %)) quality-profiles)))
|
||||
:language-profile (:name (first (filter #(= language-profile-id (:id %)) language-profiles)))})))
|
||||
|
||||
(defn request [payload _]
|
||||
(a/go (let [details (a/<! (if-let [id (:id payload)]
|
||||
(impl/get-from-id id)
|
||||
(impl/get-from-tvdb (:tvdb-id payload))))
|
||||
status (impl/status details (:season payload))
|
||||
request-payload (impl/request-payload payload details)]
|
||||
(if status
|
||||
status
|
||||
(->> (a/<! ((if (:id payload) impl/PUT impl/POST) "/series" {:form-params (utils/to-camel request-payload)
|
||||
:content-type :json}))
|
||||
(then (fn [_]
|
||||
(when-let [id (:id payload)]
|
||||
(if (= -1 (:season payload))
|
||||
(impl/search-series id)
|
||||
(impl/search-season id (:season payload)))))))))))
|
||||
109
src/doplarr/backends/sonarr/impl.clj
Normal file
109
src/doplarr/backends/sonarr/impl.clj
Normal file
@@ -0,0 +1,109 @@
|
||||
(ns doplarr.backends.sonarr.impl
|
||||
(:require
|
||||
[clojure.core.async :as a]
|
||||
[config.core :refer [env]]
|
||||
[fmnoise.flow :as flow :refer [then]]
|
||||
[doplarr.utils :as utils]))
|
||||
|
||||
(def base-url (delay (str (:sonarr/url env) "/api/v3")))
|
||||
(def api-key (delay (:sonarr/api env)))
|
||||
|
||||
(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 PUT [endpoint & [params]]
|
||||
(utils/http-request :put (str @base-url endpoint) @api-key params))
|
||||
|
||||
(def rootfolder (delay (a/<!! (utils/request-and-process-body GET #(get (first %) "path") "/rootfolder"))))
|
||||
|
||||
(defn quality-profiles []
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
#(map utils/process-profile %)
|
||||
"/qualityProfile"))
|
||||
|
||||
(defn language-profiles []
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
#(map utils/process-profile %)
|
||||
"/languageProfile"))
|
||||
|
||||
(defn get-from-tvdb [tvdb-id]
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
(comp utils/from-camel first)
|
||||
"/series/lookup"
|
||||
{:query-params {:term (str "tvdbId:" tvdb-id)}}))
|
||||
|
||||
(defn get-from-id [id]
|
||||
(utils/request-and-process-body
|
||||
GET
|
||||
utils/from-camel
|
||||
(str "/series/" id)))
|
||||
|
||||
(defn execute-command [command & {:as opts}]
|
||||
(a/go
|
||||
(->> (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 status [details season]
|
||||
(if (= -1 season)
|
||||
nil ; FIXME All season status
|
||||
(let [ssn (->> (:seasons details)
|
||||
(filter (comp (partial = season) :season-number))
|
||||
first)]
|
||||
(when-let [stats (:statistics ssn)]
|
||||
(when (:monitored ssn)
|
||||
(cond
|
||||
(> 100.0 (:percent-of-episodes stats)) :processing
|
||||
:else :available))))))
|
||||
|
||||
(defn generate-seasons [request-seasons total-seasons]
|
||||
(into []
|
||||
(conj
|
||||
(for [season (range 1 (inc total-seasons))]
|
||||
{:season-number season
|
||||
:monitored (contains? request-seasons season)})
|
||||
{:season-number 0 :monitored false})))
|
||||
|
||||
(defn generate-request-seasons
|
||||
[details season]
|
||||
(if (= -1 season)
|
||||
(into #{} (range 1 (inc (count (:seasons details)))))
|
||||
(if (:id details)
|
||||
(conj (->> (:seasons details)
|
||||
(keep #(when (:monitored %) (:season-number %)))
|
||||
(into #{}))
|
||||
season)
|
||||
#{season})))
|
||||
|
||||
(defn request-payload [payload details]
|
||||
(let [seasons (-> (generate-request-seasons details (:season payload))
|
||||
(generate-seasons (count (:seasons details))))]
|
||||
(if (:id payload)
|
||||
(assoc details
|
||||
:seasons seasons
|
||||
:quality-profile-id (:quality-profile-id payload))
|
||||
(-> payload
|
||||
(assoc :monitored true
|
||||
:seasons seasons
|
||||
:root-folder-path @rootfolder
|
||||
:add-options {:ignore-episodes-with-files true
|
||||
:search-for-missing-episodes true})
|
||||
(dissoc :season
|
||||
:format)))))
|
||||
@@ -1,24 +1,48 @@
|
||||
(ns doplarr.config
|
||||
(:require
|
||||
[config.core :refer [env]]))
|
||||
[config.core :refer [env]]
|
||||
[taoensso.timbre :refer [info fatal]]
|
||||
[doplarr.config.specs :as specs]
|
||||
[clojure.spec.alpha :as spec]
|
||||
[clojure.set :as set]))
|
||||
|
||||
(def bot-requirements #{:bot-token})
|
||||
(defn validate-config []
|
||||
(if (spec/valid? ::specs/config env)
|
||||
(info "Configuration is valid")
|
||||
(do (fatal "Error in configuration"
|
||||
:info
|
||||
(->> (spec/explain-data ::specs/config env)
|
||||
::spec/problems
|
||||
(into [])))
|
||||
(System/exit -1))))
|
||||
|
||||
(def direct-requirements #{:sonarr-url
|
||||
:sonarr-api
|
||||
:radarr-url
|
||||
:radarr-api})
|
||||
(def backend-media {:radarr [:movie]
|
||||
:sonarr [:series]
|
||||
:overseerr [:movie :series]
|
||||
:readarr [:book]
|
||||
:lidarr [:music]})
|
||||
|
||||
(def overseerr-requirements #{:overseerr-url
|
||||
:overseerr-api})
|
||||
(def media-backend
|
||||
(-> (for [[key types] backend-media]
|
||||
(for [type types]
|
||||
[key type]))
|
||||
(->> (mapcat identity)
|
||||
(group-by second))
|
||||
(update-vals (partial map first))))
|
||||
|
||||
;; Default to overseerr if both are configured
|
||||
(defn backend []
|
||||
(cond
|
||||
(every? env overseerr-requirements) :overseerr
|
||||
(every? env direct-requirements) :direct
|
||||
:else nil))
|
||||
(defn available-backends []
|
||||
(cond-> #{}
|
||||
(:radarr/url env) (conj :radarr)
|
||||
(:sonarr/url env) (conj :sonarr)
|
||||
(:overseerr/url env) (conj :overseerr)
|
||||
(:readarr/url env) (conj :readarr)
|
||||
(:lidarr/url env) (conj :lidarr)))
|
||||
|
||||
(defn validate-env []
|
||||
(and (every? env bot-requirements)
|
||||
(keyword? (backend))))
|
||||
(defn available-media []
|
||||
(into #{} (flatten (map backend-media (available-backends)))))
|
||||
|
||||
(defn available-backed-for-media [media]
|
||||
(first
|
||||
(set/intersection
|
||||
(available-backends)
|
||||
(into #{} (media-backend media)))))
|
||||
|
||||
64
src/doplarr/config/specs.clj
Normal file
64
src/doplarr/config/specs.clj
Normal file
@@ -0,0 +1,64 @@
|
||||
(ns doplarr.config.specs
|
||||
(:require [clojure.spec.alpha :as spec]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(spec/def ::valid-url (spec/and string?
|
||||
(complement #(str/ends-with? % "/"))))
|
||||
|
||||
; Backend endpoints
|
||||
(spec/def :sonarr/url ::valid-url)
|
||||
(spec/def :radarr/url ::valid-url)
|
||||
(spec/def :overseerr/url ::valid-url)
|
||||
|
||||
; Backend API keys
|
||||
(spec/def :sonarr/api string?)
|
||||
(spec/def :radarr/api string?)
|
||||
(spec/def :overseerr/api string?)
|
||||
|
||||
; Discord bot token
|
||||
(spec/def :discord/token string?)
|
||||
|
||||
; --- Optional settings
|
||||
(spec/def ::log-level keyword?)
|
||||
(spec/def :discord/role-id string?)
|
||||
(spec/def :discord/max-results #(and (pos-int? %)
|
||||
(<= % 25)))
|
||||
|
||||
; Radarr optionals
|
||||
(spec/def :radarr/quality-profile string?)
|
||||
(spec/def :sonarr/quality-profile string?)
|
||||
|
||||
; Sonarr optionals
|
||||
(spec/def :sonarr/language-profile string?)
|
||||
|
||||
; Overseerr optionals
|
||||
(spec/def :overseerr/default-id pos-int?)
|
||||
|
||||
(spec/def ::partial-seasons boolean?)
|
||||
|
||||
(defn when-req [pred spec]
|
||||
(spec/nonconforming
|
||||
(spec/or :passed (spec/and pred spec)
|
||||
:failed (complement (partial spec/valid? pred)))))
|
||||
|
||||
(defmacro matched-keys
|
||||
[& ks]
|
||||
`(when-req #(some (partial contains? %) ~(vec ks)) (spec/keys :req ~(vec ks))))
|
||||
|
||||
; Complete configuration
|
||||
(spec/def ::config (spec/and
|
||||
(spec/keys :req [:discord/token]
|
||||
:opt [::log-level
|
||||
:discord/role-id
|
||||
:discord/max-results
|
||||
:radarr/quality-profile
|
||||
:sonarr/quality-profile
|
||||
:sonarr/language-profile
|
||||
:overseerr/default-id
|
||||
::partial-seasons])
|
||||
#(some (partial contains? %) [:sonarr/url
|
||||
:radarr/url
|
||||
:overseerr/url])
|
||||
(matched-keys :sonarr/url :sonarr/api)
|
||||
(matched-keys :radarr/url :radarr/api)
|
||||
(matched-keys :overseerr/url :overseerr/api)))
|
||||
@@ -1,59 +1,77 @@
|
||||
(ns doplarr.core
|
||||
(:require
|
||||
[doplarr.config :as config]
|
||||
[doplarr.interaction-state-machine :as ism]
|
||||
[doplarr.discord :as discord]
|
||||
[discljord.messaging :as m]
|
||||
[taoensso.timbre :refer [info fatal debug] :as timbre]
|
||||
[taoensso.timbre.tools.logging :as tlog]
|
||||
[discljord.connections :as c]
|
||||
[discljord.messaging :as m]
|
||||
[discljord.events :as e]
|
||||
[config.core :refer [env]]
|
||||
[doplarr.config :as config]
|
||||
[doplarr.state :as state]
|
||||
[doplarr.interaction-state-machine :as ism]
|
||||
[doplarr.discord :as discord]
|
||||
[clojure.core.async :as a])
|
||||
(:gen-class))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;; Gateway event handlers
|
||||
(defmulti handle-event
|
||||
; Pipe tools.logging to timbre
|
||||
(tlog/use-timbre)
|
||||
|
||||
(timbre/merge-config! {:min-level [[#{"*"} (:log-level env :info)]]})
|
||||
|
||||
; Multimethod for handling incoming Discord events
|
||||
(defmulti handle-event!
|
||||
(fn [event-type _]
|
||||
event-type))
|
||||
|
||||
(defmethod handle-event :interaction-create
|
||||
; A new interaction was received (slash command or component)
|
||||
(defmethod handle-event! :interaction-create
|
||||
[_ data]
|
||||
(debug "Received interaction")
|
||||
(let [interaction (discord/interaction-data data)]
|
||||
(case (:type interaction)
|
||||
:application-command (ism/start-interaction interaction)
|
||||
:message-component (ism/continue-interaction interaction))))
|
||||
; Slash commands start our request sequence
|
||||
:application-command (ism/start-interaction! interaction)
|
||||
; Message components continue the request until they are complete or failed
|
||||
:message-component (ism/continue-interaction! interaction))))
|
||||
|
||||
(defmethod handle-event :ready
|
||||
; Once we receive a ready event, grab our bot-id
|
||||
(defmethod handle-event! :ready
|
||||
[_ {{id :id} :user}]
|
||||
(swap! discord/state assoc :id id))
|
||||
(info "Discord connection successful")
|
||||
(swap! state/discord assoc :bot-id id))
|
||||
|
||||
(defmethod handle-event :guild-create
|
||||
(defmethod handle-event! :guild-create
|
||||
[_ {:keys [id]}]
|
||||
(let [guild-id id
|
||||
[{command-id :id}] @(discord/register-commands guild-id)]
|
||||
(info "Connected to guild")
|
||||
(let [media-types (config/available-media)
|
||||
messaging (:messaging @state/discord)
|
||||
bot-id (:bot-id @state/discord)
|
||||
[{command-id :id}] (discord/register-commands media-types bot-id messaging id)]
|
||||
(when (:role-id env)
|
||||
(discord/set-permission guild-id command-id))))
|
||||
(discord/set-permission bot-id messaging id command-id))))
|
||||
|
||||
(defmethod handle-event :default
|
||||
[_ _])
|
||||
(defmethod handle-event! :default
|
||||
[event-type data]
|
||||
(debug "Got unhandled event" event-type data))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;; Bot startup and entry point
|
||||
(defn run []
|
||||
(defn start-bot! []
|
||||
(let [event-ch (a/chan 100)
|
||||
connection-ch (c/connect-bot! (:bot-token env) event-ch :intents #{:guilds})
|
||||
messaging-ch (m/start-connection! (:bot-token env))
|
||||
token (:discord/token env)
|
||||
connection-ch (c/connect-bot! token event-ch :intents #{:guilds})
|
||||
messaging-ch (m/start-connection! token)
|
||||
init-state {:connection connection-ch
|
||||
:event event-ch
|
||||
:messaging messaging-ch}]
|
||||
(reset! discord/state init-state)
|
||||
(try (e/message-pump! event-ch handle-event)
|
||||
(reset! state/discord init-state)
|
||||
(try (e/message-pump! event-ch handle-event!)
|
||||
(catch Exception e (fatal e "Exception thrown from event handler"))
|
||||
(finally
|
||||
(m/stop-connection! messaging-ch)
|
||||
(a/close! event-ch)))))
|
||||
|
||||
; Program Entry Point
|
||||
(defn -main
|
||||
[& _]
|
||||
(when-not (config/validate-env)
|
||||
(println "Error in configuration")
|
||||
(System/exit -1))
|
||||
(run)
|
||||
(config/validate-config)
|
||||
(start-bot!)
|
||||
(shutdown-agents))
|
||||
|
||||
@@ -1,39 +1,32 @@
|
||||
(ns doplarr.discord
|
||||
(:require
|
||||
[config.core :refer [env]]
|
||||
[com.rpl.specter :as s]
|
||||
[clojure.string :as str]
|
||||
[taoensso.timbre :refer [fatal]]
|
||||
[doplarr.utils :as utils]
|
||||
[fmnoise.flow :as flow :refer [else]]
|
||||
[discljord.messaging :as m]
|
||||
[clojure.core.cache.wrapped :as cache]
|
||||
[com.rpl.specter :as s]))
|
||||
[clojure.set :as set]))
|
||||
|
||||
(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
|
||||
(defn request-command [media-types]
|
||||
{:name "request"
|
||||
:description "Request a series or movie"
|
||||
:description "Request media"
|
||||
:default_permission (boolean (not (:role-id env)))
|
||||
: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}]}]})
|
||||
(into [] (for [media media-types]
|
||||
{:type 1
|
||||
:name (name media)
|
||||
:description (str "Request " (name media))
|
||||
:options [{:type 3
|
||||
:name "query"
|
||||
:description "Query"
|
||||
:required true}]}))})
|
||||
|
||||
(defn content-response [content]
|
||||
{:content content
|
||||
:flags 64
|
||||
:embeds []
|
||||
:components []})
|
||||
|
||||
(def interaction-types {1 :ping
|
||||
@@ -44,63 +37,13 @@
|
||||
2 :button
|
||||
3 :select-menu})
|
||||
|
||||
(def max-results (delay (:max-results env 10)))
|
||||
(def MAX-OPTIONS 25)
|
||||
(def MAX-CHARACTERS 100)
|
||||
|
||||
(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))])
|
||||
@@ -110,6 +53,7 @@
|
||||
:type (interaction-types (:type interaction))
|
||||
:token (:token interaction)
|
||||
:user-id (s/select-one [:member :user :id] interaction)
|
||||
:channel-id (:channel-id interaction)
|
||||
:payload
|
||||
{:component-type (component-types (get-in interaction [:data :component-type]))
|
||||
:component-id (s/select-one [:data :custom-id] interaction)
|
||||
@@ -117,27 +61,28 @@
|
||||
: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?]
|
||||
(defn request-button [format uuid]
|
||||
{:type 2
|
||||
:style 1
|
||||
:disabled (not enabled?)
|
||||
:custom_id (str "request:" uuid)
|
||||
:label "Request"})
|
||||
:disabled false
|
||||
:custom_id (str "request:" uuid ":" format)
|
||||
:label (str/trim (str "Request " format))})
|
||||
|
||||
(defn request-4k-button [uuid enabled?]
|
||||
(defn page-button [uuid option page label]
|
||||
{:type 2
|
||||
:style 1
|
||||
:disabled (not enabled?)
|
||||
:custom_id (str "request-4k:" uuid)
|
||||
:label "Request 4K"})
|
||||
:custom_id (str "option-page:" uuid ":" option "-" page)
|
||||
:disabled false
|
||||
:label label})
|
||||
|
||||
(defn select-menu-option [index result]
|
||||
{:label (or (:title result) (:name result))
|
||||
{:label (apply str (take MAX-CHARACTERS (or (:title result) (:name result))))
|
||||
:description (:year result)
|
||||
:value index})
|
||||
|
||||
(defn dropdown [content id options]
|
||||
{:content content
|
||||
:flags 64
|
||||
:components [{:type 1
|
||||
:components [{:type 3
|
||||
:custom_id id
|
||||
@@ -145,49 +90,63 @@
|
||||
|
||||
(defn search-response [results uuid]
|
||||
(if (empty? results)
|
||||
{:content "Search result returned no hits"}
|
||||
{:content "Search result returned no hits"
|
||||
:flags 64}
|
||||
(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 option-dropdown [option options uuid page]
|
||||
(let [all-options (map #(set/rename-keys % {:name :label :id :value}) options)
|
||||
chunked (partition-all MAX-OPTIONS all-options)
|
||||
ddown (dropdown (str "Which " (utils/canonical-option-name option) "?")
|
||||
(str "option-select:" uuid ":" (name option))
|
||||
(nth chunked page))]
|
||||
(cond-> ddown
|
||||
; Create the action row if we have more than 1 chunk
|
||||
(> (count chunked) 1) (update-in [:components] conj {:type 1 :components []})
|
||||
; More chunks exist
|
||||
(< page (dec (count chunked))) (update-in [:components 1 :components] conj (page-button uuid (name option) (inc page) "More"))
|
||||
; Past chunk 1
|
||||
(> page 0) (update-in [:components 1 :components] conj (page-button uuid (name option) (dec page) "Less")))))
|
||||
|
||||
(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))}
|
||||
(defn dropdown-result [interaction]
|
||||
(Integer/parseInt (s/select-one [:payload :values 0] interaction)))
|
||||
|
||||
(defn request-embed [{:keys [media-type title overview poster season quality-profile language-profile]}]
|
||||
{:title title
|
||||
:description overview
|
||||
:image {:url poster}
|
||||
:thumbnail {:url (media-type request-thumbnail)}
|
||||
:fields (filterv
|
||||
identity
|
||||
[(when profile
|
||||
; Some overrides to make things pretty
|
||||
[(when quality-profile
|
||||
{:name "Profile"
|
||||
:value profile})
|
||||
:value quality-profile})
|
||||
(when language-profile
|
||||
{:name "Language Profile"
|
||||
:value language-profile})
|
||||
(when season
|
||||
{:name "Season"
|
||||
:value (if (= season -1)
|
||||
"All"
|
||||
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 [embed-data uuid]
|
||||
{:content (str "Request this " (name (:media-type embed-data)) " ?")
|
||||
:embeds [(request-embed embed-data)]
|
||||
:flags 64
|
||||
:components [{:type 1 :components (for [format (:request-formats embed-data)]
|
||||
(request-button format uuid))}]})
|
||||
|
||||
(defn request-alert [selection & {:keys [season profile]}]
|
||||
{:content "This has been requested!"
|
||||
:embeds [(selection-embed selection :season season :profile profile)]})
|
||||
;; Discljord Utilities
|
||||
(defn register-commands [media-types bot-id messaging guild-id]
|
||||
(->> @(m/bulk-overwrite-guild-application-commands!
|
||||
messaging bot-id guild-id
|
||||
[(request-command media-types)])
|
||||
(else #(fatal % "Error in registering commands"))))
|
||||
|
||||
(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)))
|
||||
(defn set-permission [bot-id messaging guild-id command-id]
|
||||
(->> @(m/edit-application-command-permissions!
|
||||
messaging bot-id guild-id command-id
|
||||
[{:id (:role-id env) :type 1 :permission true}])
|
||||
(else #(fatal % "Error in setting command permissions"))))
|
||||
|
||||
@@ -1,149 +1,147 @@
|
||||
(ns doplarr.interaction-state-machine
|
||||
(:require
|
||||
[doplarr.overseerr :as ovsr]
|
||||
[config.core :refer [env]]
|
||||
[doplarr.discord :as discord]
|
||||
[taoensso.timbre :refer [info fatal]]
|
||||
[clojure.core.async :as a]
|
||||
[com.rpl.specter :as s]
|
||||
[fmnoise.flow :as flow :refer [then else]]
|
||||
[fmnoise.flow :refer [then else]]
|
||||
[discljord.messaging :as m]
|
||||
[clojure.string :as str]
|
||||
[doplarr.config :as config]
|
||||
[doplarr.sonarr :as sonarr]
|
||||
[doplarr.radarr :as radarr]))
|
||||
[doplarr.state :as state]
|
||||
[doplarr.utils :as utils :refer [log-on-error]]))
|
||||
|
||||
(def backend (delay (config/backend)))
|
||||
(def channel-timeout 600000)
|
||||
|
||||
(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]
|
||||
(defn start-interaction! [interaction]
|
||||
(a/go
|
||||
(let [{:keys [results request-type token]} (get @discord/cache uuid)
|
||||
selection-id (discord/dropdown-index interaction)
|
||||
profiles (->> (a/<! (((profiles-fn @backend) request-type)))
|
||||
(into []))
|
||||
selection (a/<! (((process-selection-fn @backend) request-type) (nth results selection-id)))]
|
||||
(case request-type
|
||||
:series (let [one-season? (> (case @backend :overseerr 1 :direct 2) (count (:seasons selection)))
|
||||
partial-seasons? (if (nil? (:partial-seasons env))
|
||||
(case @backend :overseerr (a/<! (ovsr/partial-seasons?)) :direct true)
|
||||
(:partial-seasons env))]
|
||||
(if (and partial-seasons? (not one-season?))
|
||||
(discord/update-interaction-response token (discord/select-season selection uuid))
|
||||
(let [season (if one-season? 1 -1)]
|
||||
(swap! discord/cache assoc-in [uuid :season] season)
|
||||
(case @backend
|
||||
:overseerr (discord/update-interaction-response token (discord/request selection uuid :season season))
|
||||
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid))))))
|
||||
:movie (case @backend
|
||||
:overseerr (discord/update-interaction-response token (discord/request selection uuid))
|
||||
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid))))
|
||||
(swap! discord/cache assoc-in [uuid :profiles] profiles)
|
||||
(swap! discord/cache assoc-in [uuid :selection] selection))))
|
||||
(let [uuid (str (java.util.UUID/randomUUID))
|
||||
id (:id interaction)
|
||||
token (:token interaction)
|
||||
payload-opts (:options (:payload interaction))
|
||||
media-type (first (keys payload-opts))
|
||||
query (s/select-one [media-type :query] payload-opts)
|
||||
{:keys [messaging bot-id]} @state/discord]
|
||||
; Send the ack for delayed response
|
||||
(->> @(m/create-interaction-response! messaging id token 5 :data {:flags 64})
|
||||
(else #(fatal % "Error in interaction ack")))
|
||||
; Search for results
|
||||
(info "Performing search for" (name media-type) query)
|
||||
(let [results (->> (log-on-error
|
||||
(a/<! ((utils/media-fn media-type "search") query media-type))
|
||||
"Exception from search")
|
||||
(then #(->> (take (:max-results env discord/MAX-OPTIONS) %)
|
||||
(into []))))]
|
||||
; Setup ttl cache entry
|
||||
(swap! state/cache assoc uuid {:results results
|
||||
:media-type media-type
|
||||
:token token
|
||||
:last-modified (System/currentTimeMillis)})
|
||||
; Create dropdown for search results
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/search-response results uuid))
|
||||
(else #(fatal % "Error in creating search responses")))))))
|
||||
|
||||
(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]
|
||||
(defn query-for-option-or-request [pending-opts 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))))))))))))
|
||||
(let [{:keys [messaging bot-id]} @state/discord
|
||||
{:keys [media-type token payload]} (get @state/cache uuid)]
|
||||
(if (empty? pending-opts)
|
||||
(let [embed (log-on-error
|
||||
(a/<! ((utils/media-fn media-type "request-embed") payload media-type))
|
||||
"Exception from request-embed")]
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/request embed uuid))
|
||||
(else #(fatal % "Error in sending request embed"))))
|
||||
(let [[op options] (first pending-opts)]
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/option-dropdown op options uuid 0))
|
||||
(else #(fatal % "Error in creating option dropdown"))))))))
|
||||
|
||||
(defmethod process-event "request-4k" [_ interaction uuid]
|
||||
(swap! discord/cache assoc-in [uuid :is4k] true)
|
||||
(process-event "request" interaction uuid))
|
||||
(defmulti process-event! (fn [event _ _ _] event))
|
||||
|
||||
(defn continue-interaction [interaction]
|
||||
(let [[event uuid] (str/split (s/select-one [:payload :component-id] interaction) #":")
|
||||
now (System/currentTimeMillis)]
|
||||
(defmethod process-event! "result-select" [_ interaction uuid _]
|
||||
(a/go
|
||||
(let [{:keys [results media-type]} (get @state/cache uuid)
|
||||
result (nth results (discord/dropdown-result interaction))
|
||||
add-opts (log-on-error
|
||||
(a/<! ((utils/media-fn media-type "additional-options") result media-type))
|
||||
"Exception thrown from additional-options")
|
||||
pending-opts (->> add-opts
|
||||
(filter #(seq? (second %)))
|
||||
(into {}))
|
||||
ready-opts (apply (partial dissoc add-opts) (keys pending-opts))]
|
||||
; Start setting up the payload
|
||||
(swap! state/cache assoc-in [uuid :payload] result)
|
||||
(swap! state/cache assoc-in [uuid :pending-opts] pending-opts)
|
||||
; Merge in the opts that are already satisfied
|
||||
(swap! state/cache update-in [uuid :payload] merge ready-opts)
|
||||
(query-for-option-or-request pending-opts uuid))))
|
||||
|
||||
(defmethod process-event! "option-page" [_ _ uuid option]
|
||||
(let [{:keys [messaging bot-id]} @state/discord
|
||||
{:keys [pending-opts token]} (get @state/cache uuid)
|
||||
[opt page] (str/split option #"-")
|
||||
op (keyword opt)
|
||||
page (Long/parseLong page)
|
||||
options (op pending-opts)]
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/option-dropdown op options uuid page))
|
||||
(else #(fatal % "Error in updating option dropdown")))))
|
||||
|
||||
(defmethod process-event! "option-select" [_ interaction uuid option]
|
||||
(let [selection (discord/dropdown-result interaction)
|
||||
cache-val (swap! state/cache update-in [uuid :pending-opts] #(dissoc % (keyword option)))]
|
||||
(swap! state/cache assoc-in [uuid :payload (keyword option)] selection)
|
||||
(query-for-option-or-request (get-in cache-val [uuid :pending-opts]) uuid)))
|
||||
|
||||
(defmethod process-event! "request" [_ interaction uuid format]
|
||||
(let [{:keys [messaging bot-id]} @state/discord
|
||||
{:keys [payload media-type token]} (get @state/cache uuid)
|
||||
{:keys [user-id channel-id]} interaction]
|
||||
(letfn [(msg-resp [msg] (->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response msg))
|
||||
(else #(fatal % "Error in message response"))))]
|
||||
(->> (log-on-error
|
||||
(a/<!! ((utils/media-fn media-type "request")
|
||||
(assoc payload :format (keyword format) :discord-id user-id)
|
||||
media-type))
|
||||
"Exception from request")
|
||||
(then (fn [status]
|
||||
(case status
|
||||
:unauthorized (msg-resp "You are unauthorized to perform this request in the configured backend")
|
||||
:pending (msg-resp "This has already been requested and the request is pending")
|
||||
:processing (msg-resp "This is currently processing and should be available soon!")
|
||||
:available (msg-resp "This selection is already available!")
|
||||
(do
|
||||
(info "Performing request for " payload)
|
||||
(m/create-message! messaging channel-id
|
||||
:content
|
||||
(str "<@" user-id "> has requested the "
|
||||
(name media-type) " `" (:title payload) " (" (:year payload) ")"
|
||||
"` and it should be available soon!"))
|
||||
(msg-resp "Request performed!")))))
|
||||
(else (fn [e]
|
||||
(let [{:keys [status body] :as data} (ex-data e)]
|
||||
(if (= status 403)
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response (body "message")))
|
||||
(else #(fatal % "Error in sending request failure response")))
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response "Unspecified error on request, check logs"))
|
||||
(then #(fatal data "Non 403 error on request"))
|
||||
(else #(fatal % "Error in sending error response")))))))))))
|
||||
|
||||
(defn continue-interaction! [interaction]
|
||||
(let [[event uuid option] (str/split (s/select-one [:payload :component-id] interaction) #":")
|
||||
now (System/currentTimeMillis)
|
||||
{:keys [token id]} interaction
|
||||
{:keys [messaging bot-id]} @state/discord]
|
||||
; Send the ack
|
||||
(discord/interaction-response (:id interaction) (:token interaction) 6)
|
||||
(->> @(m/create-interaction-response! messaging id token 6)
|
||||
(else #(fatal % "Error sending response ack")))
|
||||
; Check last modified
|
||||
(let [{:keys [token last-modified]} (get @discord/cache uuid)]
|
||||
(if (> (- now last-modified) discord/channel-timeout)
|
||||
(if-let [{:keys [token last-modified]} (get @state/cache uuid)]
|
||||
(if (> (- now last-modified) channel-timeout)
|
||||
; Update interaction with timeout message
|
||||
(discord/update-interaction-response token (discord/content-response "Request timed out, please try again."))
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response "Request timed out, please try again"))
|
||||
(else #(fatal % "Error in sending timeout response")))
|
||||
; 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))))))
|
||||
(swap! state/cache assoc-in [uuid :last-modified] now)
|
||||
(process-event! event interaction uuid option)))
|
||||
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response "Request timed out, please try again"))
|
||||
(else #(fatal % "Error in sending timeout response"))))))
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
(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))))
|
||||
@@ -1,61 +0,0 @@
|
||||
(ns doplarr.radarr
|
||||
(: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)))
|
||||
|
||||
(defn rootfolder [] (utils/rootfolder @base-url @api-key))
|
||||
|
||||
(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 search [term]
|
||||
(a/go
|
||||
(->> (a/<! (GET "/movie/lookup" {:query-params {:term term}}))
|
||||
(then :body)
|
||||
(else utils/fatal-error))))
|
||||
|
||||
(defn quality-profiles []
|
||||
(a/go
|
||||
(->> (a/<! (GET "/qualityProfile"))
|
||||
(then #(->> (:body %)
|
||||
(map utils/quality-profile-data)))
|
||||
(else utils/fatal-error))))
|
||||
|
||||
(defn request [movie & {:keys [profile-id]}]
|
||||
(a/go
|
||||
(->> (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))
|
||||
@@ -1,150 +0,0 @@
|
||||
(ns doplarr.sonarr
|
||||
(: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 (:sonarr-url env) "/api")))
|
||||
(def api-key (delay (:sonarr-api env)))
|
||||
|
||||
(defn rootfolder [] (utils/rootfolder @base-url @api-key))
|
||||
|
||||
(defn GET [endpoint & [params]]
|
||||
(utils/http-request
|
||||
:get
|
||||
(str @base-url endpoint)
|
||||
@api-key
|
||||
params))
|
||||
|
||||
(defn POST [endpoint & [params]]
|
||||
(utils/http-request
|
||||
:post
|
||||
(str @base-url endpoint)
|
||||
@api-key
|
||||
params))
|
||||
|
||||
(defn PUT [endpoint & [params]]
|
||||
(utils/http-request
|
||||
:put
|
||||
(str @base-url endpoint)
|
||||
@api-key
|
||||
params))
|
||||
|
||||
(defn search [term]
|
||||
(a/go
|
||||
(->> (a/<! (GET "/series/lookup" {:query-params {:term term}}))
|
||||
(then :body)
|
||||
(else utils/fatal-error))))
|
||||
|
||||
(defn quality-profiles []
|
||||
(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 {:ignoreEpisodesWithFiles true
|
||||
:searchForMissingEpisodes true}}))
|
||||
|
||||
(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 [id (:id series)
|
||||
series (if id
|
||||
(s/multi-transform
|
||||
(s/multi-path
|
||||
[:seasons
|
||||
s/ALL
|
||||
(comp pos? :seasonNumber)
|
||||
:monitored
|
||||
(s/terminal-val true)]
|
||||
[:profileId
|
||||
(s/terminal-val profile-id)])
|
||||
series)
|
||||
(merge series (a/<! (request-options profile-id))))]
|
||||
(->> (a/<! ((if id PUT POST)
|
||||
"/series"
|
||||
{:form-params series
|
||||
:content-type :json}))
|
||||
(then (fn [_]
|
||||
(when id
|
||||
(search-series id))))))))
|
||||
|
||||
(defn request-season [series season profile-id]
|
||||
(a/go
|
||||
(let [id (:id series)
|
||||
series (if id
|
||||
(s/multi-transform
|
||||
(s/multi-path
|
||||
[:seasons
|
||||
s/ALL
|
||||
(comp (partial = season) :seasonNumber)
|
||||
(s/multi-path
|
||||
[:monitored
|
||||
(s/terminal-val true)]
|
||||
[:statistics
|
||||
(s/terminal #(assoc % :episodeCount (:totalEpisodeCount %)))])]
|
||||
[:profileId
|
||||
(s/terminal-val profile-id)])
|
||||
series)
|
||||
(merge (s/setval [:seasons
|
||||
s/ALL
|
||||
(comp (partial not= season) :seasonNumber)
|
||||
:monitored]
|
||||
false series)
|
||||
(a/<! (request-options profile-id))))]
|
||||
(->> (a/<! ((if id PUT POST)
|
||||
"/series"
|
||||
{:form-params series
|
||||
:content-type :json}))
|
||||
(then (fn [_]
|
||||
(when id
|
||||
(search-season id season))))))))
|
||||
|
||||
(defn request [series & {:keys [season profile-id]}]
|
||||
(if (= -1 season)
|
||||
(request-all series profile-id)
|
||||
(request-season series season profile-id)))
|
||||
|
||||
(defn post-process-series [series]
|
||||
(a/go
|
||||
(if-let [id (:id series)]
|
||||
(->> (a/<! (GET (str "/series/" id)))
|
||||
(then #(->> (:body %)
|
||||
(merge series)))
|
||||
(else utils/fatal-error))
|
||||
series)))
|
||||
|
||||
(defn season-status [series & {:keys [season]}]
|
||||
(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)))))
|
||||
7
src/doplarr/state.clj
Normal file
7
src/doplarr/state.clj
Normal file
@@ -0,0 +1,7 @@
|
||||
(ns doplarr.state
|
||||
(:require
|
||||
[clojure.core.cache.wrapped :as cache]))
|
||||
|
||||
(def cache (cache/ttl-cache-factory {} :ttl 900000))
|
||||
|
||||
(def discord (atom nil))
|
||||
86
src/doplarr/utils.clj
Normal file
86
src/doplarr/utils.clj
Normal file
@@ -0,0 +1,86 @@
|
||||
(ns doplarr.utils
|
||||
(:require
|
||||
[taoensso.timbre :refer [fatal]]
|
||||
[camel-snake-kebab.core :as csk]
|
||||
[camel-snake-kebab.extras :as cske]
|
||||
[clojure.core.async :as a]
|
||||
[fmnoise.flow :as flow :refer [then else]]
|
||||
[hato.client :as hc]
|
||||
[clojure.string :as str]
|
||||
[doplarr.config :as config]))
|
||||
|
||||
(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/promise-chan)
|
||||
put (partial a/put! chan)]
|
||||
(hc/request
|
||||
(deep-merge
|
||||
{:method method
|
||||
:url url
|
||||
:as :json-string-keys
|
||||
:coerce :always
|
||||
:async? true
|
||||
:headers {"X-API-Key" key}}
|
||||
params)
|
||||
put
|
||||
put)
|
||||
chan))
|
||||
|
||||
(defn from-camel [m]
|
||||
(cske/transform-keys csk/->kebab-case-keyword m))
|
||||
|
||||
(defn to-camel [m]
|
||||
(cske/transform-keys csk/->camelCaseString m))
|
||||
|
||||
(defn process-search-result [result]
|
||||
(-> result
|
||||
(select-keys ["title" "year" "id" "tvdbId" "tmdbId"])
|
||||
from-camel))
|
||||
|
||||
(defn process-profile [profile]
|
||||
(->> (select-keys profile ["id" "name"])
|
||||
from-camel))
|
||||
|
||||
(defn profile-name-id [profiles name]
|
||||
(->> profiles
|
||||
(filter #(= name (:name %)))
|
||||
first
|
||||
:id))
|
||||
|
||||
(defn profile-id-name [profiles id]
|
||||
(->> profiles
|
||||
(filter #(= id (:id %)))
|
||||
first
|
||||
:name))
|
||||
|
||||
(defn request-and-process-body [request-fn process-fn & request-args]
|
||||
(a/go
|
||||
(->> (a/<! (apply request-fn request-args))
|
||||
(then #(process-fn (:body %)))
|
||||
(else #(fatal %)))))
|
||||
|
||||
(defn canonical-option-name [option]
|
||||
(-> (name option)
|
||||
(str/replace #"-" " ")
|
||||
(#(if (str/ends-with? % "id")
|
||||
(str/trim (subs % 0 (- (count %) 2)))
|
||||
(str/trim %)))))
|
||||
|
||||
(defn media-fn
|
||||
"Resolves a function `f` in the backend namespace matching the available backend for a given `media`"
|
||||
[media f]
|
||||
(requiring-resolve
|
||||
(symbol (str "doplarr.backends." (name (config/available-backed-for-media
|
||||
media)))
|
||||
f)))
|
||||
|
||||
(defmacro log-on-error [expr msg]
|
||||
`(try
|
||||
~expr
|
||||
(catch Exception e#
|
||||
(fatal e# ~msg)
|
||||
(throw e#))))
|
||||
Reference in New Issue
Block a user