Merge pull request #45 from kiranshila/develop

More generic rewrite
This commit is contained in:
Kiran Shila
2022-01-12 15:00:27 -08:00
committed by GitHub
22 changed files with 966 additions and 809 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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
View 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#))))