diff --git a/.all-contributorsrc b/.all-contributorsrc
index 5b8d00db..c0f24e34 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -431,6 +431,33 @@
"contributions": [
"translation"
]
+ },
+ {
+ "login": "acortelyou",
+ "name": "Alex Cortelyou",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1689668?v=4",
+ "profile": "https://github.com/acortelyou",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jonocairns",
+ "name": "Jono Cairns",
+ "avatar_url": "https://avatars.githubusercontent.com/u/182836?v=4",
+ "profile": "https://nz.linkedin.com/in/jonocairns",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "DJScias",
+ "name": "DJScias",
+ "avatar_url": "https://avatars.githubusercontent.com/u/439655?v=4",
+ "profile": "https://scias.net/",
+ "contributions": [
+ "translation"
+ ]
}
],
"badgeTemplate": " -orange.svg\"/> ",
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..fcadb2cf
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 751a8c07..489e4aaa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ jobs:
run: yarn build
build_and_push:
- name: Build & Publish to Docker Hub
+ name: Build & Publish Docker Images
needs: test
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
@@ -38,23 +38,23 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
- uses: actions/cache@v2.1.4
+ uses: actions/cache@v2.1.5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- - name: Login to DockerHub
+ - name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- - name: Login to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
- password: ${{ secrets.CR_PAT }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6ddccdd1..62b2ecb1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v2
with:
fetch-depth: 0
- - name: Setup Node.js
+ - name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 14
@@ -38,17 +38,17 @@ jobs:
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- - name: Login to DockerHub
+ - name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- - name: Login to GitHub Container Registry
+ - name: Log in to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
- password: ${{ secrets.CR_PAT }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: yarn
- name: Release
diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml
index 9fd27835..ea7f92f9 100644
--- a/.github/workflows/snap.yaml
+++ b/.github/workflows/snap.yaml
@@ -2,7 +2,8 @@ name: Publish Snap
on:
push:
- branches: [develop]
+ branches:
+ - develop
jobs:
jobs:
@@ -11,14 +12,15 @@ jobs:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
- uses: styfle/cancel-workflow-action@0.8.0
+ uses: styfle/cancel-workflow-action@0.9.0
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
+
test:
name: Lint & Test Build
needs: jobs
runs-on: ubuntu-20.04
- container: node:12.18-alpine
+ container: node:14.16-alpine
steps:
- name: checkout
uses: actions/checkout@v2
@@ -30,6 +32,7 @@ jobs:
run: yarn lint
- name: build
run: yarn build
+
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: test
@@ -44,7 +47,6 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v2
-
- name: Prepare
id: prepare
run: |
@@ -54,35 +56,31 @@ jobs:
else
echo ::set-output name=RELEASE::edge
fi
-
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
-
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
-
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
-
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
with:
snap: ${{ steps.build.outputs.snap }}
-
- name: Publish Snap Package
uses: snapcore/action-publish@v1
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
+
discord:
name: Send Discord Notification
needs: build-snap
@@ -91,7 +89,6 @@ jobs:
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
-
- name: Combine Job Status
id: status
run: |
@@ -101,7 +98,6 @@ jobs:
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
-
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
diff --git a/README.md b/README.md
index 07a836c6..2fa70358 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -22,18 +22,14 @@
- Full Plex integration. Authenticate and manage user access with Plex!
- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come!
-- Plex library sync, to keep track of the titles which are already available.
+- Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
-## Planned Features
-
-- Additional notification types.
-- Issues system. This will allow users to report issues with content on your media server.
-- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
+With more features on the way! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
## Getting Started
@@ -41,27 +37,6 @@ Check out our documentation for instructions on how to install and run Overseerr
https://docs.overseerr.dev/getting-started/installation
-## Running Overseerr
-
-Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with:
-
-```
-docker run -d \
- --name overseerr \
- -e LOG_LEVEL=info \
- -e TZ=Asia/Tokyo \
- -p 5055:5055 \
- -v /path/to/appdata/config:/app/config \
- --restart unless-stopped \
- sctx/overseerr
-```
-
-After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
-
-For more information and alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
-
-⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
-
## Preview
@@ -158,6 +133,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Torkil 🌍
Jagandeep Brar 📖
dtalens 🌍
+ Alex Cortelyou 💻
+ Jono Cairns 💻
+ DJScias 🌍
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 0e84bdeb..36afc905 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -9,10 +9,15 @@
## Using Overseerr
- [Settings](using-overseerr/settings/README.md)
+- [Users](using-overseerr/users/README.md)
- [Notifications](using-overseerr/notifications/README.md)
- [Email](using-overseerr/notifications/email.md)
- [Discord](using-overseerr/notifications/discord.md)
- - [Webhooks](using-overseerr/notifications/webhooks.md)
+ - [Pushbullet](using-overseerr/notifications/pushbullet.md)
+ - [Pushover](using-overseerr/notifications/pushover.md)
+ - [Slack](using-overseerr/notifications/slack.md)
+ - [Telegram](using-overseerr/notifications/telegram.md)
+ - [Webhook](using-overseerr/notifications/webhooks.md)
## Support
diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md
index 659feb88..2a06d9f4 100644
--- a/docs/extending-overseerr/reverse-proxy-examples.md
+++ b/docs/extending-overseerr/reverse-proxy-examples.md
@@ -1,14 +1,20 @@
# Reverse Proxy Examples
{% hint style="warning" %}
-Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk.
+Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
+
+A Nginx subfolder workaround configuration is provided below, but it is not officially supported.
{% endhint %}
## SWAG
-A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
+A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag).
-To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
+However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
+
+To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`.
+
+Alternatively, you can create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
```nginx
server {
@@ -22,20 +28,18 @@ server {
client_max_body_size 0;
location / {
-
include /config/nginx/proxy.conf;
resolver 127.0.0.11 valid=30s;
set $upstream_app overseerr;
set $upstream_port 5055;
set $upstream_proto http;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
-
}
}
```
-## Traefik \(v2\)
+## Traefik (v2)
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
@@ -51,7 +55,7 @@ labels:
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
```
-For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
+For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
## Nginx
@@ -84,24 +88,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Ssl on;
- real_ip_header CF-Connecting-IP;
- # Control the behavior of the Referer header (Referrer-Policy)
- add_header Referrer-Policy "no-referrer";
- # HTTP Strict Transport Security
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
- # Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
- # add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
- # Prevent some categories of XSS attacks (X-XSS-Protection)
- add_header X-XSS-Protection "1; mode=block" always;
- # Provide clickjacking protection (X-Frame-Options)
- add_header X-Frame-Options "SAMEORIGIN" always;
- # Prevent Sniff Mimetype (X-Content-Type-Options)
- add_header X-Content-Type-Options "nosniff" always;
- # Tell crawling bots to not index the site
- add_header X-Robots-Tag "noindex, nofollow" always;
-
- access_log /var/log/nginx/overseerr.example.com-access.log;
- error_log /var/log/nginx/overseerr.example.com-error.log;
location / {
proxy_pass http://127.0.0.1:5055;
@@ -114,12 +100,15 @@ Then, create a symlink to `/etc/nginx/sites-enabled`:
```bash
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
+
{% endtab %}
{% tab title="Subfolder" %}
{% hint style="warning" %}
-Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk!
+This Nginx subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Overseerr is updated.
+
+If you encounter any issues with Overseerr while using this workaround, we may ask you to try to reproduce the problem without the Nginx proxy.
{% endhint %}
Add the following location block to your existing `nginx.conf` file.
@@ -127,13 +116,16 @@ Add the following location block to your existing `nginx.conf` file.
```nginx
location ^~ /overseerr {
set $app 'overseerr';
+
# Remove /overseerr path to pass to the app
rewrite ^/overseerr/?(.*)$ /$1 break;
- proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
+ proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
+
# Redirect location headers
proxy_redirect ^ /$app;
proxy_redirect /setup /$app/setup;
proxy_redirect /login /$app/login;
+
# Sub filters to replace hardcoded paths
proxy_set_header Accept-Encoding "";
sub_filter_once off;
@@ -152,6 +144,7 @@ location ^~ /overseerr {
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
}
```
+
{% endtab %}
{% endtabs %}
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index 88e213fd..55f8a5f0 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -1,7 +1,7 @@
# Installation
{% hint style="danger" %}
-Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`** instead!
+Overseerr is currently in beta. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
{% endhint %}
{% hint style="info" %}
@@ -32,7 +32,7 @@ docker run -d \
```yaml
---
-version: "3"
+version: '3'
services:
overseerr:
@@ -68,7 +68,7 @@ docker run -d \
{% tab title="Manual Update" %}
-```text
+```bash
# Stop the Overseerr container
docker stop overseerr
@@ -116,7 +116,7 @@ Docker on Windows works differently than it does on Linux; it uses a VM to run a
## Linux
{% hint style="info" %}
-The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd).
+The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supported Linux install method aside from [Docker](#docker). Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
{% endhint %}
**To install:**
@@ -142,7 +142,7 @@ sudo snap install overseerr --edge
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
-## Third Party
+## Third-Party
{% tabs %}
diff --git a/docs/support/asking-for-support.md b/docs/support/asking-for-support.md
index 2743827e..ad696926 100644
--- a/docs/support/asking-for-support.md
+++ b/docs/support/asking-for-support.md
@@ -1,33 +1,40 @@
# Asking for Support
-## Before Asking for Support
+Before seeking help, please make sure you have first tried these following:
-Before seeking help, please make sure you have tried these following first:
+- **Updating** Overseerr to the latest version.
+- **Stopping and restarting** Overseerr.
+- **Restarting** your machine.
+- **Clearing** your browser cache.
+- **Analyzing** your logs, you just might find the solution yourself!
+- **Searching** the [documentation](../), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
-- **Update** to the latest version.
-- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
-- **Analyze** your logs, you just might find the solution yourself!
-- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
-- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
+If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
+
+Be sure to also include a link to your logs. (Please see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.)
## What should I include when asking for support?
-When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
+When contacting support, please try to include as much information as possible. A vague statement like "it doesn't work" provides very little to go on, and makes it difficult for us to help you.
-- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
+Try to answer the following questions:
+
+- What were you trying to do, and how did you attempt it?
- What command did you enter?
- What did you click on?
- What settings did you change?
+ - Did you follow official instructions, or a third-party guide?
- Provide a step-by-step list of what you tried.
-- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
+ - Provide a brief description of your setup.
+- What exactly do you see?
- Did something happen?
- Did something not happen?
- Are there any error messages showing?
- Provide screenshots to help us see what you are seeing.
- - Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
+ - Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues (see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below).
## How can I share my logs?
-1. Locate the log file at `/logs/overseerr.log`
+1. Locate the current log file at `/logs/overseerr.log`.
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).
diff --git a/docs/support/faq.md b/docs/support/faq.md
index b80b7751..b74bf918 100644
--- a/docs/support/faq.md
+++ b/docs/support/faq.md
@@ -1,46 +1,54 @@
# Frequently Asked Questions (FAQ)
{% hint style="info" %}
-If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
+If you can't find the solution to your problem here, please seek help on [Discord](https://discord.gg/PkCWJSeCk7).
+
+_Please do not post questions or support requests on the GitHub issue tracker!_
{% endhint %}
## General
-### I receive 409 or 400 errors when requesting a movie or TV series!
-
-**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions.
-
### How do I keep Overseerr up-to-date?
-**A:** Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
+Use a third-party update mechanism (such as [Watchtower](https://github.com/containrrr/watchtower), [Ouroboros](https://github.com/pyouroboros/ouroboros), or [Pullio](https://hotio.dev/pullio)) to keep Overseerr up-to-date automatically.
-### How can I access Overseerr outside my home network?
+### How can I access Overseerr outside of my home network?
-**A:** The easy and least secure method is to forward an external port \(`5055`\) on your router to the internal port used by Overseerr \(default is TCP `5055`\). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You will then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
+The easiest but least secure method is to simply forward an external port (e.g., `5055`) on your router to the internal port used by Overseerr (default is TCP `5055`). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You would then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
-The more advanced and most preferred method \(and more secure if you use SSL\) is to set up a web server with NGINX/Apache, and use a reverse proxy to access Overseerr. You can lookup many guides on the internet to find out how to do this. There are several reverse proxy config examples located [here](../extending-overseerr/reverse-proxy-examples.md).
+A more advanced, user-friendly, and secure (if using SSL) method is to set up a web server and use a reverse proxy to access Overseerr. Please refer to our [reverse proxy examples](../extending-overseerr/reverse-proxy-examples.md) for more information.
-The most secure method, but also the most inconvenient, is to set up a VPN tunnel to your home server, then you can access Overseerr as if it is on a local network via `http://LOCAL-IP-ADDRESS:5055`.
+The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`.
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
-**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
+You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
-**A:** You can find the changelog in the **Settings → About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
+You can find the changelog in the **Settings → About** page in your Overseerr instance if you are using the `latest` tag. You can alternatively review the [release/version history on GitHub](https://github.com/sct/overseerr/releases).
-### Can I make 4K requests?
-
-**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings → Services**, tick the `4K Server` checkbox. You also need to tick the `Default Server` checkbox if it is the default server you would like to use for 4K content requests. (To enable 4K requests, there need to be default Sonarr/Radarr servers for both 4K content **and** non-4K content.)
+If you are using the `develop` tag, please refer to the [commit history for that branch on GitHub](https://github.com/sct/overseerr/commits/develop).
### Some media is missing from Overseerr that I know is in Plex!
-**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
+Overseerr currently supports the following agents:
-**Troubleshooting Steps:**
+- New Plex Movie
+- Legacy Plex Movie
+- New Plex TV
+- Legacy Plex TV
+- TheTVDB
+- TMDb
+- [HAMA](https://github.com/ZeroQI/Hama.bundle)
-First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
+Please verify that your library is using one of the agents previously listed.
+
+When changing agents, a full metadata refresh of your Plex library is required. (Caution: This can take a long time depending on the size of your library.)
+
+#### Troubleshooting Steps
+
+First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched.
1. Verify that you are using one of the agents mentioned above.
2. Refresh the metadata for just that item.
@@ -58,44 +66,56 @@ You can also perform the following to verify the media item has a GUID Overseerr
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
-### TV series requests are failing after I updated Overseerr!
+### Where can I find the log files?
-**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
+Please see [these instructions on how to locate and share your logs](./asking-for-support#how-can-i-share-my-logs).
-### Where can I find the logs?
+## Users
-**A:** The logs are located at `/logs/overseerr.log`
+### Why can't I see all of my Plex users?
-## User management
-
-### Why can't I see all my Plex users?
-
-**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings → General Settings** page beforehand.
+Please see the [documentation for importing users from Plex](../using-overseerr/users#importing-users-from-plex).
### Can I create local users in Overseerr?
-**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
+Yes! Please see the [documentation for creating local users](../using-overseerr/users#creating-local-users).
### Is is possible to set user roles in Overseerr?
-**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
+Permissions can be configured for each user via the **User List** or their **User Settings** page. The list of assignable permissions is still growing, so if you have any suggestions, [submit a feature request](https://github.com/sct/overseerr/issues/new/choose)!
## Requests
+### I receive 409 or 400 errors when requesting a movie or TV series!
+
+Verify you are running v3 of both Radarr and Sonarr. Overseerr is not backwards-compatible with previous versions.
+
+### Can I allow users to submit 4K requests?
+
+Yes! If you keep both non-4K and 4K content in your media libraries, you can link separate 4K Radarr/Sonarr servers to allow users to submit 4K requests. (You must configure default non-4K **and** default 4K Radarr/Sonarr servers.)
+
+Please see the [Services documentation](../using-overseerr/settings/README.md#services) for details on how to configure your Radarr and/or Sonarr servers.
+
+Note that users must also have the **Request 4K**, **Request 4K Movies**, and/or **Request 4K Series** permissions in order to submit requests for 4K content.
+
### I approved a requested movie and Radarr didn't search for it!
-**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
+Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
### Help! My request still shows "requested" even though it is in Plex!
-**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
+See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
-### Approved series requests keep failing!
+### Series requests keep failing!
-**A:** If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr. Also, check that you are using Sonarr v3 and have configured a default language profile in Overseerr.
+If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr.
+
+Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
+
+Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
## Notifications
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
-**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
+If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md
index 05005233..5836e089 100644
--- a/docs/using-overseerr/notifications/README.md
+++ b/docs/using-overseerr/notifications/README.md
@@ -1,15 +1,15 @@
# Notifications
-Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it.
-
## Supported Notification Agents
+Overseerr currently supports the following notification agents:
+
- [Email](./email.md)
- [Discord](./discord.md)
-- Pushbullet
-- Pushover
-- Slack
-- Telegram
+- [Pushbullet](./pushbullet.md)
+- [Pushover](./pushover.md)
+- [Slack](./slack.md)
+- [Telegram](./telegram.md)
- [Webhooks](./webhooks.md)
## Setting Up Notifications
@@ -22,4 +22,4 @@ Note that some notifications are intended for the user who submitted the relevan
## Requesting New Notification Agents
-If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
+If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
diff --git a/docs/using-overseerr/notifications/discord.md b/docs/using-overseerr/notifications/discord.md
index a546ba38..f46b93ac 100644
--- a/docs/using-overseerr/notifications/discord.md
+++ b/docs/using-overseerr/notifications/discord.md
@@ -1,11 +1,25 @@
# Discord
+{% hint style="info" %}
+The following notification types will mention _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
+
+- Media Requested
+- Media Automatically Approved
+- Media Failed
+
+On the other hand, the notification types below will only mention the user who submitted the request:
+
+- Media Approved (does not include automatic approvals)
+- Media Declined
+- Media Available
+
+In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured and **Enable Mentions** checked in their Discord notification user settings.
+{% endhint %}
+
## Configuration
{% hint style="info" %}
-In order to configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
-
-In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured in their user settings.
+To configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
{% endhint %}
### Bot Username (optional)
diff --git a/docs/using-overseerr/notifications/email.md b/docs/using-overseerr/notifications/email.md
index fd2c71c0..1cd5839a 100644
--- a/docs/using-overseerr/notifications/email.md
+++ b/docs/using-overseerr/notifications/email.md
@@ -9,15 +9,16 @@ The following email notification types are sent to _all_ users with the **Manage
On the other hand, the email notification types below are only sent to the user who submitted the request:
-- Media Approved
+- Media Approved (does not include automatic approvals)
- Media Declined
- Media Available
+In order for users to receive email notifications, they must have **Enable Notifications** checked in their email notification user settings.
{% endhint %}
## Configuration
-### Sender Address (required)
+### Sender Address
Set this to the email address you would like to appear in the "from" field of the email message.
@@ -51,4 +52,6 @@ Configure these values as appropriate to authenticate with your SMTP host.
### PGP Private Key & Password (optional)
-Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their PGP public keys enabled in their user settings in order for PGP encryption to be used.
+Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their **PGP public keys** configured in their user settings in order for PGP encryption to be used in messages addressed to them.
+
+When configuring the PGP keys, be sure to keep the entire contents of the key intact. For example, private keys always begin with `-----BEGIN PGP PRIVATE KEY BLOCK-----` and end with `-----END PGP PRIVATE KEY BLOCK-----`.
diff --git a/docs/using-overseerr/notifications/pushbullet.md b/docs/using-overseerr/notifications/pushbullet.md
new file mode 100644
index 00000000..45edcc3a
--- /dev/null
+++ b/docs/using-overseerr/notifications/pushbullet.md
@@ -0,0 +1,7 @@
+# Pushbullet
+
+## Configuration
+
+### Access Token
+
+[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API.
diff --git a/docs/using-overseerr/notifications/pushover.md b/docs/using-overseerr/notifications/pushover.md
new file mode 100644
index 00000000..55893dba
--- /dev/null
+++ b/docs/using-overseerr/notifications/pushover.md
@@ -0,0 +1,15 @@
+# Pushover
+
+## Configuration
+
+### Application/API Token
+
+[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/sct/overseerr/tree/develop/public) when configuring the application.)
+
+For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
+
+### User Key
+
+Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
+
+For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).
diff --git a/docs/using-overseerr/notifications/slack.md b/docs/using-overseerr/notifications/slack.md
new file mode 100644
index 00000000..5b9d0fd7
--- /dev/null
+++ b/docs/using-overseerr/notifications/slack.md
@@ -0,0 +1,7 @@
+# Slack
+
+## Configuration
+
+### Webhook URL
+
+Simply [create a webhook](https://catflixserver.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks) and enter the URL in this field.
diff --git a/docs/using-overseerr/notifications/telegram.md b/docs/using-overseerr/notifications/telegram.md
new file mode 100644
index 00000000..ddbd992e
--- /dev/null
+++ b/docs/using-overseerr/notifications/telegram.md
@@ -0,0 +1,38 @@
+# Telegram
+
+{% hint style="info" %}
+All notification types will be sent to the chat ID configured in your Overseerr application settings.
+
+If a user has configured a chat ID and has **Enable Notifications** checked in their Telegram notification user settings as well, they will be sent the following notification types for requests which they submit:
+
+- Media Approved (does not include automatic approvals)
+- Media Declined
+- Media Available
+
+{% endhint %}
+
+## Configuration
+
+{% hint style="info" %}
+In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
+
+Bots **cannot** initiate conversations with users, users must have your bot added to a conversation in order to receive notifications.
+{% endhint %}
+
+### Bot Username (optional)
+
+If this value is configured, users will be able to start a chat with your bot and configure their own personal notifications.
+
+The bot username should end with `_bot`, and the `@` prefix should be omitted.
+
+### Bot Authentication Token
+
+At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
+
+### Chat ID
+
+To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
+
+### Send Silently (optional)
+
+Instagram allows you to enable silent notifications. Those will present a pop-up to the user, but will not make any sound. That's a per user configuration.
diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md
index d1e91284..3717c2e4 100644
--- a/docs/using-overseerr/notifications/webhooks.md
+++ b/docs/using-overseerr/notifications/webhooks.md
@@ -1,10 +1,10 @@
-# Webhooks
+# Webhook
-Webhooks allow you to send a custom JSON payload to any endpoint. You can also set an authorization header for security purposes.
+The webhook notification agent allows you to send a custom JSON payload to any endpoint.
## Configuration
-### Webhook URL (required)
+### Webhook URL
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
@@ -16,7 +16,7 @@ This is typically not needed. Please refer to your webhook provider's documentat
This value will be sent as an `Authorization` HTTP header.
-### JSON Payload (required)
+### JSON Payload
Customize the JSON payload to suit your needs. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
@@ -31,24 +31,29 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
### User
-These variables are usually the target user of the notification.
+These variables are for the target recipient of the notification.
- `{{notifyuser_username}}` Target user's username.
-- `{{notifyuser_email}}` Target user's email.
-- `{{notifyuser_avatar}}` Target user's avatar.
-- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
-- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
+- `{{notifyuser_email}}` Target user's email address.
+- `{{notifyuser_avatar}}` Target user's avatar URL.
+- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
+- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
-### Media
+{% hint style="info" %}
+The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
-These variables are only included in media related notifications, such as requests.
+- Media Requested
+- Media Automatically Approved
+- Media Failed
-- `{{media_type}}` Media type. Either `movie` or `tv`.
-- `{{media_tmdbid}}` Media's TMDb ID.
-- `{{media_imdbid}}` Media's IMDb ID.
-- `{{media_tvdbid}}` Media's TVDB ID.
-- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
-- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
+On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
+
+- Media Approved
+- Media Declined
+- Media Available
+
+If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
+{% endhint %}
### Special
@@ -57,3 +62,25 @@ The following variables must be used as a key in the JSON payload (e.g., `"{{ext
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
+
+#### Media
+
+These `{{media}}` special variables are only included in media-related notifications, such as requests.
+
+- `{{media_type}}` Media type. Either `movie` or `tv`.
+- `{{media_tmdbid}}` Media's TMDb ID.
+- `{{media_imdbid}}` Media's IMDb ID.
+- `{{media_tvdbid}}` Media's TVDB ID.
+- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
+- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
+
+#### Request
+
+The `{{request}}` special variables are only included in request-related notifications.
+
+- `{{request_id}}` Request ID.
+- `{{requestedBy_username}}` Requesting user's username.
+- `{{requestedBy_email}}` Requesting user's email address.
+- `{{requestedBy_avatar}}` Requesting user's avatar URL.
+- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if one is set).
+- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if one is set).
diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md
index 9b4c5a2e..1753c944 100644
--- a/docs/using-overseerr/settings/README.md
+++ b/docs/using-overseerr/settings/README.md
@@ -38,18 +38,6 @@ If you enable this setting and find yourself unable to access Overseerr, you can
This setting is **disabled** by default.
-### Enable Image Caching
-
-{% hint style="danger" %}
-**This feature is experimental.** Enable it at your own risk!
-{% endhint %}
-
-When enabled, all images (including media posters from TMDb) will be cached locally on your server. Images will also be optimized for client devices; i.e., if you access Overseerr using a mobile device, smaller versions will be served compared to when accessing Overseerr on desktop.
-
-Note that this feature requires and will use a significant amount of disk space, and there is currently no automated deletion of old or expired images. If running Overseerr using Docker, it is possible to manually clear the image cache by simply removing and recreating the container.
-
-This setting is **disabled** by default.
-
### Discover Region & Discover Language
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
@@ -78,9 +66,21 @@ When disabled, Plex OAuth becomes the only sign-in option, and any "local users"
This setting is **enabled** by default.
+### Global Movie Request Limit & Global Series Request Limit
+
+Select the request limits you would like granted to users.
+
+Unless an [override](../users/README.md#movie-request-limit-and-series-request-limit) is configured, users are granted these global request limits.
+
+Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users.
+
### Default User Permissions
-Select the permissions you would like new users to have by default. It is important to set these, as any user with access to your Plex server will be able to log in to Overseerr, and they will be granted the permissions you select here.
+Select the permissions you would like assigned to new users to have by default upon account creation.
+
+It is important to configure this, as any user with access to your Plex server will be able to sign in to Overseerr, and they will be granted the permissions you select here upon first sign-in.
+
+This setting only affects new users, and has no impact on existing users. In order to modify permissions for existing users, you will need to [edit the users](../users/README.md#editing-users).
## Plex
diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md
new file mode 100644
index 00000000..bff049ba
--- /dev/null
+++ b/docs/using-overseerr/users/README.md
@@ -0,0 +1,75 @@
+# Users
+
+## Owner Account
+
+The user account created during Overseerr setup is the "Owner" account, which cannot be deleted or modified by other users. This account's credentials are used to authenticate with Plex.
+
+## Adding Users
+
+There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-user-permissions) defined in **Settings → Users**.
+
+### Importing Users from Plex
+
+Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
+
+Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-user-permissions) upon their first login.
+
+### Creating Local Users
+
+If you would like to grant Overseerr access to a user who doesn't have their own Plex account and/or access to the Plex server, you can manually add them by clicking the **Create Local User** button.
+
+#### Email Address
+
+Enter a valid email address at which the user can receive messages pertaining to their account and other notifications. The email address currently cannot be modified after the account is created.
+
+#### Automatically Generate Password
+
+If [email notifications](../notifications/email.md) have been configured and enabled, Overseerr can automatically generate a password for the new user.
+
+#### Password
+
+If you would prefer to manually configure a password, enter a password here that is a minimum of 8 characters.
+
+## Editing Users
+
+From the **User List**, you can click the **Edit** button to modify a particular user's settings.
+
+You can also click the check boxes and click the **Bulk Edit** button to set user permissions for multiple users at once.
+
+### General
+
+#### Display Name
+
+You can optionally set a "friendly name" for any user. This name will be used in lieu of their Plex username (for users imported from Plex) or their email address (for manually-created local users).
+
+#### Discover Region & Discover Language
+
+Users can override the [global filter settings](../settings/README.md#discover-region-and-discover-language) to suit their own preferences.
+
+#### Movie Request Limit & Series Request Limit
+
+You can override the default settings and assign different request limits for specific users by checking the **Enable Override** box and selecting the desired request limit and time period.
+
+Unless an override is configured, users are granted the global request limits.
+
+Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users.
+
+Users are also unable to modify their own request limits.
+
+### Password
+
+All "local users" are assigned passwords upon creation, but users imported from Plex can also optionally configure passwords to enable sign-in using their email address.
+
+Passwords must be a minimum of 8 characters long.
+
+### Notifications
+
+Users can configure their personal notification settings here. Please see [Notifications](../notifications/README.md) for details on configuring and enabling notifications.
+
+### Permissions
+
+Users cannot modify their own permissions. Users with the **Manage Users** permission can manage permissions of other users, except those of users with the **Admin** permission.
+
+## Deleting Users
+
+When users are deleted, all of their data and request history is also cleared from the database.
diff --git a/overseerr-api.yml b/overseerr-api.yml
index 0fdfdcea..08bf1b5c 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -92,17 +92,12 @@ components:
UserSettings:
type: object
properties:
- enableNotifications:
- type: boolean
- default: true
discordId:
type: string
- telegramChatId:
+ region:
+ type: string
+ language:
type: string
- telegramSendSilently:
- type: boolean
- required:
- - enableNotifications
MainSettings:
type: object
properties:
@@ -200,9 +195,6 @@ components:
message:
type: string
example: 'OK'
- host:
- type: string
- example: '127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct'
required:
- protocol
- address
@@ -441,6 +433,15 @@ components:
- is4k
- enableSeasonFolders
- isDefault
+ ServarrTag:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ label:
+ type: string
+ example: A Label
PublicSettings:
type: object
properties:
@@ -1195,12 +1196,6 @@ components:
type: string
priority:
type: number
- NotificationSettings:
- type: object
- properties:
- enabled:
- type: boolean
- example: true
NotificationEmailSettings:
type: object
properties:
@@ -1553,20 +1548,30 @@ components:
UserSettingsNotifications:
type: object
properties:
- enableNotifications:
+ notificationAgents:
+ type: number
+ example: 0
+ emailEnabled:
+ type: boolean
+ pgpKey:
+ type: string
+ nullable: true
+ discordEnabled:
type: boolean
- default: true
discordId:
type: string
nullable: true
+ telegramEnabled:
+ type: boolean
+ telegramBotUsername:
+ type: string
+ nullable: true
telegramChatId:
type: string
nullable: true
telegramSendSilently:
type: boolean
nullable: true
- required:
- - enableNotifications
securitySchemes:
cookieAuth:
type: apiKey
@@ -2300,37 +2305,6 @@ paths:
timestamp:
type: string
example: 2020-12-15T16:20:00.069Z
- /settings/notifications:
- get:
- summary: Return notification settings
- description: Returns current notification settings in a JSON object.
- tags:
- - settings
- responses:
- '200':
- description: Returned settings
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/NotificationSettings'
- post:
- summary: Update notification settings
- description: Updates notification settings with the provided values.
- tags:
- - settings
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/NotificationSettings'
- responses:
- '200':
- description: 'Values were sucessfully updated'
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/NotificationSettings'
/settings/notifications/email:
get:
summary: Get email notification settings
diff --git a/package.json b/package.json
index 4a79ed44..e7876f0a 100644
--- a/package.json
+++ b/package.json
@@ -17,10 +17,10 @@
},
"license": "MIT",
"dependencies": {
- "@headlessui/react": "^0.3.1",
+ "@headlessui/react": "^1.0.0",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
- "@tanem/react-nprogress": "^3.0.60",
+ "@tanem/react-nprogress": "^3.0.62",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
@@ -33,14 +33,14 @@
"csurf": "^1.11.0",
"email-templates": "^8.0.4",
"express": "^4.17.1",
- "express-openapi-validator": "^4.12.6",
+ "express-openapi-validator": "^4.12.7",
"express-rate-limit": "^5.2.6",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
- "next": "10.1.2",
+ "next": "10.1.3",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.5.0",
@@ -53,8 +53,8 @@
"react-animate-height": "^2.0.23",
"react-dom": "17.0.2",
"react-intersection-observer": "^8.31.0",
- "react-intl": "5.13.5",
- "react-markdown": "^5.0.3",
+ "react-intl": "5.15.8",
+ "react-markdown": "^6.0.0",
"react-select": "^4.3.0",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.3",
@@ -69,15 +69,15 @@
"typeorm": "^0.2.32",
"uuid": "^8.3.2",
"winston": "^3.3.3",
- "winston-daily-rotate-file": "^4.5.1",
+ "winston-daily-rotate-file": "^4.5.2",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.9"
},
"devDependencies": {
"@babel/cli": "^7.13.14",
- "@commitlint/cli": "^12.0.1",
- "@commitlint/config-conventional": "^12.0.1",
+ "@commitlint/cli": "^12.1.1",
+ "@commitlint/config-conventional": "^12.1.1",
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/exec": "^5.0.0",
@@ -85,22 +85,22 @@
"@tailwindcss/aspect-ratio": "^0.2.0",
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.0",
- "@types/bcrypt": "^3.0.0",
+ "@types/bcrypt": "^3.0.1",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
- "@types/csurf": "^1.11.0",
- "@types/email-templates": "^8.0.2",
+ "@types/csurf": "^1.11.1",
+ "@types/email-templates": "^8.0.3",
"@types/express": "^4.17.11",
"@types/express-rate-limit": "^5.1.1",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
- "@types/node": "^14.14.37",
+ "@types/node": "^14.14.41",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.1",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
- "@types/react-select": "^4.0.13",
+ "@types/react-select": "^4.0.15",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-transition-group": "^4.4.1",
"@types/secure-random-password": "^0.2.0",
@@ -109,32 +109,32 @@
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
- "@typescript-eslint/eslint-plugin": "^4.20.0",
- "@typescript-eslint/parser": "^4.20.0",
+ "@typescript-eslint/eslint-plugin": "^4.22.0",
+ "@typescript-eslint/parser": "^4.22.0",
"autoprefixer": "^10.2.5",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.3",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
- "eslint": "^7.23.0",
- "eslint-config-prettier": "^8.1.0",
- "eslint-plugin-formatjs": "^2.14.3",
+ "eslint": "^7.24.0",
+ "eslint-config-prettier": "^8.2.0",
+ "eslint-plugin-formatjs": "^2.14.6",
"eslint-plugin-jsx-a11y": "^6.4.1",
- "eslint-plugin-prettier": "^3.3.1",
- "eslint-plugin-react": "^7.23.1",
+ "eslint-plugin-prettier": "^3.4.0",
+ "eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "4.3.8",
"lint-staged": "^10.5.4",
"nodemon": "^2.0.7",
- "postcss": "^8.2.9",
+ "postcss": "^8.2.10",
"prettier": "^2.2.1",
"semantic-release": "^17.4.2",
"semantic-release-docker-buildx": "^1.0.1",
- "tailwindcss": "^2.0.4",
+ "tailwindcss": "^2.1.1",
"ts-node": "^9.1.1",
- "typescript": "^4.2.3"
+ "typescript": "^4.2.4"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
diff --git a/public/preview.jpg b/public/preview.jpg
index 8abdaa1e..946ef07a 100644
Binary files a/public/preview.jpg and b/public/preview.jpg differ
diff --git a/server/api/github.ts b/server/api/github.ts
new file mode 100644
index 00000000..48b8854b
--- /dev/null
+++ b/server/api/github.ts
@@ -0,0 +1,133 @@
+import cacheManager from '../lib/cache';
+import logger from '../logger';
+import ExternalAPI from './externalapi';
+
+interface GitHubRelease {
+ url: string;
+ assets_url: string;
+ upload_url: string;
+ html_url: string;
+ id: number;
+ node_id: string;
+ tag_name: string;
+ target_commitish: string;
+ name: string;
+ draft: boolean;
+ prerelease: boolean;
+ created_at: string;
+ published_at: string;
+ tarball_url: string;
+ zipball_url: string;
+ body: string;
+}
+
+interface GithubCommit {
+ sha: string;
+ node_id: string;
+ commit: {
+ author: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ committer: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ message: string;
+ tree: {
+ sha: string;
+ url: string;
+ };
+ url: string;
+ comment_count: number;
+ verification: {
+ verified: boolean;
+ reason: string;
+ signature: string;
+ payload: string;
+ };
+ };
+ url: string;
+ html_url: string;
+ comments_url: string;
+ parents: [
+ {
+ sha: string;
+ url: string;
+ html_url: string;
+ }
+ ];
+}
+
+class GithubAPI extends ExternalAPI {
+ constructor() {
+ super(
+ 'https://api.github.com',
+ {},
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ nodeCache: cacheManager.getCache('github').data,
+ }
+ );
+ }
+
+ public async getOverseerrReleases({
+ take = 20,
+ }: {
+ take?: number;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/repos/sct/overseerr/releases',
+ {
+ params: {
+ per_page: take,
+ },
+ }
+ );
+
+ return data;
+ } catch (e) {
+ logger.warn(
+ "Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
+ { label: 'GitHub API', errorMessage: e.message }
+ );
+ return [];
+ }
+ }
+
+ public async getOverseerrCommits({
+ take = 20,
+ branch = 'develop',
+ }: {
+ take?: number;
+ branch?: string;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/repos/sct/overseerr/commits',
+ {
+ params: {
+ per_page: take,
+ branch,
+ },
+ }
+ );
+
+ return data;
+ } catch (e) {
+ logger.warn(
+ "Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
+ { label: 'GitHub API', errorMessage: e.message }
+ );
+ return [];
+ }
+ }
+}
+
+export default GithubAPI;
diff --git a/server/api/plextv.ts b/server/api/plextv.ts
index 5d93f956..9efcecc2 100644
--- a/server/api/plextv.ts
+++ b/server/api/plextv.ts
@@ -91,7 +91,7 @@ interface FriendResponse {
email: string;
thumb: string;
};
- Server: ServerResponse[];
+ Server?: ServerResponse[];
}[];
};
}
@@ -232,7 +232,7 @@ class PlexTvAPI {
);
}
- return !!user.Server.find(
+ return !!user.Server?.find(
(server) => server.$.machineIdentifier === settings.plex.machineId
);
} catch (e) {
diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts
new file mode 100644
index 00000000..75f138b5
--- /dev/null
+++ b/server/api/servarr/base.ts
@@ -0,0 +1,169 @@
+import cacheManager, { AvailableCacheIds } from '../../lib/cache';
+import { DVRSettings } from '../../lib/settings';
+import ExternalAPI from '../externalapi';
+
+export interface RootFolder {
+ id: number;
+ path: string;
+ freeSpace: number;
+ totalSpace: number;
+ unmappedFolders: {
+ name: string;
+ path: string;
+ }[];
+}
+
+export interface QualityProfile {
+ id: number;
+ name: string;
+}
+
+interface QueueItem {
+ size: number;
+ title: string;
+ sizeleft: number;
+ timeleft: string;
+ estimatedCompletionTime: string;
+ status: string;
+ trackedDownloadStatus: string;
+ trackedDownloadState: string;
+ downloadId: string;
+ protocol: string;
+ downloadClient: string;
+ indexer: string;
+ id: number;
+}
+
+export interface Tag {
+ id: number;
+ label: string;
+}
+
+interface QueueResponse {
+ page: number;
+ pageSize: number;
+ sortKey: string;
+ sortDirection: string;
+ totalRecords: number;
+ records: (QueueItem & QueueItemAppendT)[];
+}
+
+class ServarrBase extends ExternalAPI {
+ static buildUrl(settings: DVRSettings, path?: string): string {
+ return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
+ settings.port
+ }${settings.baseUrl ?? ''}${path}`;
+ }
+
+ protected apiName: string;
+
+ constructor({
+ url,
+ apiKey,
+ cacheName,
+ apiName,
+ }: {
+ url: string;
+ apiKey: string;
+ cacheName: AvailableCacheIds;
+ apiName: string;
+ }) {
+ super(
+ url,
+ {
+ apikey: apiKey,
+ },
+ {
+ nodeCache: cacheManager.getCache(cacheName).data,
+ }
+ );
+
+ this.apiName = apiName;
+ }
+
+ public getProfiles = async (): Promise => {
+ try {
+ const data = await this.getRolling(
+ `/qualityProfile`,
+ undefined,
+ 3600
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve profiles: ${e.message}`
+ );
+ }
+ };
+
+ public getRootFolders = async (): Promise => {
+ try {
+ const data = await this.getRolling(
+ `/rootfolder`,
+ undefined,
+ 3600
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve root folders: ${e.message}`
+ );
+ }
+ };
+
+ public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
+ try {
+ const response = await this.axios.get>(
+ `/queue`
+ );
+
+ return response.data.records;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve queue: ${e.message}`
+ );
+ }
+ };
+
+ public getTags = async (): Promise => {
+ try {
+ const response = await this.axios.get(`/tag`);
+
+ return response.data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve tags: ${e.message}`
+ );
+ }
+ };
+
+ public createTag = async ({ label }: { label: string }): Promise => {
+ try {
+ const response = await this.axios.post(`/tag`, {
+ label,
+ });
+
+ return response.data;
+ } catch (e) {
+ throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
+ }
+ };
+
+ protected async runCommand(
+ commandName: string,
+ options: Record
+ ): Promise {
+ try {
+ await this.axios.post(`/command`, {
+ name: commandName,
+ ...options,
+ });
+ } catch (e) {
+ throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
+ }
+ }
+}
+
+export default ServarrBase;
diff --git a/server/api/radarr.ts b/server/api/servarr/radarr.ts
similarity index 68%
rename from server/api/radarr.ts
rename to server/api/servarr/radarr.ts
index 187a52ba..59407720 100644
--- a/server/api/radarr.ts
+++ b/server/api/servarr/radarr.ts
@@ -1,12 +1,11 @@
-import cacheManager from '../lib/cache';
-import { RadarrSettings } from '../lib/settings';
-import logger from '../logger';
-import ExternalAPI from './externalapi';
+import logger from '../../logger';
+import ServarrBase from './base';
interface RadarrMovieOptions {
title: string;
qualityProfileId: number;
minimumAvailability: string;
+ tags: number[];
profileId: number;
year: number;
rootFolderPath: string;
@@ -32,65 +31,9 @@ export interface RadarrMovie {
hasFile: boolean;
}
-export interface RadarrRootFolder {
- id: number;
- path: string;
- freeSpace: number;
- totalSpace: number;
- unmappedFolders: {
- name: string;
- path: string;
- }[];
-}
-
-export interface RadarrProfile {
- id: number;
- name: string;
-}
-
-interface QueueItem {
- movieId: number;
- size: number;
- title: string;
- sizeleft: number;
- timeleft: string;
- estimatedCompletionTime: string;
- status: string;
- trackedDownloadStatus: string;
- trackedDownloadState: string;
- downloadId: string;
- protocol: string;
- downloadClient: string;
- indexer: string;
- id: number;
-}
-
-interface QueueResponse {
- page: number;
- pageSize: number;
- sortKey: string;
- sortDirection: string;
- totalRecords: number;
- records: QueueItem[];
-}
-
-class RadarrAPI extends ExternalAPI {
- static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
- return `${radarrSettings.useSsl ? 'https' : 'http'}://${
- radarrSettings.hostname
- }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
- }
-
+class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
- super(
- url,
- {
- apikey: apiKey,
- },
- {
- nodeCache: cacheManager.getCache('radarr').data,
- }
- );
+ super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
}
public getMovies = async (): Promise => {
@@ -162,6 +105,7 @@ class RadarrAPI extends ExternalAPI {
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
+ tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
@@ -206,6 +150,7 @@ class RadarrAPI extends ExternalAPI {
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
+ tags: options.tags,
addOptions: {
searchForMovie: options.searchNow,
},
@@ -238,44 +183,6 @@ class RadarrAPI extends ExternalAPI {
throw new Error('Failed to add movie to Radarr');
}
};
-
- public getProfiles = async (): Promise => {
- try {
- const data = await this.getRolling(
- `/qualityProfile`,
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
- }
- };
-
- public getRootFolders = async (): Promise => {
- try {
- const data = await this.getRolling(
- `/rootfolder`,
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
- }
- };
-
- public getQueue = async (): Promise => {
- try {
- const response = await this.axios.get(`/queue`);
-
- return response.data.records;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
- }
- };
}
export default RadarrAPI;
diff --git a/server/api/sonarr.ts b/server/api/servarr/sonarr.ts
similarity index 69%
rename from server/api/sonarr.ts
rename to server/api/servarr/sonarr.ts
index e2e8bd19..12337839 100644
--- a/server/api/sonarr.ts
+++ b/server/api/servarr/sonarr.ts
@@ -1,7 +1,5 @@
-import cacheManager from '../lib/cache';
-import { SonarrSettings } from '../lib/settings';
-import logger from '../logger';
-import ExternalAPI from './externalapi';
+import logger from '../../logger';
+import ServarrBase from './base';
interface SonarrSeason {
seasonNumber: number;
@@ -49,7 +47,7 @@ export interface SonarrSeries {
titleSlug: string;
certification: string;
genres: string[];
- tags: string[];
+ tags: number[];
added: string;
ratings: {
votes: number;
@@ -65,49 +63,6 @@ export interface SonarrSeries {
};
}
-interface QueueItem {
- seriesId: number;
- episodeId: number;
- size: number;
- title: string;
- sizeleft: number;
- timeleft: string;
- estimatedCompletionTime: string;
- status: string;
- trackedDownloadStatus: string;
- trackedDownloadState: string;
- downloadId: string;
- protocol: string;
- downloadClient: string;
- indexer: string;
- id: number;
-}
-
-interface QueueResponse {
- page: number;
- pageSize: number;
- sortKey: string;
- sortDirection: string;
- totalRecords: number;
- records: QueueItem[];
-}
-
-interface SonarrProfile {
- id: number;
- name: string;
-}
-
-interface SonarrRootFolder {
- id: number;
- path: string;
- freeSpace: number;
- totalSpace: number;
- unmappedFolders: {
- name: string;
- path: string;
- }[];
-}
-
interface AddSeriesOptions {
tvdbid: number;
title: string;
@@ -116,6 +71,7 @@ interface AddSeriesOptions {
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
+ tags?: number[];
seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
searchNow?: boolean;
@@ -126,23 +82,9 @@ export interface LanguageProfile {
name: string;
}
-class SonarrAPI extends ExternalAPI {
- static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
- return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
- sonarrSettings.hostname
- }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
- }
-
+class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
- super(
- url,
- {
- apikey: apiKey,
- },
- {
- nodeCache: cacheManager.getCache('sonarr').data,
- }
- );
+ super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
public async getSeries(): Promise {
@@ -151,7 +93,7 @@ class SonarrAPI extends ExternalAPI {
return response.data;
} catch (e) {
- throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
+ throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
}
}
@@ -205,6 +147,7 @@ class SonarrAPI extends ExternalAPI {
// If the series already exists, we will simply just update it
if (series.id) {
+ series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesResponse = await this.axios.put(
@@ -249,6 +192,7 @@ class SonarrAPI extends ExternalAPI {
monitored: false,
}))
),
+ tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
@@ -286,46 +230,6 @@ class SonarrAPI extends ExternalAPI {
}
}
- public async getProfiles(): Promise {
- try {
- const data = await this.getRolling(
- '/qualityProfile',
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- logger.error('Something went wrong while retrieving Sonarr profiles.', {
- label: 'Sonarr API',
- message: e.message,
- });
- throw new Error('Failed to get profiles');
- }
- }
-
- public async getRootFolders(): Promise {
- try {
- const data = await this.getRolling(
- '/rootfolder',
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- logger.error(
- 'Something went wrong while retrieving Sonarr root folders.',
- {
- label: 'Sonarr API',
- message: e.message,
- }
- );
-
- throw new Error('Failed to get root folders');
- }
- }
-
public async getLanguageProfiles(): Promise {
try {
const data = await this.getRolling(
@@ -356,25 +260,6 @@ class SonarrAPI extends ExternalAPI {
await this.runCommand('SeriesSearch', { seriesId });
}
- private async runCommand(
- commandName: string,
- options: Record
- ): Promise {
- try {
- await this.axios.post(`/command`, {
- name: commandName,
- ...options,
- });
- } catch (e) {
- logger.error('Something went wrong attempting to run a Sonarr command.', {
- label: 'Sonarr API',
- message: e.message,
- });
-
- throw new Error('Failed to run Sonarr command.');
- }
- }
-
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]
@@ -399,16 +284,6 @@ class SonarrAPI extends ExternalAPI {
return newSeasons;
}
-
- public getQueue = async (): Promise => {
- try {
- const response = await this.axios.get(`/queue`);
-
- return response.data.records;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
- }
- };
}
export default SonarrAPI;
diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts
index c52e971b..3436e1bd 100644
--- a/server/api/themoviedb/index.ts
+++ b/server/api/themoviedb/index.ts
@@ -715,7 +715,34 @@ class TheMovieDb extends ExternalAPI {
86400 // 24 hours
);
- const movieGenres = sortBy(data.genres, 'name');
+ if (
+ !language.startsWith('en') &&
+ data.genres.some((genre) => !genre.name)
+ ) {
+ const englishData = await this.get(
+ '/genre/movie/list',
+ {
+ params: {
+ language: 'en',
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ data.genres
+ .filter((genre) => !genre.name)
+ .forEach((genre) => {
+ genre.name =
+ englishData.genres.find(
+ (englishGenre) => englishGenre.id === genre.id
+ )?.name ?? '';
+ });
+ }
+
+ const movieGenres = sortBy(
+ data.genres.filter((genre) => genre.name),
+ 'name'
+ );
return movieGenres;
} catch (e) {
@@ -739,7 +766,34 @@ class TheMovieDb extends ExternalAPI {
86400 // 24 hours
);
- const tvGenres = sortBy(data.genres, 'name');
+ if (
+ !language.startsWith('en') &&
+ data.genres.some((genre) => !genre.name)
+ ) {
+ const englishData = await this.get(
+ '/genre/tv/list',
+ {
+ params: {
+ language: 'en',
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ data.genres
+ .filter((genre) => !genre.name)
+ .forEach((genre) => {
+ genre.name =
+ englishData.genres.find(
+ (englishGenre) => englishGenre.id === genre.id
+ )?.name ?? '';
+ });
+ }
+
+ const tvGenres = sortBy(
+ data.genres.filter((genre) => genre.name),
+ 'name'
+ );
return tvGenres;
} catch (e) {
diff --git a/server/entity/Media.ts b/server/entity/Media.ts
index 9ca195c6..3d821651 100644
--- a/server/entity/Media.ts
+++ b/server/entity/Media.ts
@@ -1,23 +1,23 @@
import {
- Entity,
- PrimaryGeneratedColumn,
+ AfterLoad,
Column,
- Index,
- OneToMany,
CreateDateColumn,
- UpdateDateColumn,
+ Entity,
getRepository,
In,
- AfterLoad,
+ Index,
+ OneToMany,
+ PrimaryGeneratedColumn,
+ UpdateDateColumn,
} from 'typeorm';
-import { MediaRequest } from './MediaRequest';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
-import logger from '../logger';
-import Season from './Season';
-import { getSettings } from '../lib/settings';
-import RadarrAPI from '../api/radarr';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
-import SonarrAPI from '../api/sonarr';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import { MediaRequest } from './MediaRequest';
+import Season from './Season';
@Entity()
class Media {
@@ -168,10 +168,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
- : RadarrAPI.buildRadarrUrl(
- server,
- `/movie/${this.externalServiceSlug}`
- );
+ : RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
@@ -184,7 +181,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
- : RadarrAPI.buildRadarrUrl(
+ : RadarrAPI.buildUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
@@ -202,10 +199,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
- : SonarrAPI.buildSonarrUrl(
- server,
- `/series/${this.externalServiceSlug}`
- );
+ : SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`);
}
}
@@ -218,7 +212,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
- : SonarrAPI.buildSonarrUrl(
+ : SonarrAPI.buildUrl(
server,
`/series/${this.externalServiceSlug4k}`
);
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index 6e5c1135..167d1db0 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -1,28 +1,29 @@
+import { isEqual } from 'lodash';
import {
- Entity,
- PrimaryGeneratedColumn,
- ManyToOne,
+ AfterInsert,
+ AfterRemove,
+ AfterUpdate,
Column,
CreateDateColumn,
- UpdateDateColumn,
- AfterUpdate,
- AfterInsert,
+ Entity,
getRepository,
+ ManyToOne,
OneToMany,
- AfterRemove,
+ PrimaryGeneratedColumn,
RelationCount,
+ UpdateDateColumn,
} from 'typeorm';
-import { User } from './User';
-import Media from './Media';
-import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
-import { getSettings } from '../lib/settings';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
-import RadarrAPI from '../api/radarr';
-import logger from '../logger';
-import SeasonRequest from './SeasonRequest';
-import SonarrAPI, { SonarrSeries } from '../api/sonarr';
+import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import notificationManager, { Notification } from '../lib/notifications';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import Media from './Media';
+import SeasonRequest from './SeasonRequest';
+import { User } from './User';
@Entity()
export class MediaRequest {
@@ -85,6 +86,37 @@ export class MediaRequest {
@Column({ nullable: true })
public languageProfileId: number;
+ @Column({
+ type: 'text',
+ nullable: true,
+ transformer: {
+ from: (value: string | null): number[] | null => {
+ if (value) {
+ if (value === 'none') {
+ return [];
+ }
+ return value.split(',').map((v) => Number(v));
+ }
+ return null;
+ },
+ to: (value: number[] | null): string | null => {
+ if (value) {
+ const finalValue = value.join(',');
+
+ // We want to keep the actual state of an "empty array" so we use
+ // the keyword "none" to track this.
+ if (!finalValue) {
+ return 'none';
+ }
+
+ return finalValue;
+ }
+ return null;
+ },
+ },
+ })
+ public tags?: number[];
+
constructor(init?: Partial) {
Object.assign(this, init);
}
@@ -113,7 +145,6 @@ export class MediaRequest {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
- notifyUser: this.requestedBy,
media,
request: this,
});
@@ -125,7 +156,6 @@ export class MediaRequest {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
- notifyUser: this.requestedBy,
media,
extra: [
{
@@ -200,7 +230,7 @@ export class MediaRequest {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
- notifyUser: this.requestedBy,
+ notifyUser: autoApproved ? undefined : this.requestedBy,
media,
extra: [
{
@@ -329,7 +359,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
- 'Skipped radarr request as there is no radarr configured',
+ 'Skipped Radarr request as there is no Radarr server configured',
{ label: 'Media Request' }
);
return;
@@ -357,7 +387,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
- }radarr configured. Did you set any of your Radarr servers as default?`,
+ }Radarr server configured. Did you set any of your ${
+ this.is4k ? '4K ' : ''
+ }Radarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -365,6 +397,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
+ let tags = radarrSettings.tags;
if (
this.rootFolder &&
@@ -387,10 +420,18 @@ export class MediaRequest {
});
}
+ if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
+ tags = this.tags;
+ logger.info(`Request has override tags`, {
+ label: 'Media Request',
+ tagIds: tags,
+ });
+ }
+
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -420,6 +461,7 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
+ tags,
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
@@ -459,7 +501,7 @@ export class MediaRequest {
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
- const errorMessage = `Request failed to send to radarr: ${e.message}`;
+ const errorMessage = `Request failed to send to Radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
@@ -479,7 +521,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.info(
- 'Skipped sonarr request as there is no sonarr configured',
+ 'Skipped Sonarr request as there is no Sonarr server configured',
{ label: 'Media Request' }
);
return;
@@ -507,7 +549,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
- }sonarr configured. Did you set any of your Sonarr servers as default?`,
+ }Sonarr server configured. Did you set any of your ${
+ this.is4k ? '4K ' : ''
+ }Sonarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -531,7 +575,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
- url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
+ url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
@@ -568,6 +612,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
+ let tags =
+ seriesType === 'anime'
+ ? sonarrSettings.animeTags
+ : sonarrSettings.tags;
+
if (
this.rootFolder &&
this.rootFolder !== '' &&
@@ -599,6 +648,14 @@ export class MediaRequest {
);
}
+ if (this.tags && !isEqual(this.tags, tags)) {
+ tags = this.tags;
+ logger.info(`Request has override tags`, {
+ label: 'Media Request',
+ tagIds: tags,
+ });
+ }
+
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
@@ -610,6 +667,7 @@ export class MediaRequest {
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
+ tags,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})
@@ -659,7 +717,7 @@ export class MediaRequest {
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
- const errorMessage = `Request failed to send to sonarr: ${e.message}`;
+ const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
diff --git a/server/entity/User.ts b/server/entity/User.ts
index db5fa950..25b57f71 100644
--- a/server/entity/User.ts
+++ b/server/entity/User.ts
@@ -157,7 +157,8 @@ export class User {
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
- const email = new PreparedEmail();
+
+ const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
@@ -193,7 +194,7 @@ export class User {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
- const email = new PreparedEmail();
+ const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
@@ -236,11 +237,9 @@ export class User {
const movieDate = new Date();
if (movieQuotaDays) {
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
- } else {
- movieDate.setDate(0);
}
- // YYYY-MM-DD format
- const movieQuotaStartDate = movieDate.toJSON().split('T')[0];
+ const movieQuotaStartDate = movieDate.toJSON();
+
const movieQuotaUsed = movieQuotaLimit
? await requestRepository.count({
where: {
@@ -261,11 +260,8 @@ export class User {
const tvDate = new Date();
if (tvQuotaDays) {
tvDate.setDate(tvDate.getDate() - tvQuotaDays);
- } else {
- tvDate.setDate(0);
}
- // YYYY-MM-DD format
- const tvQuotaStartDate = tvDate.toJSON().split('T')[0];
+ const tvQuotaStartDate = tvDate.toJSON();
const tvQuotaUsed = tvQuotaLimit
? (
await requestRepository
diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts
index 8e60865a..023a1bde 100644
--- a/server/entity/UserSettings.ts
+++ b/server/entity/UserSettings.ts
@@ -5,6 +5,10 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
+import {
+ hasNotificationAgentEnabled,
+ NotificationAgentType,
+} from '../lib/notifications/agenttypes';
import { User } from './User';
@Entity()
@@ -20,8 +24,17 @@ export class UserSettings {
@JoinColumn()
public user: User;
- @Column({ default: true })
- public enableNotifications: boolean;
+ @Column({ nullable: true })
+ public region?: string;
+
+ @Column({ nullable: true })
+ public originalLanguage?: string;
+
+ @Column({ type: 'integer', default: NotificationAgentType.EMAIL })
+ public notificationAgents = NotificationAgentType.EMAIL;
+
+ @Column({ nullable: true })
+ public pgpKey?: string;
@Column({ nullable: true })
public discordId?: string;
@@ -32,12 +45,7 @@ export class UserSettings {
@Column({ nullable: true })
public telegramSendSilently?: boolean;
- @Column({ nullable: true })
- public region?: string;
-
- @Column({ nullable: true })
- public originalLanguage?: string;
-
- @Column({ nullable: true })
- public pgpKey?: string;
+ public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean {
+ return !!hasNotificationAgentEnabled(agent, this.notificationAgents);
+ }
}
diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts
index 42ec9cb4..5373cb58 100644
--- a/server/interfaces/api/plexInterfaces.ts
+++ b/server/interfaces/api/plexInterfaces.ts
@@ -14,7 +14,6 @@ export interface PlexConnection {
local: boolean;
status?: number;
message?: string;
- host?: string;
}
export interface PlexDevice {
diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts
index 3bfa289e..1188f24c 100644
--- a/server/interfaces/api/serviceInterfaces.ts
+++ b/server/interfaces/api/serviceInterfaces.ts
@@ -1,5 +1,5 @@
-import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
-import { LanguageProfile } from '../../api/sonarr';
+import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
+import { LanguageProfile } from '../../api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;
@@ -12,11 +12,14 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
+ activeTags: number[];
+ activeAnimeTags?: number[];
}
export interface ServiceCommonServerWithDetails {
server: ServiceCommonServer;
- profiles: RadarrProfile[];
- rootFolders: Partial[];
+ profiles: QualityProfile[];
+ rootFolders: Partial[];
languageProfiles?: LanguageProfile[];
+ tags: Tag[];
}
diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts
index 72ac9b8a..7c40c6db 100644
--- a/server/interfaces/api/settingsInterfaces.ts
+++ b/server/interfaces/api/settingsInterfaces.ts
@@ -43,3 +43,10 @@ export interface CacheItem {
vsize: number;
};
}
+
+export interface StatusResponse {
+ version: string;
+ commitTag: string;
+ updateAvailable: boolean;
+ commitsBehind: number;
+}
diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts
index e6d0302f..006facf0 100644
--- a/server/interfaces/api/userSettingsInterfaces.ts
+++ b/server/interfaces/api/userSettingsInterfaces.ts
@@ -13,10 +13,13 @@ export interface UserSettingsGeneralResponse {
}
export interface UserSettingsNotificationsResponse {
- enableNotifications: boolean;
- telegramBotUsername?: string;
+ notificationAgents: number;
+ emailEnabled?: boolean;
+ pgpKey?: string;
+ discordEnabled?: boolean;
discordId?: string;
+ telegramEnabled?: boolean;
+ telegramBotUsername?: string;
telegramChatId?: string;
telegramSendSilently?: boolean;
- pgpKey?: string;
}
diff --git a/server/lib/cache.ts b/server/lib/cache.ts
index aaf3bd44..3aa18244 100644
--- a/server/lib/cache.ts
+++ b/server/lib/cache.ts
@@ -1,6 +1,6 @@
import NodeCache from 'node-cache';
-export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
+export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'github';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -44,6 +44,10 @@ class CacheManager {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
+ github: new Cache('github', 'GitHub API', {
+ stdTtl: 21600,
+ checkPeriod: 60 * 30,
+ }),
};
public getCache(id: AvailableCacheIds): Cache {
diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts
index 9faf411a..33282285 100644
--- a/server/lib/downloadtracker.ts
+++ b/server/lib/downloadtracker.ts
@@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
-import RadarrAPI from '../api/radarr';
-import SonarrAPI from '../api/sonarr';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
@@ -73,7 +73,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
- url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
+ url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
@@ -140,7 +140,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
- url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
+ url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts
index abbc1632..f9c0c747 100644
--- a/server/lib/email/index.ts
+++ b/server/lib/email/index.ts
@@ -1,11 +1,10 @@
-import nodemailer from 'nodemailer';
import Email from 'email-templates';
-import { getSettings } from '../settings';
+import nodemailer from 'nodemailer';
+import { NotificationAgentEmail } from '../settings';
import { openpgpEncrypt } from './openpgpEncrypt';
-class PreparedEmail extends Email {
- public constructor(pgpKey?: string) {
- const settings = getSettings().notifications.agents.email;
+class PreparedEmail extends Email {
+ public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
const transport = nodemailer.createTransport({
host: settings.options.smtpHost,
port: settings.options.smtpPort,
diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts
index cefde186..c04b4948 100644
--- a/server/lib/notifications/agents/discord.ts
+++ b/server/lib/notifications/agents/discord.ts
@@ -1,7 +1,11 @@
import axios from 'axios';
+import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
+import { User } from '../../../entity/User';
import logger from '../../../logger';
+import { Permission } from '../../permissions';
import { getSettings, NotificationAgentDiscord } from '../../settings';
+import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
@@ -107,7 +111,7 @@ class DiscordAgent
if (payload.request) {
fields.push({
name: 'Requested By',
- value: payload.request?.requestedBy.displayName ?? '',
+ value: payload.request.requestedBy.displayName,
inline: true,
});
}
@@ -201,7 +205,14 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Discord notification', { label: 'Notifications' });
+ logger.debug('Sending Discord notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ let content = undefined;
+
try {
const {
botUsername,
@@ -213,16 +224,32 @@ class DiscordAgent
return false;
}
- const mentionedUsers: string[] = [];
- let content = undefined;
+ if (payload.notifyUser) {
+ // Mention user who submitted the request
+ if (
+ payload.notifyUser.settings?.hasNotificationAgentEnabled(
+ NotificationAgentType.DISCORD
+ ) &&
+ payload.notifyUser.settings?.discordId
+ ) {
+ content = `<@${payload.notifyUser.settings.discordId}>`;
+ }
+ } else {
+ // Mention all users with the Manage Requests permission
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
- if (
- payload.notifyUser &&
- (payload.notifyUser.settings?.enableNotifications ?? true) &&
- payload.notifyUser.settings?.discordId
- ) {
- mentionedUsers.push(payload.notifyUser.settings.discordId);
- content = `<@${payload.notifyUser.settings.discordId}>`;
+ content = users
+ .filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ user.settings?.hasNotificationAgentEnabled(
+ NotificationAgentType.DISCORD
+ ) &&
+ user.settings?.discordId
+ )
+ .map((user) => `<@${user.settings?.discordId}>`)
+ .join(' ');
}
await axios.post(webhookUrl, {
@@ -230,18 +257,19 @@ class DiscordAgent
avatar_url: botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
- allowed_mentions: {
- users: mentionedUsers,
- },
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
- message: e.message,
+ mentions: content,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
response: e.response.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts
index ea6b02ef..4d00eb6f 100644
--- a/server/lib/notifications/agents/email.ts
+++ b/server/lib/notifications/agents/email.ts
@@ -1,3 +1,4 @@
+import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
@@ -7,6 +8,7 @@ import logger from '../../../logger';
import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import { getSettings, NotificationAgentEmail } from '../../settings';
+import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
@@ -35,379 +37,194 @@ class EmailAgent
return false;
}
- private async sendMediaRequestEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
+ private buildMessage(
+ type: Notification,
+ payload: NotificationPayload,
+ toEmail: string
+ ): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
- // Send to all users with the manage requests permission (or admins)
- users
- .filter(
- (user) =>
- user.hasPermission(Permission.MANAGE_REQUESTS) &&
- (user.settings?.enableNotifications ?? true)
- )
- .forEach((user) => {
- const email = new PreparedEmail(user.settings?.pgpKey);
-
- email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: user.email,
- },
- locals: {
- body: `A user has requested a new ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- }!`,
- mediaName: payload.subject,
- mediaPlot: payload.message,
- mediaExtra: payload.extra ?? [],
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `New ${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Request`,
- },
- });
- });
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
+ if (type === Notification.TEST_NOTIFICATION) {
+ return {
+ template: path.join(__dirname, '../../../templates/email/test-email'),
+ message: {
+ to: toEmail,
+ },
+ locals: {
+ body: payload.message,
+ applicationUrl,
+ applicationTitle,
+ },
+ };
}
- }
- private async sendMediaFailedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
+ if (payload.media) {
+ let requestType = '';
+ let body = '';
- // Send to all users with the manage requests permission (or admins)
- users
- .filter(
- (user) =>
- user.hasPermission(Permission.MANAGE_REQUESTS) &&
- (user.settings?.enableNotifications ?? true)
- )
- .forEach((user) => {
- const email = new PreparedEmail(user.settings?.pgpKey);
-
- email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: user.email,
- },
- locals: {
- body: `A new request for the following ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- } could not be added to ${
- payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
- }:`,
- mediaName: payload.subject,
- mediaPlot: payload.message,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `Failed ${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Request`,
- },
- });
- });
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaApprovedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- if (
- payload.notifyUser &&
- (payload.notifyUser.settings?.enableNotifications ?? true)
- ) {
- const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: `Your request for the following ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- } has been approved:`,
- mediaName: payload.subject,
- mediaExtra: payload.extra ?? [],
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Request Approved`,
- },
- });
+ switch (type) {
+ case Notification.MEDIA_PENDING:
+ requestType = `New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
+ body = `A user has requested a new ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ }!`;
+ break;
+ case Notification.MEDIA_APPROVED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved`;
+ body = `Your request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } has been approved:`;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved`;
+ body = `A new request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } has been automatically approved:`;
+ break;
+ case Notification.MEDIA_AVAILABLE:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available`;
+ body = `The following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } you requested is now available!`;
+ break;
+ case Notification.MEDIA_DECLINED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined`;
+ body = `Your request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } was declined:`;
+ break;
+ case Notification.MEDIA_FAILED:
+ requestType = `Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
+ body = `A new request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } could not be added to ${
+ payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
+ }:`;
+ break;
}
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
+ return {
+ template: path.join(
+ __dirname,
+ '../../../templates/email/media-request'
+ ),
+ message: {
+ to: toEmail,
+ },
+ locals: {
+ requestType,
+ body,
+ mediaName: payload.subject,
+ mediaPlot: payload.message,
+ mediaExtra: payload.extra ?? [],
+ imageUrl: payload.image,
+ timestamp: new Date().toTimeString(),
+ requestedBy: payload.request?.requestedBy.displayName,
+ actionUrl: applicationUrl
+ ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
+ : undefined,
+ applicationUrl,
+ applicationTitle,
+ },
+ };
}
- }
- private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
-
- // Send to all users with the manage requests permission (or admins)
- users
- .filter(
- (user) =>
- user.hasPermission(Permission.MANAGE_REQUESTS) &&
- (user.settings?.enableNotifications ?? true)
- )
- .forEach((user) => {
- const email = new PreparedEmail();
-
- email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: user.email,
- },
- locals: {
- body: `A new request for the following ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- } has been automatically approved:`,
- mediaName: payload.subject,
- mediaExtra: payload.extra ?? [],
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Request Automatically Approved`,
- },
- });
- });
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaDeclinedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- if (
- payload.notifyUser &&
- (payload.notifyUser.settings?.enableNotifications ?? true)
- ) {
- const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: `Your request for the following ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- } was declined:`,
- mediaName: payload.subject,
- mediaExtra: payload.extra ?? [],
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Request Declined`,
- },
- });
- }
-
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaAvailableEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- if (
- payload.notifyUser &&
- (payload.notifyUser.settings?.enableNotifications ?? true)
- ) {
- const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: `The following ${
- payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
- } you requested is now available!`,
- mediaName: payload.subject,
- mediaExtra: payload.extra ?? [],
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.request?.requestedBy.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: `${
- payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
- } Now Available`,
- },
- });
- }
-
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendTestEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- if (payload.notifyUser) {
- const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
-
- await email.send({
- template: path.join(__dirname, '../../../templates/email/test-email'),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: payload.message,
- applicationUrl,
- applicationTitle,
- },
- });
- }
-
- return true;
- } catch (e) {
- logger.error('Email notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
+ return undefined;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending email notification', { label: 'Notifications' });
+ if (payload.notifyUser) {
+ // Send notification to the user who submitted the request
+ if (
+ !payload.notifyUser.settings ||
+ payload.notifyUser.settings.hasNotificationAgentEnabled(
+ NotificationAgentType.EMAIL
+ )
+ ) {
+ logger.debug('Sending email notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
- switch (type) {
- case Notification.MEDIA_PENDING:
- this.sendMediaRequestEmail(payload);
- break;
- case Notification.MEDIA_APPROVED:
- this.sendMediaApprovedEmail(payload);
- break;
- case Notification.MEDIA_AUTO_APPROVED:
- this.sendMediaAutoApprovedEmail(payload);
- break;
- case Notification.MEDIA_DECLINED:
- this.sendMediaDeclinedEmail(payload);
- break;
- case Notification.MEDIA_AVAILABLE:
- this.sendMediaAvailableEmail(payload);
- break;
- case Notification.MEDIA_FAILED:
- this.sendMediaFailedEmail(payload);
- break;
- case Notification.TEST_NOTIFICATION:
- this.sendTestEmail(payload);
- break;
+ try {
+ const email = new PreparedEmail(
+ this.getSettings(),
+ payload.notifyUser.settings?.pgpKey
+ );
+ await email.send(
+ this.buildMessage(type, payload, payload.notifyUser.email)
+ );
+ } catch (e) {
+ logger.error('Error sending email notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ });
+
+ return false;
+ }
+ }
+ } else {
+ // Send notifications to all users with the Manage Requests permission
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
+
+ await Promise.all(
+ users
+ .filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ (!user.settings ||
+ user.settings.hasNotificationAgentEnabled(
+ NotificationAgentType.EMAIL
+ ))
+ )
+ .map(async (user) => {
+ logger.debug('Sending email notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ const email = new PreparedEmail(
+ this.getSettings(),
+ user.settings?.pgpKey
+ );
+ await email.send(this.buildMessage(type, payload, user.email));
+ } catch (e) {
+ logger.error('Error sending email notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ });
+
+ return false;
+ }
+ })
+ );
}
return true;
diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts
index f0c0f757..c43e9971 100644
--- a/server/lib/notifications/agents/pushbullet.ts
+++ b/server/lib/notifications/agents/pushbullet.ts
@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
-import { MediaType } from '../../../constants/media';
interface PushbulletPayload {
title: string;
@@ -136,7 +136,12 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
+ logger.debug('Sending Pushbullet notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
@@ -162,8 +167,12 @@ class PushbulletAgent
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts
index 3b5d3f87..f9bff21c 100644
--- a/server/lib/notifications/agents/pushover.ts
+++ b/server/lib/notifications/agents/pushover.ts
@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
-import { MediaType } from '../../../constants/media';
interface PushoverPayload {
token: string;
@@ -160,7 +160,11 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Pushover notification', { label: 'Notifications' });
+ logger.debug('Sending Pushover notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
@@ -189,8 +193,12 @@ class PushoverAgent
} catch (e) {
logger.error('Error sending Pushover notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts
index b5234785..f9fe46c9 100644
--- a/server/lib/notifications/agents/slack.ts
+++ b/server/lib/notifications/agents/slack.ts
@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
-import { MediaType } from '../../../constants/media';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
@@ -67,9 +67,7 @@ class SlackAgent
if (payload.request) {
fields.push({
type: 'mrkdwn',
- text: `*Requested By*\n${
- payload.request?.requestedBy.displayName ?? ''
- }`,
+ text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
@@ -235,7 +233,11 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Slack notification', { label: 'Notifications' });
+ logger.debug('Sending Slack notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
try {
const webhookUrl = this.getSettings().options.webhookUrl;
@@ -249,8 +251,12 @@ class SlackAgent
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts
index 5fa4c518..894a7726 100644
--- a/server/lib/notifications/agents/telegram.ts
+++ b/server/lib/notifications/agents/telegram.ts
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
+import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload {
@@ -155,62 +156,98 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Telegram notification', { label: 'Notifications' });
+ const endpoint = `${this.baseUrl}bot${this.getSettings().options.botAPI}/${
+ payload.image ? 'sendPhoto' : 'sendMessage'
+ }`;
+
+ // Send system notification
try {
- const endpoint = `${this.baseUrl}bot${
- this.getSettings().options.botAPI
- }/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
+ logger.debug('Sending Telegram notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
- // Send system notification
- await (payload.image
- ? axios.post(endpoint, {
- photo: payload.image,
- caption: this.buildMessage(type, payload),
- parse_mode: 'MarkdownV2',
- chat_id: `${this.getSettings().options.chatId}`,
- disable_notification: this.getSettings().options.sendSilently,
- } as TelegramPhotoPayload)
- : axios.post(endpoint, {
- text: this.buildMessage(type, payload),
- parse_mode: 'MarkdownV2',
- chat_id: `${this.getSettings().options.chatId}`,
- disable_notification: this.getSettings().options.sendSilently,
- } as TelegramMessagePayload));
-
- // Send user notification
- if (
- payload.notifyUser &&
- (payload.notifyUser.settings?.enableNotifications ?? true) &&
- payload.notifyUser.settings?.telegramChatId &&
- payload.notifyUser.settings?.telegramChatId !==
- this.getSettings().options.chatId
- ) {
- await (payload.image
- ? axios.post(endpoint, {
+ await axios.post(
+ endpoint,
+ payload.image
+ ? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
- chat_id: `${payload.notifyUser.settings.telegramChatId}`,
- disable_notification:
- payload.notifyUser.settings.telegramSendSilently,
+ chat_id: this.getSettings().options.chatId,
+ disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
- : axios.post(endpoint, {
+ : ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
- chat_id: `${payload.notifyUser.settings.telegramChatId}`,
- disable_notification:
- payload.notifyUser.settings.telegramSendSilently,
- } as TelegramMessagePayload));
- }
-
- return true;
+ chat_id: `${this.getSettings().options.chatId}`,
+ disable_notification: this.getSettings().options.sendSilently,
+ } as TelegramMessagePayload)
+ );
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response.data,
});
return false;
}
+
+ if (
+ payload.notifyUser &&
+ payload.notifyUser.settings?.hasNotificationAgentEnabled(
+ NotificationAgentType.TELEGRAM
+ ) &&
+ payload.notifyUser.settings?.telegramChatId &&
+ payload.notifyUser.settings?.telegramChatId !==
+ this.getSettings().options.chatId
+ ) {
+ // Send notification to the user who submitted the request
+ logger.debug('Sending Telegram notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ await axios.post(
+ endpoint,
+ payload.image
+ ? ({
+ photo: payload.image,
+ caption: this.buildMessage(type, payload),
+ parse_mode: 'MarkdownV2',
+ chat_id: payload.notifyUser.settings.telegramChatId,
+ disable_notification:
+ payload.notifyUser.settings.telegramSendSilently,
+ } as TelegramPhotoPayload)
+ : ({
+ text: this.buildMessage(type, payload),
+ parse_mode: 'MarkdownV2',
+ chat_id: payload.notifyUser.settings.telegramChatId,
+ disable_notification:
+ payload.notifyUser.settings.telegramSendSilently,
+ } as TelegramMessagePayload)
+ );
+ } catch (e) {
+ logger.error('Error sending Telegram notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response.data,
+ });
+
+ return false;
+ }
+ }
+
+ return true;
}
}
diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts
index fa3058fe..7630cf44 100644
--- a/server/lib/notifications/agents/webhook.ts
+++ b/server/lib/notifications/agents/webhook.ts
@@ -30,6 +30,12 @@ const KeyMap: Record = {
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
request_id: 'request.id',
+ requestedBy_username: 'request.requestedBy.displayName',
+ requestedBy_email: 'request.requestedBy.email',
+ requestedBy_avatar: 'request.requestedBy.avatar',
+ requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
+ requestedBy_settings_telegramChatId:
+ 'request.requestedBy.settings.telegramChatId',
};
class WebhookAgent
@@ -122,7 +128,12 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending webhook notification', { label: 'Notifications' });
+ logger.debug('Sending webhook notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
try {
const { webhookUrl, authHeader } = this.getSettings().options;
@@ -140,8 +151,12 @@ class WebhookAgent
} catch (e) {
logger.error('Error sending webhook notification', {
label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
errorMessage: e.message,
+ response: e.response.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agenttypes.ts b/server/lib/notifications/agenttypes.ts
new file mode 100644
index 00000000..9e0d79aa
--- /dev/null
+++ b/server/lib/notifications/agenttypes.ts
@@ -0,0 +1,16 @@
+export enum NotificationAgentType {
+ NONE = 0,
+ EMAIL = 2,
+ DISCORD = 4,
+ TELEGRAM = 8,
+ PUSHOVER = 16,
+ PUSHBULLET = 32,
+ SLACK = 64,
+}
+
+export const hasNotificationAgentEnabled = (
+ agent: NotificationAgentType,
+ value: number
+): boolean => {
+ return !!(value & agent);
+};
diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts
index 7d5b6800..f1f237f5 100644
--- a/server/lib/notifications/index.ts
+++ b/server/lib/notifications/index.ts
@@ -38,7 +38,7 @@ class NotificationManager {
public registerAgents = (agents: NotificationAgent[]): void => {
this.activeAgents = [...this.activeAgents, ...agents];
- logger.info('Registered Notification Agents', { label: 'Notifications' });
+ logger.info('Registered notification agents', { label: 'Notifications' });
};
public sendNotification(
@@ -46,8 +46,9 @@ class NotificationManager {
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
- logger.info(`Sending notification for ${Notification[type]}`, {
+ logger.info(`Sending notification(s) for ${Notification[type]}`, {
label: 'Notifications',
+ subject: payload.subject,
});
this.activeAgents.forEach((agent) => {
if (settings.enabled && agent.shouldSend(type)) {
diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts
index 74682cc5..f3573209 100644
--- a/server/lib/scanners/radarr/index.ts
+++ b/server/lib/scanners/radarr/index.ts
@@ -1,5 +1,5 @@
import { uniqWith } from 'lodash';
-import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
+import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
@@ -52,7 +52,7 @@ class RadarrScanner
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
- url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
+ url: RadarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts
index 4bc505fb..73500db9 100644
--- a/server/lib/scanners/sonarr/index.ts
+++ b/server/lib/scanners/sonarr/index.ts
@@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
-import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
+import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
@@ -58,7 +58,7 @@ class SonarrScanner
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
- url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
+ url: SonarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
diff --git a/server/lib/settings.ts b/server/lib/settings.ts
index 5809600f..bb82c7ef 100644
--- a/server/lib/settings.ts
+++ b/server/lib/settings.ts
@@ -30,7 +30,7 @@ export interface PlexSettings {
libraries: Library[];
}
-interface DVRSettings {
+export interface DVRSettings {
id: number;
name: string;
hostname: string;
@@ -41,6 +41,7 @@ interface DVRSettings {
activeProfileId: number;
activeProfileName: string;
activeDirectory: string;
+ tags: number[];
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
@@ -58,6 +59,7 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number;
+ animeTags?: number[];
enableSeasonFolders: boolean;
}
@@ -295,7 +297,7 @@ class Settings {
webhookUrl: '',
authHeader: '',
jsonPayload:
- 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
+ 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
},
},
},
diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts
new file mode 100644
index 00000000..c8bd6dd4
--- /dev/null
+++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateTagsFieldonMediaRequest1617624225464
+ implements MigrationInterface {
+ name = 'CreateTagsFieldonMediaRequest1617624225464';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "media_request"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "temporary_media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_media_request"`);
+ }
+}
diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts
new file mode 100644
index 00000000..86a52c08
--- /dev/null
+++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts
@@ -0,0 +1,52 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserSettingsNotificationAgentsField1617730837489
+ implements MigrationInterface {
+ name = 'AddUserSettingsNotificationAgentsField1617730837489';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/routes/index.ts b/server/routes/index.ts
index af9537db..d9e2342b 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -1,33 +1,75 @@
import { Router } from 'express';
-import user from './user';
-import authRoutes from './auth';
-import { checkUser, isAuthenticated } from '../middleware/auth';
-import settingsRoutes from './settings';
+import GithubAPI from '../api/github';
+import TheMovieDb from '../api/themoviedb';
+import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
-import searchRoutes from './search';
-import discoverRoutes from './discover';
-import requestRoutes from './request';
-import movieRoutes from './movie';
-import tvRoutes from './tv';
-import mediaRoutes from './media';
-import personRoutes from './person';
-import collectionRoutes from './collection';
-import { getAppVersion, getCommitTag } from '../utils/appVersion';
-import serviceRoutes from './service';
-import { appDataStatus, appDataPath } from '../utils/appDataVolume';
-import TheMovieDb from '../api/themoviedb';
+import { checkUser, isAuthenticated } from '../middleware/auth';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
+import { appDataPath, appDataStatus } from '../utils/appDataVolume';
+import { getAppVersion, getCommitTag } from '../utils/appVersion';
+import authRoutes from './auth';
+import collectionRoutes from './collection';
+import discoverRoutes from './discover';
+import mediaRoutes from './media';
+import movieRoutes from './movie';
+import personRoutes from './person';
+import requestRoutes from './request';
+import searchRoutes from './search';
+import serviceRoutes from './service';
+import settingsRoutes from './settings';
+import tvRoutes from './tv';
+import user from './user';
const router = Router();
router.use(checkUser);
-router.get('/status', (req, res) => {
+router.get('/status', async (req, res) => {
+ const githubApi = new GithubAPI();
+
+ const currentVersion = getAppVersion();
+ const commitTag = getCommitTag();
+ let updateAvailable = false;
+ let commitsBehind = 0;
+
+ if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
+ const commits = await githubApi.getOverseerrCommits();
+
+ if (commits.length) {
+ const filteredCommits = commits.filter(
+ (commit) => !commit.commit.message.includes('[skip ci]')
+ );
+ if (filteredCommits[0].sha !== commitTag) {
+ updateAvailable = true;
+ }
+
+ const commitIndex = filteredCommits.findIndex(
+ (commit) => commit.sha === commitTag
+ );
+
+ if (updateAvailable) {
+ commitsBehind = commitIndex;
+ }
+ }
+ } else if (commitTag !== 'local') {
+ const releases = await githubApi.getOverseerrReleases();
+
+ if (releases.length) {
+ const latestVersion = releases[0];
+
+ if (latestVersion.name !== currentVersion) {
+ updateAvailable = true;
+ }
+ }
+ }
+
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
+ updateAvailable,
+ commitsBehind,
});
});
@@ -39,7 +81,7 @@ router.get('/status/appdata', (_req, res) => {
});
router.use('/user', isAuthenticated(), user);
-router.get('/settings/public', (_req, res) => {
+router.get('/settings/public', async (_req, res) => {
const settings = getSettings();
return res.status(200).json(settings.fullPublicSettings);
diff --git a/server/routes/request.ts b/server/routes/request.ts
index b7598f4e..6ad4ac05 100644
--- a/server/routes/request.ts
+++ b/server/routes/request.ts
@@ -278,6 +278,7 @@ requestRoutes.post(
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
+ tags: req.body.tags,
});
await requestRepository.save(request);
@@ -356,6 +357,7 @@ requestRoutes.post(
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
+ tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
@@ -497,6 +499,7 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
+ request.tags = req.body.tags;
request.requestedBy = requestUser as User;
requestRepository.save(request);
@@ -505,6 +508,8 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
+ request.languageProfileId = req.body.languageProfileId;
+ request.tags = req.body.tags;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;
diff --git a/server/routes/service.ts b/server/routes/service.ts
index 5e6dccc8..51bbc4e3 100644
--- a/server/routes/service.ts
+++ b/server/routes/service.ts
@@ -1,12 +1,12 @@
import { Router } from 'express';
-import RadarrAPI from '../api/radarr';
-import SonarrAPI from '../api/sonarr';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
+import TheMovieDb from '../api/themoviedb';
import {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
-import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@@ -22,6 +22,7 @@ serviceRoutes.get('/radarr', async (req, res) => {
isDefault: radarr.isDefault,
activeDirectory: radarr.activeDirectory,
activeProfileId: radarr.activeProfileId,
+ activeTags: radarr.tags ?? [],
})
);
@@ -46,11 +47,12 @@ serviceRoutes.get<{ radarrId: string }>(
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const rootFolders = await radarr.getRootFolders();
+ const tags = await radarr.getTags();
return res.status(200).json({
server: {
@@ -60,6 +62,7 @@ serviceRoutes.get<{ radarrId: string }>(
isDefault: radarrSettings.isDefault,
activeDirectory: radarrSettings.activeDirectory,
activeProfileId: radarrSettings.activeProfileId,
+ activeTags: radarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -71,6 +74,7 @@ serviceRoutes.get<{ radarrId: string }>(
path: folder.path,
totalSpace: folder.totalSpace,
})),
+ tags,
} as ServiceCommonServerWithDetails);
}
);
@@ -90,6 +94,7 @@ serviceRoutes.get('/sonarr', async (req, res) => {
activeAnimeDirectory: sonarr.activeAnimeDirectory,
activeLanguageProfileId: sonarr.activeLanguageProfileId,
activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId,
+ activeTags: [],
})
);
@@ -114,13 +119,14 @@ serviceRoutes.get<{ sonarrId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
- url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
+ url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
+ const tags = await sonarr.getTags();
return res.status(200).json({
server: {
@@ -135,6 +141,8 @@ serviceRoutes.get<{ sonarrId: string }>(
activeLanguageProfileId: sonarrSettings.activeLanguageProfileId,
activeAnimeLanguageProfileId:
sonarrSettings.activeAnimeLanguageProfileId,
+ activeTags: sonarrSettings.tags,
+ activeAnimeTags: sonarrSettings.animeTags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -147,6 +155,7 @@ serviceRoutes.get<{ sonarrId: string }>(
totalSpace: folder.totalSpace,
})),
languageProfiles: languageProfiles,
+ tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts
index c17e3e13..719e8c9f 100644
--- a/server/routes/settings/index.ts
+++ b/server/routes/settings/index.ts
@@ -113,7 +113,6 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
- const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/;
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
@@ -126,40 +125,32 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
return device.provides.includes('server') && device.owned;
});
const settings = getSettings();
+
if (devices) {
await Promise.all(
devices.map(async (device) => {
await Promise.all(
device.connection.map(async (connection) => {
- connection.host = connection.uri.replace(regexp, '$3');
- let msg:
- | { status: number; message: string }
- | undefined = undefined;
const plexDeviceSettings = {
...settings.plex,
- ip: connection.host,
+ ip: connection.address,
port: connection.port,
- useSsl: connection.protocol === 'https' ? true : false,
+ useSsl: !connection.local && connection.protocol === 'https',
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexSettings: plexDeviceSettings,
timeout: 5000,
});
+
try {
await plexClient.getStatus();
- msg = {
- status: 200,
- message: 'OK',
- };
+ connection.status = 200;
+ connection.message = 'OK';
} catch (e) {
- msg = {
- status: 500,
- message: e.message,
- };
+ connection.status = 500;
+ connection.message = e.message;
}
- connection.status = msg?.status;
- connection.message = msg?.message;
})
);
})
diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts
index fbf1ce1e..739b3981 100644
--- a/server/routes/settings/notifications.ts
+++ b/server/routes/settings/notifications.ts
@@ -1,36 +1,16 @@
import { Router } from 'express';
-import { getSettings } from '../../lib/settings';
import { Notification } from '../../lib/notifications';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
+import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
+import PushoverAgent from '../../lib/notifications/agents/pushover';
import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram';
-import PushoverAgent from '../../lib/notifications/agents/pushover';
import WebhookAgent from '../../lib/notifications/agents/webhook';
-import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
+import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
-notificationRoutes.get('/', (_req, res) => {
- const settings = getSettings().notifications;
- return res.status(200).json({
- enabled: settings.enabled,
- });
-});
-
-notificationRoutes.post('/', (req, res) => {
- const settings = getSettings();
-
- Object.assign(settings.notifications, {
- enabled: req.body.enabled,
- });
- settings.save();
-
- return res.status(200).json({
- enabled: settings.notifications.enabled,
- });
-});
-
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();
diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts
index 1e17a475..d250ea29 100644
--- a/server/routes/settings/radarr.ts
+++ b/server/routes/settings/radarr.ts
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import RadarrAPI from '../../api/radarr';
+import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => {
return res.status(201).json(newRadarr);
});
-radarrRoutes.post('/test', async (req, res, next) => {
+radarrRoutes.post<
+ undefined,
+ Record,
+ RadarrSettings & { tagLabel?: string }
+>('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
- url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'),
+ url: RadarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
+ const tags = await radarr.getTags();
return res.status(200).json({
profiles,
@@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
id: folder.id,
path: folder.path,
})),
+ tags,
});
} catch (e) {
logger.error('Failed to test Radarr', {
@@ -62,40 +68,41 @@ radarrRoutes.post('/test', async (req, res, next) => {
}
});
-radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
- const settings = getSettings();
+radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
+ '/:id',
+ (req, res, next) => {
+ const settings = getSettings();
- const radarrIndex = settings.radarr.findIndex(
- (r) => r.id === Number(req.params.id)
- );
+ const radarrIndex = settings.radarr.findIndex(
+ (r) => r.id === Number(req.params.id)
+ );
- if (radarrIndex === -1) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ if (radarrIndex === -1) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ // If we are setting this as the default, clear any previous defaults for the same type first
+ // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
+ // and are the default
+ if (req.body.isDefault) {
+ settings.radarr
+ .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
+ .forEach((radarrInstance) => {
+ radarrInstance.isDefault = false;
+ });
+ }
+
+ settings.radarr[radarrIndex] = {
+ ...req.body,
+ id: Number(req.params.id),
+ } as RadarrSettings;
+ settings.save();
+
+ return res.status(200).json(settings.radarr[radarrIndex]);
}
+);
- // If we are setting this as the default, clear any previous defaults for the same type first
- // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
- // and are the default
- if (req.body.isDefault) {
- settings.radarr
- .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
- .forEach((radarrInstance) => {
- radarrInstance.isDefault = false;
- });
- }
-
- settings.radarr[radarrIndex] = {
- ...req.body,
- id: Number(req.params.id),
- } as RadarrSettings;
- settings.save();
-
- return res.status(200).json(settings.radarr[radarrIndex]);
-});
-
-radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
+radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
@@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
if (!radarrSettings) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ return next({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@@ -123,7 +128,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
});
-radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
+radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
);
if (radarrIndex === -1) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts
index d9bbe3c2..4f63ebb3 100644
--- a/server/routes/settings/sonarr.ts
+++ b/server/routes/settings/sonarr.ts
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import SonarrAPI from '../../api/sonarr';
+import SonarrAPI from '../../api/servarr/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -39,12 +39,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
- url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'),
+ url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
+ const tags = await sonarr.getTags();
return res.status(200).json({
profiles,
@@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
path: folder.path,
})),
languageProfiles,
+ tags,
});
} catch (e) {
logger.error('Failed to test Sonarr', {
diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts
index 2ddc700f..0c904691 100644
--- a/server/routes/user/index.ts
+++ b/server/routes/user/index.ts
@@ -281,7 +281,7 @@ router.delete<{ id: string }>(
});
}
- if (user.hasPermission(Permission.ADMIN)) {
+ if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts
index 693c228e..f85ef179 100644
--- a/server/routes/user/usersettings.ts
+++ b/server/routes/user/usersettings.ts
@@ -7,6 +7,7 @@ import {
UserSettingsGeneralResponse,
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
+import { NotificationAgentType } from '../../lib/notifications/agenttypes';
import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -242,13 +243,17 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
- enableNotifications: user.settings?.enableNotifications ?? true,
+ notificationAgents:
+ user.settings?.notificationAgents ?? NotificationAgentType.EMAIL,
+ emailEnabled: settings?.notifications.agents.email.enabled,
+ pgpKey: user.settings?.pgpKey,
+ discordEnabled: settings?.notifications.agents.discord.enabled,
+ discordId: user.settings?.discordId,
+ telegramEnabled: settings?.notifications.agents.telegram.enabled,
telegramBotUsername:
settings?.notifications.agents.telegram.options.botUsername,
- discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
- pgpKey: user?.settings?.pgpKey,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -256,60 +261,62 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
);
-userSettingsRoutes.post<
- { id: string },
- UserSettingsNotificationsResponse,
- UserSettingsNotificationsResponse
->('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
- const userRepository = getRepository(User);
+userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
+ '/notifications',
+ isOwnProfileOrAdmin(),
+ async (req, res, next) => {
+ const userRepository = getRepository(User);
- try {
- const user = await userRepository.findOne({
- where: { id: Number(req.params.id) },
- });
-
- if (!user) {
- return next({ status: 404, message: 'User not found.' });
- }
-
- // "Owner" user settings cannot be modified by other users
- if (user.id === 1 && req.user?.id !== 1) {
- return next({
- status: 403,
- message: "You do not have permission to modify this user's settings.",
+ try {
+ const user = await userRepository.findOne({
+ where: { id: Number(req.params.id) },
});
- }
- if (!user.settings) {
- user.settings = new UserSettings({
- user: req.user,
- enableNotifications: req.body.enableNotifications,
- discordId: req.body.discordId,
- telegramChatId: req.body.telegramChatId,
- telegramSendSilently: req.body.telegramSendSilently,
- pgpKey: req.body.pgpKey,
+ if (!user) {
+ return next({ status: 404, message: 'User not found.' });
+ }
+
+ // "Owner" user settings cannot be modified by other users
+ if (user.id === 1 && req.user?.id !== 1) {
+ return next({
+ status: 403,
+ message: "You do not have permission to modify this user's settings.",
+ });
+ }
+
+ if (!user.settings) {
+ user.settings = new UserSettings({
+ user: req.user,
+ notificationAgents:
+ req.body.notificationAgents ?? NotificationAgentType.EMAIL,
+ pgpKey: req.body.pgpKey,
+ discordId: req.body.discordId,
+ telegramChatId: req.body.telegramChatId,
+ telegramSendSilently: req.body.telegramSendSilently,
+ });
+ } else {
+ user.settings.notificationAgents =
+ req.body.notificationAgents ?? NotificationAgentType.EMAIL;
+ user.settings.pgpKey = req.body.pgpKey;
+ user.settings.discordId = req.body.discordId;
+ user.settings.telegramChatId = req.body.telegramChatId;
+ user.settings.telegramSendSilently = req.body.telegramSendSilently;
+ }
+
+ userRepository.save(user);
+
+ return res.status(200).json({
+ notificationAgents: user.settings?.notificationAgents,
+ pgpKey: user.settings?.pgpKey,
+ discordId: user.settings?.discordId,
+ telegramChatId: user.settings?.telegramChatId,
+ telegramSendSilently: user?.settings?.telegramSendSilently,
});
- } else {
- user.settings.enableNotifications = req.body.enableNotifications;
- user.settings.discordId = req.body.discordId;
- user.settings.telegramChatId = req.body.telegramChatId;
- user.settings.telegramSendSilently = req.body.telegramSendSilently;
- user.settings.pgpKey = req.body.pgpKey;
+ } catch (e) {
+ next({ status: 500, message: e.message });
}
-
- userRepository.save(user);
-
- return res.status(200).json({
- enableNotifications: user.settings.enableNotifications,
- discordId: user.settings.discordId,
- telegramChatId: user.settings.telegramChatId,
- telegramSendSilently: user.settings.telegramSendSilently,
- pgpKey: user.settings.pgpKey,
- });
- } catch (e) {
- next({ status: 500, message: e.message });
}
-});
+);
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',
diff --git a/server/templates/email/generatedpassword/html.pug b/server/templates/email/generatedpassword/html.pug
index 1fa4713f..b9bc2a2e 100644
--- a/server/templates/email/generatedpassword/html.pug
+++ b/server/templates/email/generatedpassword/html.pug
@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
- font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
- font-size: 16px;\
+ font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')
diff --git a/server/templates/email/resetpassword/html.pug b/server/templates/email/resetpassword/html.pug
index f7c8bb08..718a0495 100644
--- a/server/templates/email/resetpassword/html.pug
+++ b/server/templates/email/resetpassword/html.pug
@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
- font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
- font-size: 16px;\
+ font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')
diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug
index b4abfebb..f1b21b36 100644
--- a/server/templates/email/test-email/html.pug
+++ b/server/templates/email/test-email/html.pug
@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
- font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
- font-size: 16px;\
+ font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index a83561a0..0d73f8ad 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -11,9 +11,9 @@ confinement: strict
parts:
overseerr:
plugin: nodejs
- nodejs-version: "14.16.0"
+ nodejs-version: "14.16.1"
nodejs-package-manager: "yarn"
- nodejs-yarn-version: v1.22.5
+ nodejs-yarn-version: v1.22.10
build-packages:
- git
- on arm64:
diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx
index 3023db81..fce97bd5 100644
--- a/src/components/AppDataWarning/index.tsx
+++ b/src/components/AppDataWarning/index.tsx
@@ -4,7 +4,6 @@ import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
- dockerVolumeMissing: 'Docker Volume Mount Missing',
dockerVolumeMissingDescription:
'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});
@@ -26,14 +25,14 @@ const AppDataWarning: React.FC = () => {
return (
<>
{!data.appData && (
-
- {intl.formatMessage(messages.dockerVolumeMissingDescription, {
+ {msg};
},
appDataPath: data.appDataPath,
})}
-
+ />
)}
>
);
diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx
index 689fba5c..7a893708 100644
--- a/src/components/Common/List/index.tsx
+++ b/src/components/Common/List/index.tsx
@@ -3,15 +3,16 @@ import { withProperties } from '../../../utils/typeHelpers';
interface ListItemProps {
title: string;
+ className?: string;
}
-const ListItem: React.FC = ({ title, children }) => {
+const ListItem: React.FC = ({ title, className, children }) => {
return (
{title}
- {children}
+ {children}
diff --git a/src/components/Common/SettingsTabs/index.tsx b/src/components/Common/SettingsTabs/index.tsx
new file mode 100644
index 00000000..2e47b418
--- /dev/null
+++ b/src/components/Common/SettingsTabs/index.tsx
@@ -0,0 +1,173 @@
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import React from 'react';
+import { hasPermission, Permission } from '../../../../server/lib/permissions';
+import { useUser } from '../../../hooks/useUser';
+
+export interface SettingsRoute {
+ text: string;
+ content?: React.ReactNode;
+ route: string;
+ regex: RegExp;
+ requiredPermission?: Permission | Permission[];
+ permissionType?: { type: 'and' | 'or' };
+ hidden?: boolean;
+}
+
+const SettingsLink: React.FC<{
+ tabType: 'default' | 'button';
+ currentPath: string;
+ route: string;
+ regex: RegExp;
+ hidden?: boolean;
+ isMobile?: boolean;
+}> = ({
+ children,
+ tabType,
+ currentPath,
+ route,
+ regex,
+ hidden = false,
+ isMobile = false,
+}) => {
+ if (hidden) {
+ return null;
+ }
+
+ if (isMobile) {
+ return {children} ;
+ }
+
+ let linkClasses =
+ 'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 border-transparent whitespace-nowrap first:ml-0';
+ let activeLinkColor = 'text-indigo-500 border-indigo-600';
+ let inactiveLinkColor =
+ 'text-gray-500 border-transparent hover:text-gray-300 hover:border-gray-400 focus:text-gray-300 focus:border-gray-400';
+
+ if (tabType === 'button') {
+ linkClasses =
+ 'px-3 py-2 ml-8 text-sm font-medium transition duration-300 rounded-md whitespace-nowrap first:ml-0';
+ activeLinkColor = 'bg-indigo-700';
+ inactiveLinkColor = 'bg-gray-800 hover:bg-gray-700 focus:bg-gray-700';
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+const SettingsTabs: React.FC<{
+ tabType?: 'default' | 'button';
+ settingsRoutes: SettingsRoute[];
+}> = ({ tabType = 'default', settingsRoutes }) => {
+ const router = useRouter();
+ const { user: currentUser } = useUser();
+
+ return (
+ <>
+
+
+ Select a Tab
+
+ {
+ router.push(e.target.value);
+ }}
+ onBlur={(e) => {
+ router.push(e.target.value);
+ }}
+ defaultValue={
+ settingsRoutes.find((route) => !!router.pathname.match(route.regex))
+ ?.route
+ }
+ aria-label="Selected Tab"
+ >
+ {settingsRoutes
+ .filter(
+ (route) =>
+ !route.hidden &&
+ (route.requiredPermission
+ ? hasPermission(
+ route.requiredPermission,
+ currentUser?.permissions ?? 0,
+ route.permissionType
+ )
+ : true)
+ )
+ .map((route, index) => (
+
+ {route.text}
+
+ ))}
+
+
+ {tabType === 'button' ? (
+
+
+ {settingsRoutes.map((route, index) => (
+
+ {route.content ?? route.text}
+
+ ))}
+
+
+ ) : (
+
+
+
+ {settingsRoutes
+ .filter(
+ (route) =>
+ !route.hidden &&
+ (route.requiredPermission
+ ? hasPermission(
+ route.requiredPermission,
+ currentUser?.permissions ?? 0,
+ route.permissionType
+ )
+ : true)
+ )
+ .map((route, index) => (
+
+ {route.text}
+
+ ))}
+
+
+
+ )}
+ >
+ );
+};
+
+export default SettingsTabs;
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx
index 9a4d05c8..8560a535 100644
--- a/src/components/Discover/index.tsx
+++ b/src/components/Discover/index.tsx
@@ -21,7 +21,7 @@ const messages = defineMessages({
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
- nopending: 'No Pending Requests',
+ noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
});
@@ -94,7 +94,7 @@ const Discover: React.FC = () => {
/>
))}
placeholder={ }
- emptyMessage={intl.formatMessage(messages.nopending)}
+ emptyMessage={intl.formatMessage(messages.noRequests)}
/>
= ({ open, setClosed }) => {
const intl = useIntl();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
+
return (
<>
@@ -172,7 +174,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
@@ -181,7 +183,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
-
+
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
@@ -221,6 +223,9 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
);
})}
+ {hasPermission(Permission.ADMIN) && (
+ setClosed()} />
+ )}
@@ -273,6 +278,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
);
})}
+ {hasPermission(Permission.ADMIN) && }
diff --git a/src/components/Layout/VersionStatus/index.tsx b/src/components/Layout/VersionStatus/index.tsx
new file mode 100644
index 00000000..e5e07869
--- /dev/null
+++ b/src/components/Layout/VersionStatus/index.tsx
@@ -0,0 +1,138 @@
+import Link from 'next/link';
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import useSWR from 'swr';
+import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
+
+const messages = defineMessages({
+ streamdevelop: 'Overseerr Develop',
+ streamstable: 'Overseerr Stable',
+ outofdate: 'Out of Date',
+ commitsbehind:
+ '{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
+});
+
+interface VersionStatusProps {
+ onClick?: () => void;
+}
+
+const VersionStatus: React.FC = ({ onClick }) => {
+ const intl = useIntl();
+ const { data } = useSWR('/api/v1/status', {
+ refreshInterval: 60 * 1000,
+ });
+
+ if (!data) {
+ return null;
+ }
+
+ const versionStream =
+ data.commitTag === 'local'
+ ? 'Keep it up! 👍'
+ : data.version.startsWith('develop-')
+ ? intl.formatMessage(messages.streamdevelop)
+ : intl.formatMessage(messages.streamstable);
+
+ return (
+
+ {
+ if (e.key === 'Enter' && onClick) {
+ onClick();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ className={`flex items-center p-2 mx-2 text-xs transition duration-300 rounded-lg ring-1 ring-gray-700 ${
+ data.updateAvailable
+ ? 'bg-yellow-500 text-white hover:bg-yellow-400'
+ : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
+ }`}
+ >
+ {data.commitTag === 'local' ? (
+
+
+
+ ) : data.version.startsWith('develop-') ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {versionStream}
+
+ {data.commitTag === 'local' ? (
+ '(⌐■_■)'
+ ) : data.commitsBehind > 0 ? (
+ intl.formatMessage(messages.commitsbehind, {
+ commitsBehind: data.commitsBehind,
+ })
+ ) : data.commitsBehind === -1 ? (
+ intl.formatMessage(messages.outofdate)
+ ) : (
+
+ {data.version.replace('develop-', '')}
+
+ )}
+
+
+ {data.updateAvailable && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default VersionStatus;
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx
index 330f7b3a..18519734 100644
--- a/src/components/Layout/index.tsx
+++ b/src/components/Layout/index.tsx
@@ -8,8 +8,8 @@ import Sidebar from './Sidebar';
import UserDropdown from './UserDropdown';
const messages = defineMessages({
- alphawarning:
- 'This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
+ betawarning:
+ 'This is BETA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
});
const Layout: React.FC = ({ children }) => {
@@ -102,7 +102,7 @@ const Layout: React.FC = ({ children }) => {
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx
index a865f1ab..dadd9c1a 100644
--- a/src/components/MovieDetails/index.tsx
+++ b/src/components/MovieDetails/index.tsx
@@ -49,7 +49,7 @@ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
manageModalTitle: 'Manage Movie',
manageModalRequests: 'Requests',
- manageModalNoRequests: 'No Requests',
+ manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear All Media Data',
manageModalClearMediaWarning:
'* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
diff --git a/src/components/NotificationTypeSelector/NotificationType/index.tsx b/src/components/NotificationTypeSelector/NotificationType/index.tsx
index 85224717..4085b2a6 100644
--- a/src/components/NotificationTypeSelector/NotificationType/index.tsx
+++ b/src/components/NotificationTypeSelector/NotificationType/index.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { NotificationItem, hasNotificationType } from '..';
+import { hasNotificationType, NotificationItem } from '..';
interface NotificationTypeProps {
option: NotificationItem;
@@ -46,7 +46,7 @@ const NotificationType: React.FC = ({
/>
-
+
{option.name}
{option.description}
diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx
index e58dc50a..b549613f 100644
--- a/src/components/NotificationTypeSelector/index.tsx
+++ b/src/components/NotificationTypeSelector/index.tsx
@@ -3,6 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import NotificationType from './NotificationType';
const messages = defineMessages({
+ notificationTypes: 'Notification Types',
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when media is requested and requires approval.',
@@ -111,16 +112,26 @@ const NotificationTypeSelector: React.FC = ({
];
return (
- <>
- {types.map((type) => (
-
- ))}
- >
+
+
+
+ {intl.formatMessage(messages.notificationTypes)}
+ *
+
+
+
+ {types.map((type) => (
+
+ ))}
+
+
+
+
);
};
diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx
index c3b780cd..e6baf6b1 100644
--- a/src/components/RequestCard/index.tsx
+++ b/src/components/RequestCard/index.tsx
@@ -3,7 +3,7 @@ import Link from 'next/link';
import React, { useContext, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl';
-import useSWR from 'swr';
+import useSWR, { mutate } from 'swr';
import {
MediaRequestStatus,
MediaStatus,
@@ -22,6 +22,8 @@ import StatusBadge from '../StatusBadge';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
+ mediaerror: 'The associated title for this request is no longer available.',
+ deleterequest: 'Delete Request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -38,6 +40,59 @@ const RequestCardPlaceholder: React.FC = () => {
);
};
+interface RequestCardErrorProps {
+ mediaId?: number;
+}
+
+const RequestCardError: React.FC = ({ mediaId }) => {
+ const { hasPermission } = useUser();
+ const intl = useIntl();
+
+ const deleteRequest = async () => {
+ await axios.delete(`/api/v1/media/${mediaId}`);
+ mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
+ };
+
+ return (
+
+
+
+
+
+ {intl.formatMessage(messages.mediaerror)}
+
+ {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
+
+
deleteRequest()}
+ >
+
+
+
+ {intl.formatMessage(messages.deleterequest)}
+
+
+ )}
+
+
+
+
+ );
+};
+
interface RequestCardProps {
request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
@@ -88,11 +143,11 @@ const RequestCard: React.FC = ({ request, onTitleData }) => {
}
if (!requestData && !requestError) {
- return ;
+ return ;
}
if (!title || !requestData) {
- return ;
+ return ;
}
return (
@@ -192,6 +247,8 @@ const RequestCard: React.FC = ({ request, onTitleData }) => {
).length > 0
}
is4k={requestData.is4k}
+ plexUrl={requestData.media.plexUrl}
+ plexUrl4k={requestData.media.plexUrl4k}
/>
)}
diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx
index 6e76f994..f9f512c9 100644
--- a/src/components/RequestList/RequestItem/index.tsx
+++ b/src/components/RequestList/RequestItem/index.tsx
@@ -28,12 +28,66 @@ const messages = defineMessages({
requested: 'Requested',
modified: 'Modified',
modifieduserdate: '{date} by {user}',
+ mediaerror: 'The associated title for this request is no longer available.',
+ deleterequest: 'Delete Request',
+ cancelRequest: 'Cancel Request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
+interface RequestItemErroProps {
+ mediaId?: number;
+ revalidateList: () => void;
+}
+
+const RequestItemError: React.FC = ({
+ mediaId,
+ revalidateList,
+}) => {
+ const intl = useIntl();
+ const { hasPermission } = useUser();
+
+ const deleteRequest = async () => {
+ await axios.delete(`/api/v1/media/${mediaId}`);
+ revalidateList();
+ };
+
+ return (
+
+
+ {intl.formatMessage(messages.mediaerror)}
+
+ {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
+
+
deleteRequest()}
+ >
+
+
+
+ {intl.formatMessage(messages.deleterequest)}
+
+
+ )}
+
+ );
+};
+
interface RequestItemProps {
request: MediaRequest;
revalidateList: () => void;
@@ -48,7 +102,7 @@ const RequestItem: React.FC = ({
});
const { addToast } = useToasts();
const intl = useIntl();
- const { hasPermission } = useUser();
+ const { user, hasPermission } = useUser();
const [showEditModal, setShowEditModal] = useState(false);
const { locale } = useContext(LanguageContext);
const url =
@@ -108,9 +162,9 @@ const RequestItem: React.FC = ({
if (!title || !requestData) {
return (
-
);
}
@@ -315,6 +369,31 @@ const RequestItem: React.FC = ({
+ {requestData.status === MediaRequestStatus.PENDING &&
+ !hasPermission(Permission.MANAGE_REQUESTS) &&
+ requestData.requestedBy.id === user?.id && (
+
deleteRequest()}
+ confirmText={intl.formatMessage(globalMessages.areyousure)}
+ className="w-full"
+ >
+
+
+
+
+ {intl.formatMessage(messages.cancelRequest)}
+
+
+ )}
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx
index c9f54f7b..f0bee3a4 100644
--- a/src/components/RequestModal/AdvancedRequester/index.tsx
+++ b/src/components/RequestModal/AdvancedRequester/index.tsx
@@ -1,7 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Listbox, Transition } from '@headlessui/react';
+import { isEqual } from 'lodash';
+import dynamic from 'next/dynamic';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
+import type { OptionsType, OptionTypeBase } from 'react-select';
import useSWR from 'swr';
import type {
ServiceCommonServer,
@@ -13,6 +16,13 @@ import globalMessages from '../../../i18n/globalMessages';
import { formatBytes } from '../../../utils/numberHelpers';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
+type OptionType = {
+ value: string;
+ label: string;
+};
+
+const Select = dynamic(() => import('react-select'), { ssr: false });
+
const messages = defineMessages({
advancedoptions: 'Advanced Options',
destinationserver: 'Destination Server',
@@ -23,12 +33,16 @@ const messages = defineMessages({
folder: '{path} ({space})',
requestas: 'Request As',
languageprofile: 'Language Profile',
+ tags: 'Tags',
+ selecttags: 'Select tags',
+ notagoptions: 'No tags.',
});
export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
+ tags?: number[];
language?: number;
user?: User;
};
@@ -77,6 +91,10 @@ const AdvancedRequester: React.FC
= ({
defaultOverrides?.language ?? -1
);
+ const [selectedTags, setSelectedTags] = useState(
+ defaultOverrides?.tags ?? []
+ );
+
const {
data: serverData,
isValidating,
@@ -150,6 +168,9 @@ const AdvancedRequester: React.FC = ({
? serverData.server.activeAnimeLanguageProfileId
: serverData.server.activeLanguageProfileId)
);
+ const defaultTags = isAnime
+ ? serverData.server.activeAnimeTags
+ : serverData.server.activeTags;
if (
defaultProfile &&
@@ -174,46 +195,43 @@ const AdvancedRequester: React.FC = ({
) {
setSelectedLanguage(defaultLanguage.id);
}
+
+ if (
+ defaultTags &&
+ !isEqual(defaultTags, selectedTags) &&
+ (!defaultOverrides || defaultOverrides.tags === null)
+ ) {
+ setSelectedTags(defaultTags);
+ }
}
}, [serverData]);
useEffect(() => {
- if (
- defaultOverrides &&
- defaultOverrides.server !== null &&
- defaultOverrides.server !== undefined
- ) {
+ if (defaultOverrides && defaultOverrides.server != null) {
setSelectedServer(defaultOverrides.server);
}
- if (
- defaultOverrides &&
- defaultOverrides.profile !== null &&
- defaultOverrides.profile !== undefined
- ) {
+ if (defaultOverrides && defaultOverrides.profile != null) {
setSelectedProfile(defaultOverrides.profile);
}
- if (
- defaultOverrides &&
- defaultOverrides.folder !== null &&
- defaultOverrides.folder !== undefined
- ) {
+ if (defaultOverrides && defaultOverrides.folder != null) {
setSelectedFolder(defaultOverrides.folder);
}
- if (
- defaultOverrides &&
- defaultOverrides.language !== null &&
- defaultOverrides.language !== undefined
- ) {
+ if (defaultOverrides && defaultOverrides.language != null) {
setSelectedLanguage(defaultOverrides.language);
}
+
+ if (defaultOverrides && defaultOverrides.tags != null) {
+ setSelectedTags(defaultOverrides.tags);
+ }
}, [
defaultOverrides?.server,
defaultOverrides?.folder,
defaultOverrides?.profile,
defaultOverrides?.language,
+ defaultOverrides?.tags,
]);
useEffect(() => {
@@ -224,6 +242,7 @@ const AdvancedRequester: React.FC = ({
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
language: selectedLanguage ?? undefined,
+ tags: selectedTags,
});
}
}, [
@@ -232,6 +251,7 @@ const AdvancedRequester: React.FC = ({
selectedProfile,
selectedUser,
selectedLanguage,
+ selectedTags,
]);
if (!data && !error) {
@@ -436,9 +456,48 @@ const AdvancedRequester: React.FC = ({
>
)}
+ {!!data && selectedServer !== null && (
+
+ {intl.formatMessage(messages.tags)}
+ ({
+ label: tag.label,
+ value: tag.id,
+ }))}
+ isMulti
+ isDisabled={isValidating || !serverData}
+ placeholder={
+ isValidating || !serverData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.selecttags)
+ }
+ className="react-select-container react-select-container-dark"
+ classNamePrefix="react-select"
+ value={selectedTags.map((tagId) => {
+ const foundTag = serverData?.tags.find(
+ (tag) => tag.id === tagId
+ );
+ return {
+ value: foundTag?.id,
+ label: foundTag?.label,
+ };
+ })}
+ onChange={(
+ value: OptionTypeBase | OptionsType | null
+ ) => {
+ if (!Array.isArray(value)) {
+ return;
+ }
+ setSelectedTags(value?.map((option) => option.value));
+ }}
+ noOptionsMessage={() => intl.formatMessage(messages.notagoptions)}
+ />
+
+ )}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
-
+
{title} edited successfully!',
requesterror: 'Something went wrong while submitting the request.',
+ pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes {
@@ -84,6 +85,7 @@ const MovieRequestModal: React.FC = ({
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
+ tags: requestOverrides.tags,
};
}
const response = await axios.post('/api/v1/request', {
@@ -173,6 +175,7 @@ const MovieRequestModal: React.FC = ({
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
+ tags: requestOverrides?.tags,
});
addToast(
@@ -204,8 +207,7 @@ const MovieRequestModal: React.FC = ({
};
const isOwner = activeRequest
- ? activeRequest.requestedBy.id === user?.id ||
- hasPermission(Permission.MANAGE_REQUESTS)
+ ? activeRequest.requestedBy.id === user?.id
: false;
if (activeRequest?.status === MediaRequestStatus.PENDING) {
@@ -220,27 +222,27 @@ const MovieRequestModal: React.FC = ({
title: data?.title,
}
)}
- onOk={() => updateRequest()}
+ onOk={() => (isOwner ? cancelRequest() : updateRequest())}
okDisabled={isUpdating}
- okText={intl.formatMessage(globalMessages.edit)}
- okButtonType="primary"
- onSecondary={isOwner ? () => cancelRequest() : undefined}
- secondaryDisabled={isUpdating}
- secondaryText={
- isUpdating
- ? intl.formatMessage(globalMessages.canceling)
- : intl.formatMessage(messages.cancel)
+ okText={
+ isOwner
+ ? isUpdating
+ ? intl.formatMessage(globalMessages.canceling)
+ : intl.formatMessage(messages.cancel)
+ : intl.formatMessage(globalMessages.edit)
}
- secondaryButtonType="danger"
+ okButtonType={isOwner ? 'danger' : 'primary'}
cancelText={intl.formatMessage(globalMessages.close)}
iconSvg={ }
>
- {intl.formatMessage(
- is4k ? messages.request4kfrom : messages.requestfrom,
- {
- username: activeRequest.requestedBy.displayName,
- }
- )}
+ {isOwner
+ ? intl.formatMessage(messages.pendingapproval)
+ : intl.formatMessage(
+ is4k ? messages.request4kfrom : messages.requestfrom,
+ {
+ username: activeRequest.requestedBy.displayName,
+ }
+ )}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
@@ -254,6 +256,7 @@ const MovieRequestModal: React.FC
= ({
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
+ tags: editRequest.tags,
}
: undefined
}
diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx
index f1675d77..50df0469 100644
--- a/src/components/RequestModal/SearchByNameModal/index.tsx
+++ b/src/components/RequestModal/SearchByNameModal/index.tsx
@@ -1,14 +1,13 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
-import { SonarrSeries } from '../../../../server/api/sonarr';
+import { SonarrSeries } from '../../../../server/api/servarr/sonarr';
import globalMessages from '../../../i18n/globalMessages';
import Alert from '../../Common/Alert';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
import Modal from '../../Common/Modal';
const messages = defineMessages({
- notvdbid: 'Manual Match Required',
notvdbiddescription:
"We couldn't automatically match your request. Please select the correct match from the list below.",
nosummary: 'No summary for this title was found.',
@@ -69,9 +68,10 @@ const SearchByNameModal: React.FC = ({
}
>
-
- {intl.formatMessage(messages.notvdbiddescription)}
-
+
{!data && !error && }
{data?.slice(0, 6).map((item) => (
diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx
index c840dc7f..e617b174 100644
--- a/src/components/RequestModal/TvRequestModal.tsx
+++ b/src/components/RequestModal/TvRequestModal.tsx
@@ -107,6 +107,7 @@ const TvRequestModal: React.FC = ({
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
+ tags: requestOverrides?.tags,
seasons: selectedSeasons,
});
} else {
@@ -170,6 +171,7 @@ const TvRequestModal: React.FC = ({
rootFolder: requestOverrides.folder,
languageProfileId: requestOverrides.language,
userId: requestOverrides?.user?.id,
+ tags: requestOverrides.tags,
};
}
const response = await axios.post('/api/v1/request', {
@@ -669,6 +671,7 @@ const TvRequestModal: React.FC = ({
profile: editRequest.profileId,
server: editRequest.serverId,
language: editRequest.languageProfileId,
+ tags: editRequest.tags,
}
: undefined
}
diff --git a/src/components/ResetPassword/index.tsx b/src/components/ResetPassword/index.tsx
index 4a2925cd..94005ab1 100644
--- a/src/components/ResetPassword/index.tsx
+++ b/src/components/ResetPassword/index.tsx
@@ -1,13 +1,13 @@
-import React, { useState } from 'react';
-import ImageFader from '../Common/ImageFader';
-import { defineMessages, useIntl } from 'react-intl';
-import LanguagePicker from '../Layout/LanguagePicker';
-import Button from '../Common/Button';
-import { Field, Form, Formik } from 'formik';
-import * as Yup from 'yup';
import axios from 'axios';
-import { useRouter } from 'next/router';
+import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
+import { useRouter } from 'next/router';
+import React, { useState } from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import * as Yup from 'yup';
+import Button from '../Common/Button';
+import ImageFader from '../Common/ImageFader';
+import LanguagePicker from '../Layout/LanguagePicker';
const messages = defineMessages({
passwordreset: 'Password Reset',
@@ -120,9 +120,7 @@ const ResetPassword: React.FC = () => {
id="password"
name="password"
type="password"
- placeholder={intl.formatMessage(
- messages.password
- )}
+ autoComplete="new-password"
className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
@@ -141,8 +139,8 @@ const ResetPassword: React.FC = () => {
diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx
index 2fcbdfc0..ecaff4b0 100644
--- a/src/components/Settings/Notifications/NotificationsDiscord.tsx
+++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx
@@ -18,8 +18,7 @@ const messages = defineMessages({
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
- testsent: 'Test notification sent!',
- notificationtypes: 'Notification Types',
+ testsent: 'Discord test notification sent!',
validationUrl: 'You must provide a valid URL',
});
@@ -35,7 +34,13 @@ const NotificationsDiscord: React.FC = () => {
.nullable()
.url(intl.formatMessage(messages.validationUrl)),
webhookUrl: Yup.string()
- .required(intl.formatMessage(messages.validationUrl))
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationUrl)),
+ otherwise: Yup.string().nullable(),
+ })
.url(intl.formatMessage(messages.validationUrl)),
});
@@ -64,6 +69,7 @@ const NotificationsDiscord: React.FC = () => {
webhookUrl: values.webhookUrl,
},
});
+
addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -163,26 +169,10 @@ const NotificationsDiscord: React.FC = () => {
)}
-
-
-
- {intl.formatMessage(messages.notificationtypes)}
- *
-
-
-
- setFieldValue('types', newTypes)}
- />
-
-
-
-
+ setFieldValue('types', newTypes)}
+ />
diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx
index 04e74323..1daca614 100644
--- a/src/components/Settings/Notifications/NotificationsEmail.tsx
+++ b/src/components/Settings/Notifications/NotificationsEmail.tsx
@@ -13,7 +13,7 @@ import LoadingSpinner from '../../Common/LoadingSpinner';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
- validationSmtpHostRequired: 'You must provide a hostname or IP address',
+ validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
validationSmtpPortRequired: 'You must provide a valid port number',
agentenabled: 'Enable Agent',
emailsender: 'Sender Address',
@@ -24,34 +24,31 @@ const messages = defineMessages({
authPass: 'SMTP Password',
emailsettingssaved: 'Email notification settings saved successfully!',
emailsettingsfailed: 'Email notification settings failed to save.',
- testsent: 'Test notification sent!',
+ testsent: 'Email test notification sent!',
allowselfsigned: 'Allow Self-Signed Certificates',
ssldisabletip:
'SSL should be disabled on standard TLS connections (port 587)',
senderName: 'Sender Name',
- notificationtypes: 'Notification Types',
validationEmail: 'You must provide a valid email address',
- emailNotificationTypesAlert: 'Email Notification Recipients',
emailNotificationTypesAlertDescription:
'Media Requested , Media Automatically Approved , and Media Failed email notifications are sent to all users with the Manage Requests permission.',
emailNotificationTypesAlertDescriptionPt2:
'Media Approved , Media Declined , and Media Available email notifications are sent to the user who submitted the request.',
- pgpPrivateKey: 'PGP Private Key',
+ pgpPrivateKey: 'PGP Private Key',
pgpPrivateKeyTip:
- 'Sign encrypted email messages (PGP password is also required)',
- pgpPassword: 'PGP Password',
+ 'Sign encrypted email messages using OpenPGP ',
+ validationPgpPrivateKey:
+ 'You must provide a valid PGP private key if a PGP password is entered',
+ pgpPassword: 'PGP Password',
pgpPasswordTip:
- 'Sign encrypted email messages (PGP private key is also required)',
+ 'Sign encrypted email messages using OpenPGP ',
+ validationPgpPassword:
+ 'You must provide a PGP password if a PGP private key is entered',
});
-export function PgpLink(msg: string): JSX.Element {
+export function OpenPgpLink(msg: string): JSX.Element {
return (
-
+
{msg}
);
@@ -64,21 +61,60 @@ const NotificationsEmail: React.FC = () => {
'/api/v1/settings/notifications/email'
);
- const NotificationsEmailSchema = Yup.object().shape({
- emailFrom: Yup.string()
- .required(intl.formatMessage(messages.validationEmail))
- .email(intl.formatMessage(messages.validationEmail)),
- smtpHost: Yup.string()
- .required(intl.formatMessage(messages.validationSmtpHostRequired))
- .matches(
- // eslint-disable-next-line
- /^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
- intl.formatMessage(messages.validationSmtpHostRequired)
- ),
- smtpPort: Yup.number()
- .typeError(intl.formatMessage(messages.validationSmtpPortRequired))
- .required(intl.formatMessage(messages.validationSmtpPortRequired)),
- });
+ const NotificationsEmailSchema = Yup.object().shape(
+ {
+ emailFrom: Yup.string()
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationEmail)),
+ otherwise: Yup.string().nullable(),
+ })
+ .email(intl.formatMessage(messages.validationEmail)),
+ smtpHost: Yup.string()
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationSmtpHostRequired)),
+ otherwise: Yup.string().nullable(),
+ })
+ .matches(
+ /^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
+ intl.formatMessage(messages.validationSmtpHostRequired)
+ ),
+ smtpPort: Yup.number()
+ .typeError(intl.formatMessage(messages.validationSmtpPortRequired))
+ .when('enabled', {
+ is: true,
+ then: Yup.number().required(
+ intl.formatMessage(messages.validationSmtpPortRequired)
+ ),
+ otherwise: Yup.number().nullable(),
+ }),
+ pgpPrivateKey: Yup.string()
+ .when('pgpPassword', {
+ is: (value: unknown) => !!value,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationPgpPrivateKey)),
+ otherwise: Yup.string().nullable(),
+ })
+ .matches(
+ /^-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----$/,
+ intl.formatMessage(messages.validationPgpPrivateKey)
+ ),
+ pgpPassword: Yup.string().when('pgpPrivateKey', {
+ is: (value: unknown) => !!value,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationPgpPassword)),
+ otherwise: Yup.string().nullable(),
+ }),
+ },
+ [['pgpPrivateKey', 'pgpPassword']]
+ );
if (!data && !error) {
return ;
@@ -119,6 +155,7 @@ const NotificationsEmail: React.FC = () => {
pgpPassword: values.pgpPassword,
},
});
+
addToast(intl.formatMessage(messages.emailsettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -160,38 +197,40 @@ const NotificationsEmail: React.FC = () => {
return (
<>
+
+ {intl.formatMessage(
+ messages.emailNotificationTypesAlertDescription,
+ {
+ strong: function strong(msg) {
+ return (
+
+ {msg}
+
+ );
+ },
+ }
+ )}
+
+
+ {intl.formatMessage(
+ messages.emailNotificationTypesAlertDescriptionPt2,
+ {
+ strong: function strong(msg) {
+ return (
+
+ {msg}
+
+ );
+ },
+ }
+ )}
+
+ >
+ }
type="info"
- >
-
- {intl.formatMessage(
- messages.emailNotificationTypesAlertDescription,
- {
- strong: function strong(msg) {
- return (
-
- {msg}
-
- );
- },
- }
- )}
-
-
- {intl.formatMessage(
- messages.emailNotificationTypesAlertDescriptionPt2,
- {
- strong: function strong(msg) {
- return (
-
- {msg}
-
- );
- },
- }
- )}
-
-
+ />
-
-
-
- {intl.formatMessage(messages.notificationtypes)}
- *
-
-
-
-
- setFieldValue('types', newTypes)
- }
- />
-
-
-
-
+
setFieldValue('types', newTypes)}
+ />
diff --git a/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx
index f5e940fb..85db2cd5 100644
--- a/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx
+++ b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx
@@ -18,11 +18,9 @@ const messages = defineMessages({
pushbulletSettingsSaved:
'Pushbullet notification settings saved successfully!',
pushbulletSettingsFailed: 'Pushbullet notification settings failed to save.',
- testSent: 'Test notification sent!',
- settingUpPushbullet: 'Setting Up Pushbullet Notifications',
+ testSent: 'Pushbullet test notification sent!',
settingUpPushbulletDescription:
- 'To configure Pushbullet notifications, you will need to create an access token and enter it below.',
- notificationTypes: 'Notification Types',
+ 'To configure Pushbullet notifications, you will need to create an access token .',
});
const NotificationsPushbullet: React.FC = () => {
@@ -33,9 +31,13 @@ const NotificationsPushbullet: React.FC = () => {
);
const NotificationsPushbulletSchema = Yup.object().shape({
- accessToken: Yup.string().required(
- intl.formatMessage(messages.validationAccessTokenRequired)
- ),
+ accessToken: Yup.string().when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationAccessTokenRequired)),
+ otherwise: Yup.string().nullable(),
+ }),
});
if (!data && !error) {
@@ -92,24 +94,25 @@ const NotificationsPushbullet: React.FC = () => {
return (
<>
+ {msg}
+
+ );
+ },
+ }
+ )}
type="info"
- >
- {intl.formatMessage(messages.settingUpPushbulletDescription, {
- CreateAccessTokenLink: function CreateAccessTokenLink(msg) {
- return (
-
- {msg}
-
- );
- },
- })}
-
+ />
-
-
-
- {intl.formatMessage(messages.notificationTypes)}
- *
-
-
-
-
- setFieldValue('types', newTypes)
- }
- />
-
-
-
-
+
setFieldValue('types', newTypes)}
+ />
diff --git a/src/components/Settings/Notifications/NotificationsPushover/index.tsx b/src/components/Settings/Notifications/NotificationsPushover/index.tsx
index 8b07b900..d16559fd 100644
--- a/src/components/Settings/Notifications/NotificationsPushover/index.tsx
+++ b/src/components/Settings/Notifications/NotificationsPushover/index.tsx
@@ -14,16 +14,14 @@ import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
agentenabled: 'Enable Agent',
accessToken: 'Application/API Token',
- userToken: 'User Key',
+ userToken: 'User or Group Key',
validationAccessTokenRequired: 'You must provide a valid application token',
validationUserTokenRequired: 'You must provide a valid user key',
pushoversettingssaved: 'Pushover notification settings saved successfully!',
pushoversettingsfailed: 'Pushover notification settings failed to save.',
- testsent: 'Test notification sent!',
- settinguppushover: 'Setting Up Pushover Notifications',
+ testsent: 'Pushover test notification sent!',
settinguppushoverDescription:
- 'To configure Pushover notifications, you will need to register an application and enter the API token below. (You can use one of our official icons on GitHub .) You will also need your user key.',
- notificationtypes: 'Notification Types',
+ 'To configure Pushover notifications, you will need to register an application and enter the API token below. (You can use one of the official Overseerr icons on GitHub .)',
});
const NotificationsPushover: React.FC = () => {
@@ -35,13 +33,25 @@ const NotificationsPushover: React.FC = () => {
const NotificationsPushoverSchema = Yup.object().shape({
accessToken: Yup.string()
- .required(intl.formatMessage(messages.validationAccessTokenRequired))
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationAccessTokenRequired)),
+ otherwise: Yup.string().nullable(),
+ })
.matches(
/^[a-z\d]{30}$/i,
intl.formatMessage(messages.validationAccessTokenRequired)
),
userToken: Yup.string()
- .required(intl.formatMessage(messages.validationUserTokenRequired))
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationUserTokenRequired)),
+ otherwise: Yup.string().nullable(),
+ })
.matches(
/^[a-z\d]{30}$/i,
intl.formatMessage(messages.validationUserTokenRequired)
@@ -105,15 +115,12 @@ const NotificationsPushover: React.FC = () => {
return (
<>
- {intl.formatMessage(messages.settinguppushoverDescription, {
+ title={intl.formatMessage(messages.settinguppushoverDescription, {
RegisterApplicationLink: function RegisterApplicationLink(msg) {
return (
@@ -125,7 +132,7 @@ const NotificationsPushover: React.FC = () => {
return (
@@ -134,7 +141,8 @@ const NotificationsPushover: React.FC = () => {
);
},
})}
-
+ type="info"
+ />
-
-
-
- {intl.formatMessage(messages.notificationtypes)}
- *
-
-
-
-
- setFieldValue('types', newTypes)
- }
- />
-
-
-
-
+
setFieldValue('types', newTypes)}
+ />
diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx
index 158059ce..e71143c0 100644
--- a/src/components/Settings/Notifications/NotificationsSlack/index.tsx
+++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx
@@ -16,11 +16,9 @@ const messages = defineMessages({
webhookUrl: 'Webhook URL',
slacksettingssaved: 'Slack notification settings saved successfully!',
slacksettingsfailed: 'Slack notification settings failed to save.',
- testsent: 'Test notification sent!',
- settingupslack: 'Setting Up Slack Notifications',
+ testsent: 'Slack test notification sent!',
settingupslackDescription:
'To configure Slack notifications, you will need to create an Incoming Webhook integration and enter the webhook URL below.',
- notificationtypes: 'Notification Types',
validationWebhookUrl: 'You must provide a valid URL',
});
@@ -33,7 +31,13 @@ const NotificationsSlack: React.FC = () => {
const NotificationsSlackSchema = Yup.object().shape({
webhookUrl: Yup.string()
- .required(intl.formatMessage(messages.validationWebhookUrl))
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationWebhookUrl)),
+ otherwise: Yup.string().nullable(),
+ })
.url(intl.formatMessage(messages.validationWebhookUrl)),
});
@@ -43,13 +47,13 @@ const NotificationsSlack: React.FC = () => {
return (
<>
-
- {intl.formatMessage(messages.settingupslackDescription, {
+
@@ -58,7 +62,8 @@ const NotificationsSlack: React.FC = () => {
);
},
})}
-
+ type="info"
+ />
{
)}
-
-
-
- {intl.formatMessage(messages.notificationtypes)}
- *
-
-
-
-
- setFieldValue('types', newTypes)
- }
- />
-
-
-
-
+ setFieldValue('types', newTypes)}
+ />
diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx
index 00e8e443..72b8d4dd 100644
--- a/src/components/Settings/Notifications/NotificationsTelegram.tsx
+++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx
@@ -14,17 +14,17 @@ import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
agentenabled: 'Enable Agent',
botUsername: 'Bot Username',
+ botUsernameTip:
+ 'Allow users to start a chat with the bot and configure their own personal notifications',
botAPI: 'Bot Authentication Token',
chatId: 'Chat ID',
validationBotAPIRequired: 'You must provide a bot authentication token',
validationChatIdRequired: 'You must provide a valid chat ID',
telegramsettingssaved: 'Telegram notification settings saved successfully!',
telegramsettingsfailed: 'Telegram notification settings failed to save.',
- testsent: 'Test notification sent!',
- settinguptelegram: 'Setting Up Telegram Notifications',
+ testsent: 'Telegram test notification sent!',
settinguptelegramDescription:
'To configure Telegram notifications, you will need to create a bot and get the bot API key. Additionally, you will need the chat ID for the chat to which you would like to send notifications. You can find this by adding @get_id_bot to the chat and issuing the /my_id command.',
- notificationtypes: 'Notification Types',
sendSilently: 'Send Silently',
sendSilentlyTip: 'Send notifications with no sound',
});
@@ -37,13 +37,23 @@ const NotificationsTelegram: React.FC = () => {
);
const NotificationsTelegramSchema = Yup.object().shape({
- botAPI: Yup.string().required(
- intl.formatMessage(messages.validationBotAPIRequired)
- ),
+ botAPI: Yup.string().when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationBotAPIRequired)),
+ otherwise: Yup.string().nullable(),
+ }),
chatId: Yup.string()
- .required(intl.formatMessage(messages.validationChatIdRequired))
+ .when('enabled', {
+ is: true,
+ then: Yup.string()
+ .nullable()
+ .required(intl.formatMessage(messages.validationChatIdRequired)),
+ otherwise: Yup.string().nullable(),
+ })
.matches(
- /^[-]?\d+$/,
+ /^-?\d+$/,
intl.formatMessage(messages.validationChatIdRequired)
),
});
@@ -75,6 +85,7 @@ const NotificationsTelegram: React.FC = () => {
botUsername: values.botUsername,
},
});
+
addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success',
autoDismiss: true,
@@ -111,15 +122,12 @@ const NotificationsTelegram: React.FC = () => {
return (
<>
- {intl.formatMessage(messages.settinguptelegramDescription, {
+ title={intl.formatMessage(messages.settinguptelegramDescription, {
CreateBotLink: function CreateBotLink(msg) {
return (
@@ -131,7 +139,7 @@ const NotificationsTelegram: React.FC = () => {
return (
@@ -143,7 +151,8 @@ const NotificationsTelegram: React.FC = () => {
return {msg};
},
})}
-
+ type="info"
+ />