Overseerr works!

This commit is contained in:
Kiran Shila
2022-01-11 12:38:10 -08:00
parent c303fefbc3
commit 4371b9de69
9 changed files with 118 additions and 157 deletions

View File

@@ -10,13 +10,13 @@
<img alt="Discord" src="https://img.shields.io/discord/890634173751119882?color=ff69b4&label=discord&style=for-the-badge">
</p>
> A 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 😛
@@ -36,10 +36,7 @@ There is only a boolean permission (role gated) for who has access to the bot, n
#### 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?
@@ -96,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
@@ -105,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
```
@@ -118,11 +115,11 @@ 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
- 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
```
@@ -142,11 +139,15 @@ 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) |
| `PARTIAL_SEASONS` | `:partial-seasons` | Boolean | True | Sets whether users can request partial seasons. |
| `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 |
### Setting up on Windows

View File

@@ -1,6 +1,16 @@
{: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
}

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

@@ -16,8 +16,7 @@
; if the request type is a series
(defn additional-options [result media-type]
(a/go
(let [type (impl/media-type media-type)
details (a/<! (impl/details (:id result) type))
(let [details (a/<! (impl/details (:id result) media-type))
{:keys [partial-seasons]} env]
(when (= media-type :series)
(let [seasons (impl/seasons-list details)
@@ -26,17 +25,40 @@
(= 1 (count seasons)) (:id (first seasons))
(false? partial-seasons) -1
(false? backend-partial-seasons?) -1
:else (impl/seasons-list details))})))))
: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 (impl/media-type 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 [""]
:request-formats (cond-> [""] fourk (conj "4K"))
:season season})))
(defn request [payload])
(defn request [payload media-type]
(a/go
(let [{:keys [format id season season-count discord-id]} payload
{: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

@@ -43,9 +43,9 @@
"tv"
(name kw)))
(defn details [id media-type-str]
(defn details [id media]
(a/go
(->> (a/<! (GET (str "/" media-type-str "/" id)))
(->> (a/<! (GET (str "/" (media-type media) "/" id)))
(then (comp :body utils/from-camel))
(else #(fatal % "Error requesting details on selection from Overseerr")))))
@@ -61,8 +61,8 @@
(defn backend-4k? [media]
(a/go
(->> (a/<! (GET (str "/settings/" (if (= (media-type media) "tv") "sonarr" "radarr"))))
(then #(->> (:body %)
(map :is4k)
(then #(->> (utils/from-camel (:body %))
(map :is-4k)
(some identity)))
(else #(fatal % "Exception on checking Overseeerr 4K backend support")))))
@@ -73,17 +73,29 @@
:partial-requests-enabled))
(else #(fatal % "Exception testing for partial seasons")))))
;;; NOT MODIFED YET
(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 #(s/select-one [:body :pageInfo :results] %))
(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 #(->> (s/select-one [:body :results] %)
(then #(->> (utils/from-camel %)
(s/select-one [:body :results])
(map :id)
(into [])))
(else #(fatal % "Exception on querying Overseerr users")))))
@@ -91,7 +103,8 @@
(defn discord-id [ovsr-id]
(a/go
(->> (a/<! (GET (str "/user/" ovsr-id)))
(then #(s/select-one [:body :settings :discordId] %))
(then #(->> (utils/from-camel %)
(s/select-one [:body :settings :discord-id])))
(else #(fatal % "Exception on querying Overseerr discord id")))))
(defn discord-users []
@@ -101,45 +114,3 @@
users
(let [id (first ids)]
(recur (rest ids) (assoc users (a/<! (discord-id id)) id))))))
(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 (inc (: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)))))

View File

@@ -38,7 +38,7 @@
:request-formats [""]
:quality-profile (utils/profile-id-name quality-profiles quality-profile-id)})))
(defn request [payload]
(defn request [payload _]
(a/go
(let [status (impl/status (a/<! (impl/get-from-tmdb (:tmdb-id payload))))]
(if status

View File

@@ -59,7 +59,7 @@
: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]
(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))))

View File

@@ -8,23 +8,31 @@
; 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
; --- Optional settings
(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]
@@ -44,8 +52,11 @@
:radarr/quality-profile
:sonarr/quality-profile
:sonarr/language-profile
:overseerr/default-id
::partial-seasons])
#(some (partial contains? %) [:sonarr/url
:radarr/url])
:radarr/url
:overseerr/url])
(matched-keys :sonarr/url :sonarr/api)
(matched-keys :radarr/url :radarr/api)))
(matched-keys :radarr/url :radarr/api)
(matched-keys :overseerr/url :overseerr/api)))

View File

@@ -30,7 +30,7 @@
(let [results (->> (log-on-error
(a/<! ((utils/media-fn media-type "search") query media-type))
"Exception from search")
(then #(->> (take (:max-results env 10) %)
(then #(->> (take (:max-results env discord/MAX-OPTIONS) %)
(into []))))]
; Setup ttl cache entry
(swap! state/cache assoc uuid {:results results
@@ -91,18 +91,20 @@
(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" [_ _ uuid format]
(defmethod process-event! "request" [_ interaction uuid format]
(let [{:keys [messaging bot-id]} @state/discord
{:keys [payload media-type token]} (get @state/cache uuid)]
{:keys [payload media-type token]} (get @state/cache uuid)
{:keys [user-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))))
(assoc payload :format (keyword format) :discord-id user-id)
media-type))
"Exception from request")
(then (fn [status]
(case status
:unauthorized (msg-resp "You do not have an associated account in the request backend")
: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!")
@@ -125,7 +127,7 @@
(->> @(m/create-interaction-response! messaging id token 6)
(else #(fatal % "Error sending response ack")))
; Check last modified
(let [{:keys [token last-modified]} (get @state/cache uuid)]
(if-let [{:keys [token last-modified]} (get @state/cache uuid)]
(if (> (- now last-modified) channel-timeout)
; Update interaction with timeout message
(->> @(m/edit-original-interaction-response! messaging bot-id token (discord/content-response "Request timed out, please try again"))
@@ -133,4 +135,6 @@
; Move through the state machine to update cache side effecting new components
(do
(swap! state/cache assoc-in [uuid :last-modified] now)
(process-event! event interaction uuid option))))))
(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"))))))