Merge branch 'develop'

pull/2563/head
sct 4 years ago
commit c9c429d027
No known key found for this signature in database
GPG Key ID: 77D146606D30DCCD

@ -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": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

1
.gitattributes vendored

@ -0,0 +1 @@
* text eol=lf

@ -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,7 +38,7 @@ 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 }}
@ -54,7 +54,7 @@ jobs:
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:

@ -48,7 +48,7 @@ jobs:
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: yarn
- name: Release

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

@ -12,7 +12,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-46-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-49-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -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
<img src="./public/preview.jpg">
@ -158,6 +133,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
<td align="center"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
<td align="center"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
<td align="center"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
<td align="center"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
</tr>
</table>

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

@ -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).
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`.
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:
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
# 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 %}

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

@ -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.
Try to answer the following questions:
- 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:
- 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 `<Overseeerr-install-directory>/logs/overseerr.log`
1. Locate the current log file at `<your Overseeerr config directory>/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).

@ -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 &rarr; About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
### Can I make 4K requests?
You can find the changelog in the **Settings &rarr; 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).
**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings &rarr; 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!
**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 &rarr; Services**.
### Where can I find the logs?
### Where can I find the log files?
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
Please see [these instructions on how to locate and share your logs](./asking-for-support#how-can-i-share-my-logs).
## User management
## Users
### Why can't I see all my Plex users?
### Why can't I see all of 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 &rarr; 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.
### Series requests keep failing!
If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr.
### Approved series requests keep failing!
Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
**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.
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 &rarr; 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).

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

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

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

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

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

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

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

@ -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,17 +31,41 @@ 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:
- Media Requested
- Media Automatically Approved
- Media Failed
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
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
These variables are only included in media related notifications, such as requests.
- `{{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.
@ -50,10 +74,13 @@ These variables are only included in media related notifications, such as reques
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
### Special
#### Request
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
The `{{request}}` special variables are only included in request-related notifications.
- `{{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.
- `{{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).

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

@ -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 &rarr; 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.

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 678 KiB

@ -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<GitHubRelease[]> {
try {
const data = await this.get<GitHubRelease[]>(
'/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<GithubCommit[]> {
try {
const data = await this.get<GithubCommit[]>(
'/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;

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

@ -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<QueueItemAppendT> {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: (QueueItem & QueueItemAppendT)[];
}
class ServarrBase<QueueItemAppendT> 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<QualityProfile[]> => {
try {
const data = await this.getRolling<QualityProfile[]>(
`/qualityProfile`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve profiles: ${e.message}`
);
}
};
public getRootFolders = async (): Promise<RootFolder[]> => {
try {
const data = await this.getRolling<RootFolder[]>(
`/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<QueueResponse<QueueItemAppendT>>(
`/queue`
);
return response.data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
);
}
};
public getTags = async (): Promise<Tag[]> => {
try {
const response = await this.axios.get<Tag[]>(`/tag`);
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
);
}
};
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try {
const response = await this.axios.post<Tag>(`/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<string, unknown>
): Promise<void> {
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;

@ -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<RadarrMovie[]> => {
@ -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<RadarrProfile[]> => {
try {
const data = await this.getRolling<RadarrProfile[]>(
`/qualityProfile`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
};
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
try {
const data = await this.getRolling<RadarrRootFolder[]>(
`/rootfolder`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
}
};
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default RadarrAPI;

@ -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<SonarrSeries[]> {
@ -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<SonarrSeries>(
@ -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<SonarrProfile[]> {
try {
const data = await this.getRolling<SonarrProfile[]>(
'/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<SonarrRootFolder[]> {
try {
const data = await this.getRolling<SonarrRootFolder[]>(
'/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<LanguageProfile[]> {
try {
const data = await this.getRolling<LanguageProfile[]>(
@ -356,25 +260,6 @@ class SonarrAPI extends ExternalAPI {
await this.runCommand('SeriesSearch', { seriesId });
}
private async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
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<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default SonarrAPI;

@ -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<TmdbGenresResult>(
'/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<TmdbGenresResult>(
'/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) {

@ -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 downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { MediaRequest } from './MediaRequest';
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';
@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}`
);

@ -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 { 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 SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications';
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<MediaRequest>) {
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,

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

@ -5,6 +5,10 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import {
hasNotificationAgentEnabled,
NotificationAgentType,
} from '../lib/notifications/agenttypes';
import { User } from './User';
@Entity()
@ -20,24 +24,28 @@ export class UserSettings {
@JoinColumn()
public user: User;
@Column({ default: true })
public enableNotifications: boolean;
@Column({ nullable: true })
public discordId?: string;
public region?: string;
@Column({ nullable: true })
public telegramChatId?: string;
public originalLanguage?: string;
@Column({ type: 'integer', default: NotificationAgentType.EMAIL })
public notificationAgents = NotificationAgentType.EMAIL;
@Column({ nullable: true })
public telegramSendSilently?: boolean;
public pgpKey?: string;
@Column({ nullable: true })
public region?: string;
public discordId?: string;
@Column({ nullable: true })
public originalLanguage?: string;
public telegramChatId?: string;
@Column({ nullable: true })
public pgpKey?: string;
public telegramSendSilently?: boolean;
public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean {
return !!hasNotificationAgentEnabled(agent, this.notificationAgents);
}
}

@ -14,7 +14,6 @@ export interface PlexConnection {
local: boolean;
status?: number;
message?: string;
host?: string;
}
export interface PlexDevice {

@ -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<RadarrRootFolder>[];
profiles: QualityProfile[];
rootFolders: Partial<RootFolder>[];
languageProfiles?: LanguageProfile[];
tags: Tag[];
}

@ -43,3 +43,10 @@ export interface CacheItem {
vsize: number;
};
}
export interface StatusResponse {
version: string;
commitTag: string;
updateAvailable: boolean;
commitsBehind: number;
}

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

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

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

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

@ -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<boolean> {
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,35 +224,52 @@ class DiscordAgent
return false;
}
const mentionedUsers: string[] = [];
let content = undefined;
if (payload.notifyUser) {
// Mention user who submitted the request
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true) &&
payload.notifyUser.settings?.hasNotificationAgentEnabled(
NotificationAgentType.DISCORD
) &&
payload.notifyUser.settings?.discordId
) {
mentionedUsers.push(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();
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, {
username: botUsername,
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;
}
}

@ -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,95 +37,98 @@ 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'
),
if (type === Notification.TEST_NOTIFICATION) {
return {
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: user.email,
to: toEmail,
},
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,
body: payload.message,
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;
}
};
}
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);
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;
}
email.send({
return {
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
to: toEmail,
},
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'
}:`,
requestType,
body,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
@ -132,282 +137,94 @@ class EmailAgent
: 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;
};
}
return undefined;
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true)
!payload.notifyUser.settings ||
payload.notifyUser.settings.hasNotificationAgentEnabled(
NotificationAgentType.EMAIL
)
) {
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`,
},
logger.debug('Sending email notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
}
return true;
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('Email notification failed to send', {
logger.error('Error sending email notification', {
label: 'Notifications',
message: e.message,
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
return false;
}
}
private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(user.settings?.enableNotifications ?? true)
(!user.settings ||
user.settings.hasNotificationAgentEnabled(
NotificationAgentType.EMAIL
))
)
.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', {
.map(async (user) => {
logger.debug('Sending email notification', {
label: 'Notifications',
message: e.message,
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
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;
const email = new PreparedEmail(
this.getSettings(),
user.settings?.pgpKey
);
await email.send(this.buildMessage(type, payload, user.email));
} catch (e) {
logger.error('Email notification failed to send', {
logger.error('Error sending email notification', {
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,
},
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
}
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending email notification', { label: 'Notifications' });
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;
})
);
}
return true;

@ -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<boolean> {
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;
}
}

@ -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<boolean> {
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;
}
}

@ -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<boolean> {
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;
}
}

@ -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,63 +156,99 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
const endpoint = `${this.baseUrl}bot${this.getSettings().options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
// Send system notification
await (payload.image
? axios.post(endpoint, {
try {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
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));
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
// Send user notification
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true) &&
payload.notifyUser.settings?.hasNotificationAgentEnabled(
NotificationAgentType.TELEGRAM
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
await (payload.image
? axios.post(endpoint, {
// 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}`,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload));
}
return true;
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
message: e.message,
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}
return true;
}
}
export default TelegramAgent;

@ -30,6 +30,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
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<boolean> {
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;
}
}

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

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

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

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

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

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface {
name = 'CreateTagsFieldonMediaRequest1617624225464';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface {
name = 'AddUserSettingsNotificationAgentsField1617730837489';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

@ -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 { 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 requestRoutes from './request';
import movieRoutes from './movie';
import tvRoutes from './tv';
import mediaRoutes from './media';
import movieRoutes from './movie';
import personRoutes from './person';
import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import requestRoutes from './request';
import searchRoutes from './search';
import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
import TheMovieDb from '../api/themoviedb';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
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<unknown, StatusResponse>('/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);

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

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

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

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

@ -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<string, unknown>,
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,7 +68,9 @@ radarrRoutes.post('/test', async (req, res, next) => {
}
});
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id',
(req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@ -70,9 +78,7 @@ radarrRoutes.put<{ 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' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
@ -93,9 +99,10 @@ radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
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);

@ -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', {

@ -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.',

@ -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,11 +261,10 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
);
userSettingsRoutes.post<
{ id: string },
UserSettingsNotificationsResponse,
UserSettingsNotificationsResponse
>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
@ -283,33 +287,36 @@ userSettingsRoutes.post<
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
enableNotifications: req.body.enableNotifications,
notificationAgents:
req.body.notificationAgents ?? NotificationAgentType.EMAIL,
pgpKey: req.body.pgpKey,
discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently,
pgpKey: req.body.pgpKey,
});
} else {
user.settings.enableNotifications = req.body.enableNotifications;
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;
user.settings.pgpKey = req.body.pgpKey;
}
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,
notificationAgents: user.settings?.notificationAgents,
pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
});
} catch (e) {
next({ status: 500, message: e.message });
}
});
}
);
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',

@ -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;\
')

@ -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;\
')

@ -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;\
')

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

@ -4,7 +4,6 @@ import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
dockerVolumeMissing: 'Docker Volume Mount Missing',
dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> 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 && (
<Alert title={intl.formatMessage(messages.dockerVolumeMissing)}>
{intl.formatMessage(messages.dockerVolumeMissingDescription, {
<Alert
title={intl.formatMessage(messages.dockerVolumeMissingDescription, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
appDataPath: data.appDataPath,
})}
</Alert>
/>
)}
</>
);

@ -3,15 +3,16 @@ import { withProperties } from '../../../utils/typeHelpers';
interface ListItemProps {
title: string;
className?: string;
}
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
return (
<div>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="block text-sm font-medium text-gray-400">{title}</dt>
<dd className="flex text-sm text-white sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>
<span className={`flex-grow ${className}`}>{children}</span>
</dd>
</div>
</div>

@ -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 <option value={route}>{children}</option>;
}
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 (
<Link href={route}>
<a
className={`${linkClasses} ${
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};
const SettingsTabs: React.FC<{
tabType?: 'default' | 'button';
settingsRoutes: SettingsRoute[];
}> = ({ tabType = 'default', settingsRoutes }) => {
const router = useRouter();
const { user: currentUser } = useUser();
return (
<>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a Tab
</label>
<select
onChange={(e) => {
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) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
hidden={route.hidden ?? false}
isMobile
key={`mobile-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</select>
</div>
{tabType === 'button' ? (
<div className="hidden overflow-x-scroll overflow-y-hidden sm:block hide-scrollbar">
<nav className="flex space-x-4" aria-label="Tabs">
{settingsRoutes.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
hidden={route.hidden ?? false}
key={`button-settings-link-${index}`}
>
{route.content ?? route.text}
</SettingsLink>
))}
</nav>
</div>
) : (
<div className="hidden sm:block">
<div className="border-b border-gray-600">
<nav className="flex -mb-px">
{settingsRoutes
.filter(
(route) =>
!route.hidden &&
(route.requiredPermission
? hasPermission(
route.requiredPermission,
currentUser?.permissions ?? 0,
route.permissionType
)
: true)
)
.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
</div>
)}
</>
);
};
export default SettingsTabs;

@ -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={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)}
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
<MediaSlider
sliderKey="trending"

@ -5,6 +5,7 @@ import { defineMessages, useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import { Permission, useUser } from '../../../hooks/useUser';
import Transition from '../../Transition';
import VersionStatus from '../VersionStatus';
const messages = defineMessages({
dashboard: 'Discover',
@ -122,6 +123,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const intl = useIntl();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
return (
<>
<div className="md:hidden">
@ -172,7 +174,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</div>
<div
ref={navRef}
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
className="flex flex-col flex-1 h-0 pt-5 pb-8 overflow-y-auto sm:pb-4"
>
<div className="flex items-center flex-shrink-0 px-4">
<span className="text-xl text-gray-50">
@ -181,7 +183,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</a>
</span>
</div>
<nav className="px-2 mt-5 space-y-1">
<nav className="flex-1 px-2 mt-5 space-y-1">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
@ -221,6 +223,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
);
})}
</nav>
{hasPermission(Permission.ADMIN) && (
<VersionStatus onClick={() => setClosed()} />
)}
</div>
</div>
<div className="flex-shrink-0 w-14">
@ -273,6 +278,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
);
})}
</nav>
{hasPermission(Permission.ADMIN) && <VersionStatus />}
</div>
</div>
</div>

@ -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<VersionStatusProps> = ({ onClick }) => {
const intl = useIntl();
const { data } = useSWR<StatusResponse>('/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 (
<Link href="/settings/about">
<a
onClick={onClick}
onKeyDown={(e) => {
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' ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
) : data.version.startsWith('develop-') ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
)}
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local' ? (
'(⌐■_■)'
) : data.commitsBehind > 0 ? (
intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind,
})
) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate)
) : (
<code className="p-0 bg-transparent">
{data.version.replace('develop-', '')}
</code>
)}
</span>
</div>
{data.updateAvailable && (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 11l3-3m0 0l3 3m-3-3v8m0-13a9 9 0 110 18 9 9 0 010-18z"
/>
</svg>
)}
</a>
</Link>
);
};
export default VersionStatus;

@ -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 }) => {
</div>
<div className="flex-1 ml-3 md:flex md:justify-between">
<p className="text-sm leading-5 text-white">
{intl.formatMessage(messages.alphawarning)}
{intl.formatMessage(messages.betawarning)}
</p>
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
<a

@ -84,6 +84,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>

@ -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.',

@ -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<NotificationTypeProps> = ({
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="font-medium">
<label htmlFor={option.id} className="font-medium text-white">
{option.name}
</label>
<p className="text-gray-500">{option.description}</p>

@ -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,7 +112,14 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
];
return (
<>
<div role="group" aria-labelledby="group-label" className="form-group">
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationTypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
{types.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
@ -120,7 +128,10 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
onUpdate={onUpdate}
/>
))}
</>
</div>
</div>
</div>
</div>
);
};

@ -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<RequestCardErrorProps> = ({ 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 (
<div className="relative p-4 bg-gray-800 ring-1 ring-red-500 rounded-xl w-72 sm:w-96">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center w-full h-full px-10">
<div className="w-full text-xs text-center text-gray-300 whitespace-normal sm:text-sm">
{intl.formatMessage(messages.mediaerror)}
</div>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<div className="mt-4">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
</div>
</div>
</div>
</div>
);
};
interface RequestCardProps {
request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
@ -88,11 +143,11 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
if (!requestData && !requestError) {
return <RequestCardPlaceholder />;
return <RequestCardError />;
}
if (!title || !requestData) {
return <RequestCardPlaceholder />;
return <RequestCardError mediaId={requestData?.media.id} />;
}
return (
@ -192,6 +247,8 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
).length > 0
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/>
)}
</div>

@ -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<RequestItemErroProps> = ({
mediaId,
revalidateList,
}) => {
const intl = useIntl();
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
revalidateList();
};
return (
<div className="flex flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-32">
<span className="text-sm text-center text-gray-300 lg:text-left">
{intl.formatMessage(messages.mediaerror)}
</span>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<div className="mt-4 lg:ml-4 lg:mt-0">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
</div>
);
};
interface RequestItemProps {
request: MediaRequest;
revalidateList: () => void;
@ -48,7 +102,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
});
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<RequestItemProps> = ({
if (!title || !requestData) {
return (
<div
className="w-full h-64 bg-gray-800 rounded-xl xl:h-32 animate-pulse"
ref={ref}
<RequestItemError
mediaId={requestData?.media.id}
revalidateList={revalidateList}
/>
);
}
@ -315,6 +369,31 @@ const RequestItem: React.FC<RequestItemProps> = ({
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 space-y-2 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<svg
className="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="block">
{intl.formatMessage(messages.cancelRequest)}
</span>
</ConfirmButton>
)}
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&

@ -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<AdvancedRequesterProps> = ({
defaultOverrides?.language ?? -1
);
const [selectedTags, setSelectedTags] = useState<number[]>(
defaultOverrides?.tags ?? []
);
const {
data: serverData,
isValidating,
@ -150,6 +168,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
? 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<AdvancedRequesterProps> = ({
) {
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<AdvancedRequesterProps> = ({
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
language: selectedLanguage ?? undefined,
tags: selectedTags,
});
}
}, [
@ -232,6 +251,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
selectedProfile,
selectedUser,
selectedLanguage,
selectedTags,
]);
if (!data && !error) {
@ -436,9 +456,48 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</div>
</>
)}
{!!data && selectedServer !== null && (
<div className="mt-0 sm:mt-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
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<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
}}
noOptionsMessage={() => intl.formatMessage(messages.notagoptions)}
/>
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="first:mt-0 sm:mt-4">
<div className="mt-2 first:mt-0">
<Listbox
as="div"
value={selectedUser}

@ -33,6 +33,7 @@ const messages = defineMessages({
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
@ -84,6 +85,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -173,6 +175,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
addToast(
@ -204,8 +207,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
};
const isOwner = activeRequest
? activeRequest.requestedBy.id === user?.id ||
hasPermission(Permission.MANAGE_REQUESTS)
? activeRequest.requestedBy.id === user?.id
: false;
if (activeRequest?.status === MediaRequestStatus.PENDING) {
@ -220,22 +222,22 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
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
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={<DownloadIcon className="w-6 h-6" />}
>
{intl.formatMessage(
{isOwner
? intl.formatMessage(messages.pendingapproval)
: intl.formatMessage(
is4k ? messages.request4kfrom : messages.requestfrom,
{
username: activeRequest.requestedBy.displayName,
@ -254,6 +256,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}
: undefined
}

@ -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<SearchByNameModalProps> = ({
</svg>
}
>
<Alert title={intl.formatMessage(messages.notvdbid)} type="info">
{intl.formatMessage(messages.notvdbiddescription)}
</Alert>
<Alert
title={intl.formatMessage(messages.notvdbiddescription)}
type="info"
/>
{!data && !error && <SmallLoadingSpinner />}
<div className="grid grid-cols-1 gap-4 pb-2 md:grid-cols-2">
{data?.slice(0, 6).map((item) => (

@ -107,6 +107,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
});
} else {
@ -170,6 +171,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
rootFolder: requestOverrides.folder,
languageProfileId: requestOverrides.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -669,6 +671,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
profile: editRequest.profileId,
server: editRequest.serverId,
language: editRequest.languageProfileId,
tags: editRequest.tags,
}
: undefined
}

@ -1,13 +1,13 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
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 Link from 'next/link';
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"
/>
</div>
@ -141,8 +139,8 @@ const ResetPassword: React.FC = () => {
<Field
id="confirmPassword"
name="confirmPassword"
placeholder="Confirm Password"
type="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"
/>
</div>

@ -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 = () => {
)}
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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:
'<strong>Media Requested</strong>, <strong>Media Automatically Approved</strong>, and <strong>Media Failed</strong> email notifications are sent to all users with the <strong>Manage Requests</strong> permission.',
emailNotificationTypesAlertDescriptionPt2:
'<strong>Media Approved</strong>, <strong>Media Declined</strong>, and <strong>Media Available</strong> email notifications are sent to the user who submitted the request.',
pgpPrivateKey: '<PgpLink>PGP</PgpLink> Private Key',
pgpPrivateKey: 'PGP Private Key',
pgpPrivateKeyTip:
'Sign encrypted email messages (PGP password is also required)',
pgpPassword: '<PgpLink>PGP</PgpLink> Password',
'Sign encrypted email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
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 <OpenPgpLink>OpenPGP</OpenPgpLink>',
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 (
<a
href="https://www.openpgp.org/"
target="_blank"
rel="noreferrer"
className="text-gray-100 underline transition duration-300 hover:text-white"
>
<a href="https://www.openpgp.org/" target="_blank" rel="noreferrer">
{msg}
</a>
);
@ -64,21 +61,60 @@ const NotificationsEmail: React.FC = () => {
'/api/v1/settings/notifications/email'
);
const NotificationsEmailSchema = Yup.object().shape({
const NotificationsEmailSchema = Yup.object().shape(
{
emailFrom: Yup.string()
.required(intl.formatMessage(messages.validationEmail))
.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()
.required(intl.formatMessage(messages.validationSmtpHostRequired))
.when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationSmtpHostRequired)),
otherwise: Yup.string().nullable(),
})
.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)),
});
.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 <LoadingSpinner />;
@ -119,6 +155,7 @@ const NotificationsEmail: React.FC = () => {
pgpPassword: values.pgpPassword,
},
});
addToast(intl.formatMessage(messages.emailsettingssaved), {
appearance: 'success',
autoDismiss: true,
@ -160,16 +197,15 @@ const NotificationsEmail: React.FC = () => {
return (
<>
<Alert
title={intl.formatMessage(messages.emailNotificationTypesAlert)}
type="info"
>
title={
<>
<p className="mb-2">
{intl.formatMessage(
messages.emailNotificationTypesAlertDescription,
{
strong: function strong(msg) {
return (
<strong className="font-normal text-indigo-100">
<strong className="font-semibold text-indigo-100">
{msg}
</strong>
);
@ -183,7 +219,7 @@ const NotificationsEmail: React.FC = () => {
{
strong: function strong(msg) {
return (
<strong className="font-normal text-indigo-100">
<strong className="font-semibold text-indigo-100">
{msg}
</strong>
);
@ -191,7 +227,10 @@ const NotificationsEmail: React.FC = () => {
}
)}
</p>
</Alert>
</>
}
type="info"
/>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
@ -323,15 +362,15 @@ const NotificationsEmail: React.FC = () => {
<div className="form-row">
<label htmlFor="pgpPrivateKey" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPrivateKey, {
PgpLink: PgpLink,
})}
{intl.formatMessage(messages.pgpPrivateKey)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPrivateKeyTip)}
{intl.formatMessage(messages.pgpPrivateKeyTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
@ -340,23 +379,27 @@ const NotificationsEmail: React.FC = () => {
id="pgpPrivateKey"
name="pgpPrivateKey"
as="textarea"
rows="3"
rows="10"
className="font-mono text-xs"
/>
</div>
{errors.pgpPrivateKey && touched.pgpPrivateKey && (
<div className="error">{errors.pgpPrivateKey}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="pgpPassword" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPassword, {
PgpLink: PgpLink,
})}
{intl.formatMessage(messages.pgpPassword)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPasswordTip)}
{intl.formatMessage(messages.pgpPasswordTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
@ -368,30 +411,15 @@ const NotificationsEmail: React.FC = () => {
autoComplete="off"
/>
</div>
{errors.pgpPassword && touched.pgpPassword && (
<div className="error">{errors.pgpPassword}</div>
)}
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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 <CreateAccessTokenLink>create an access token</CreateAccessTokenLink> and enter it below.',
notificationTypes: 'Notification Types',
'To configure Pushbullet notifications, you will need to <CreateAccessTokenLink>create an access token</CreateAccessTokenLink>.',
});
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,15 +94,14 @@ const NotificationsPushbullet: React.FC = () => {
return (
<>
<Alert
title={intl.formatMessage(messages.settingUpPushbullet)}
type="info"
>
{intl.formatMessage(messages.settingUpPushbulletDescription, {
title={intl.formatMessage(
messages.settingUpPushbulletDescription,
{
CreateAccessTokenLink: function CreateAccessTokenLink(msg) {
return (
<a
href="https://www.pushbullet.com/#settings"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -108,8 +109,10 @@ const NotificationsPushbullet: React.FC = () => {
</a>
);
},
})}
</Alert>
}
)}
type="info"
/>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
@ -138,28 +141,10 @@ const NotificationsPushbullet: React.FC = () => {
)}
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationTypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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 <RegisterApplicationLink>register an application</RegisterApplicationLink> and enter the API token below. (You can use one of our <IconLink>official icons on GitHub</IconLink>.) You will also need your user key.',
notificationtypes: 'Notification Types',
'To configure Pushover notifications, you will need to <RegisterApplicationLink>register an application</RegisterApplicationLink> and enter the API token below. (You can use one of the <IconLink>official Overseerr icons on GitHub</IconLink>.)',
});
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 (
<>
<Alert
title={intl.formatMessage(messages.settinguppushover)}
type="info"
>
{intl.formatMessage(messages.settinguppushoverDescription, {
title={intl.formatMessage(messages.settinguppushoverDescription, {
RegisterApplicationLink: function RegisterApplicationLink(msg) {
return (
<a
href="https://pushover.net/apps/build"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -125,7 +132,7 @@ const NotificationsPushover: React.FC = () => {
return (
<a
href="https://github.com/sct/overseerr/tree/develop/public"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -134,7 +141,8 @@ const NotificationsPushover: React.FC = () => {
);
},
})}
</Alert>
type="info"
/>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
@ -182,28 +190,10 @@ const NotificationsPushover: React.FC = () => {
)}
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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 <WebhookLink>Incoming Webhook</WebhookLink> 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 (
<>
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
{intl.formatMessage(messages.settingupslackDescription, {
<Alert
title={intl.formatMessage(messages.settingupslackDescription, {
WebhookLink: function WebhookLink(msg) {
return (
<a
href="https://my.slack.com/services/new/incoming-webhook/"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -58,7 +62,8 @@ const NotificationsSlack: React.FC = () => {
);
},
})}
</Alert>
type="info"
/>
<Formik
initialValues={{
enabled: data.enabled,
@ -136,28 +141,10 @@ const NotificationsSlack: React.FC = () => {
)}
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -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 <CreateBotLink>create a bot</CreateBotLink> 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 <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat and issuing the <code>/my_id</code> 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 (
<>
<Alert
title={intl.formatMessage(messages.settinguptelegram)}
type="info"
>
{intl.formatMessage(messages.settinguptelegramDescription, {
title={intl.formatMessage(messages.settinguptelegramDescription, {
CreateBotLink: function CreateBotLink(msg) {
return (
<a
href="https://core.telegram.org/bots#6-botfather"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -131,7 +139,7 @@ const NotificationsTelegram: React.FC = () => {
return (
<a
href="https://telegram.me/get_id_bot"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -143,7 +151,8 @@ const NotificationsTelegram: React.FC = () => {
return <code className="bg-opacity-50">{msg}</code>;
},
})}
</Alert>
type="info"
/>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
@ -156,6 +165,9 @@ const NotificationsTelegram: React.FC = () => {
<div className="form-row">
<label htmlFor="botUsername" className="text-label">
{intl.formatMessage(messages.botUsername)}
<span className="label-tip">
{intl.formatMessage(messages.botUsernameTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
@ -224,28 +236,10 @@ const NotificationsTelegram: React.FC = () => {
/>
</div>
</div>
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</span>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -32,6 +32,9 @@ const defaultPayload = {
'{{extra}}': [],
'{{request}}': {
request_id: '{{request_id}}',
requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}',
},
};
@ -42,8 +45,7 @@ const messages = defineMessages({
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
webhooksettingssaved: 'Webhook notification settings saved successfully!',
webhooksettingsfailed: 'Webhook notification settings failed to save.',
testsent: 'Test notification sent!',
notificationtypes: 'Notification Types',
testsent: 'Webhook test notification sent!',
resetPayload: 'Reset to Default',
resetPayloadSuccess: 'JSON payload reset successfully!',
customJson: 'JSON Payload',
@ -60,14 +62,26 @@ const NotificationsWebhook: React.FC = () => {
const NotificationsWebhookSchema = 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(),
})
.matches(
// eslint-disable-next-line
// eslint-disable-next-line no-useless-escape
/^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([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]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i,
intl.formatMessage(messages.validationWebhookUrl)
),
jsonPayload: Yup.string()
.required(intl.formatMessage(messages.validationJsonPayloadRequired))
.when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationJsonPayloadRequired)),
otherwise: Yup.string().nullable(),
})
.test(
'validate-json',
intl.formatMessage(messages.validationJsonPayloadRequired),
@ -255,32 +269,10 @@ const NotificationsWebhook: React.FC = () => {
</div>
</div>
</div>
<div className="mt-8">
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="sm:grid sm:grid-cols-4 sm:gap-4">
<div>
<div id="group-label" className="group-label">
{intl.formatMessage(messages.notificationtypes)}
<span className="label-required">*</span>
</div>
</div>
<div className="form-input">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
</div>
</div>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -1,7 +1,9 @@
import axios from 'axios';
import { Field, Formik } from 'formik';
import dynamic from 'next/dynamic';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import type { RadarrSettings } from '../../../../server/lib/settings';
@ -9,9 +11,18 @@ import globalMessages from '../../../i18n/globalMessages';
import Modal from '../../Common/Modal';
import Transition from '../../Transition';
type OptionType = {
value: string;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
editradarr: 'Edit Radarr Server',
edit4kradarr: 'Edit 4K Radarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
@ -24,6 +35,7 @@ const messages = defineMessages({
toastRadarrTestFailure: 'Failed to connect to Radarr.',
add: 'Add Server',
defaultserver: 'Default Server',
default4kserver: 'Default 4K Server',
servername: 'Server Name',
servernamePlaceholder: 'A Radarr Server',
hostname: 'Hostname or IP Address',
@ -47,11 +59,16 @@ const messages = defineMessages({
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
preventSearch: 'Disable Auto-Search',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
notagoptions: 'No tags.',
selecttags: 'Select tags',
});
interface TestResponse {
@ -63,6 +80,10 @@ interface TestResponse {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
}
interface RadarrModalProps {
@ -84,6 +105,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
@ -92,7 +114,6 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.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.validationHostnameRequired)
),
@ -194,7 +215,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
initialLoad.current = true;
}
},
[addToast]
[addToast, intl]
);
useEffect(() => {
@ -231,6 +252,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
activeProfileId: radarr?.activeProfileId,
rootFolder: radarr?.activeDirectory,
minimumAvailability: radarr?.minimumAvailability ?? 'released',
tags: radarr?.tags ?? [],
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
externalUrl: radarr?.externalUrl,
@ -256,6 +278,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
activeDirectory: values.rootFolder,
is4k: values.is4k,
minimumAvailability: values.minimumAvailability,
tags: values.tags,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
@ -324,14 +347,24 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
onOk={() => handleSubmit()}
title={
!radarr
? intl.formatMessage(messages.createradarr)
: intl.formatMessage(messages.editradarr)
? intl.formatMessage(
values.is4k
? messages.create4kradarr
: messages.createradarr
)
: intl.formatMessage(
values.is4k ? messages.edit4kradarr : messages.editradarr
)
}
>
<div className="mb-6">
<div className="form-row">
<label htmlFor="isDefault" className="checkbox-label">
{intl.formatMessage(messages.defaultserver)}
{intl.formatMessage(
values.is4k
? messages.default4kserver
: messages.defaultserver
)}
</label>
<div className="form-input">
<Field type="checkbox" id="isDefault" name="isDefault" />
@ -584,6 +617,57 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input">
<Select
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container"
classNamePrefix="react-select"
value={values.tags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setFieldValue(
'tags',
value?.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}

@ -18,7 +18,6 @@ const messages = defineMessages({
latestversion: 'Latest',
currentversion: 'Current Version',
viewchangelog: 'View Changelog',
runningDevelop: 'Development Version',
runningDevelopMessage:
'The latest changes to the <code>develop</code> branch of Overseerr are not shown below. Please see the commit history for this branch on <GithubLink>GitHub</GithubLink> for details.',
});
@ -159,8 +158,8 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
<h3 className="heading">{intl.formatMessage(messages.releases)}</h3>
<div className="section">
{currentVersion.startsWith('develop-') && (
<Alert title={intl.formatMessage(messages.runningDevelop)}>
{intl.formatMessage(messages.runningDevelopMessage, {
<Alert
title={intl.formatMessage(messages.runningDevelopMessage, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
@ -177,7 +176,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
);
},
})}
</Alert>
/>
)}
{data?.map((release, index) => {
return (

@ -1,14 +1,17 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import {
SettingsAboutResponse,
StatusResponse,
} from '../../../../server/interfaces/api/settingsInterfaces';
import globalMessages from '../../../i18n/globalMessages';
import Error from '../../../pages/_error';
import Badge from '../../Common/Badge';
import List from '../../Common/List';
import LoadingSpinner from '../../Common/LoadingSpinner';
import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import Releases from './Releases';
import Badge from '../../Common/Badge';
import PageTitle from '../../Common/PageTitle';
import globalMessages from '../../../i18n/globalMessages';
import Releases from './Releases';
const messages = defineMessages({
about: 'About',
@ -23,6 +26,8 @@ const messages = defineMessages({
helppaycoffee: 'Help Pay for Coffee',
documentation: 'Documentation',
preferredmethod: 'Preferred',
outofdate: 'Out of Date',
uptodate: 'Up to Date',
});
const SettingsAbout: React.FC = () => {
@ -31,6 +36,8 @@ const SettingsAbout: React.FC = () => {
'/api/v1/settings/about'
);
const { data: status } = useSWR<StatusResponse>('/api/v1/status');
if (!data && !error) {
return <LoadingSpinner />;
}
@ -49,8 +56,22 @@ const SettingsAbout: React.FC = () => {
/>
<div className="section">
<List title={intl.formatMessage(messages.overseerrinformation)}>
<List.Item title={intl.formatMessage(messages.version)}>
<code>{data.version}</code>
<List.Item
title={intl.formatMessage(messages.version)}
className="truncate"
>
<code>{data.version.replace('develop-', '')}</code>
{status?.updateAvailable ? (
<Badge badgeType="warning" className="ml-2">
{intl.formatMessage(messages.outofdate)}
</Badge>
) : (
status?.commitTag !== 'local' && (
<Badge badgeType="success" className="ml-2">
{intl.formatMessage(messages.uptodate)}
</Badge>
)
)}
</List.Item>
<List.Item title={intl.formatMessage(messages.totalmedia)}>
{intl.formatNumber(data.totalMediaItems)}

@ -1,9 +1,8 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle';
import globalMessages from '../../i18n/globalMessages';
import PageTitle from '../Common/PageTitle';
import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs';
const messages = defineMessages({
menuGeneralSettings: 'General',
@ -16,14 +15,7 @@ const messages = defineMessages({
menuAbout: 'About',
});
interface SettingsRoute {
text: string;
route: string;
regex: RegExp;
}
const SettingsLayout: React.FC = ({ children }) => {
const router = useRouter();
const intl = useIntl();
const settingsRoutes: SettingsRoute[] = [
@ -69,78 +61,11 @@ const SettingsLayout: React.FC = ({ children }) => {
},
];
const activeLinkColor =
'border-indigo-600 text-indigo-500 focus:outline-none focus:text-indigo-500 focus:border-indigo-500';
const inactiveLinkColor =
'border-transparent text-gray-500 hover:text-gray-400 hover:border-gray-300 focus:outline-none focus:text-gray-4700 focus:border-gray-300';
const SettingsLink: React.FC<{
route: string;
regex: RegExp;
isMobile?: boolean;
}> = ({ children, route, regex, isMobile = false }) => {
if (isMobile) {
return <option value={route}>{children}</option>;
}
return (
<Link href={route}>
<a
className={`whitespace-nowrap ml-8 first:ml-0 py-4 px-1 border-b-2 border-transparent font-medium text-sm leading-5 ${
router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};
return (
<>
<PageTitle title={intl.formatMessage(globalMessages.settings)} />
<div className="mt-6">
<div className="sm:hidden">
<select
onChange={(e) => {
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.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
isMobile
key={`mobile-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</select>
</div>
<div className="hidden overflow-x-scroll overflow-y-hidden border-b border-gray-600 sm:block hide-scrollbar">
<nav className="flex -mb-px">
{settingsRoutes.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
<SettingsTabs settingsRoutes={settingsRoutes} />
</div>
<div className="mt-10 text-white">{children}</div>
</>

@ -1,11 +1,5 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import Bolt from '../../assets/bolt.svg';
import DiscordLogo from '../../assets/extlogos/discord.svg';
import PushbulletLogo from '../../assets/extlogos/pushbullet.svg';
@ -13,38 +7,20 @@ import PushoverLogo from '../../assets/extlogos/pushover.svg';
import SlackLogo from '../../assets/extlogos/slack.svg';
import TelegramLogo from '../../assets/extlogos/telegram.svg';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs';
const messages = defineMessages({
notifications: 'Notifications',
notificationsettings: 'Notification Settings',
notificationsettingsDescription:
'Configure global notification settings. The options below will apply to all notification agents.',
notificationAgentsSettings: 'Notification Agents',
notificationAgentSettingsDescription:
'Choose the types of notifications to send, and which notification agents to use.',
notificationsettingssaved: 'Notification settings saved successfully!',
notificationsettingsfailed: 'Notification settings failed to save.',
enablenotifications: 'Enable Notifications',
'Configure and enable notification agents.',
email: 'Email',
webhook: 'Webhook',
});
interface SettingsRoute {
text: string;
content: React.ReactNode;
route: string;
regex: RegExp;
}
const SettingsNotifications: React.FC = ({ children }) => {
const router = useRouter();
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR('/api/v1/settings/notifications');
const settingsRoutes: SettingsRoute[] = [
{
@ -139,40 +115,6 @@ const SettingsNotifications: React.FC = ({ children }) => {
},
];
const activeLinkColor = 'bg-indigo-700';
const inactiveLinkColor = 'bg-gray-800';
const SettingsLink: React.FC<{
route: string;
regex: RegExp;
isMobile?: boolean;
}> = ({ children, route, regex, isMobile = false }) => {
if (isMobile) {
return <option value={route}>{children}</option>;
}
return (
<Link href={route}>
<a
className={`whitespace-nowrap ml-8 first:ml-0 px-3 py-2 font-medium text-sm rounded-md ${
router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={500} />;
}
return (
<>
<PageTitle
@ -185,131 +127,11 @@ const SettingsNotifications: React.FC = ({ children }) => {
<h3 className="heading">
{intl.formatMessage(messages.notificationsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.notificationsettingsDescription)}
</p>
</div>
<div className="section">
<Formik
initialValues={{
enabled: data.enabled,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications', {
enabled: values.enabled,
});
addToast(intl.formatMessage(messages.notificationsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(
intl.formatMessage(messages.notificationsettingsfailed),
{
appearance: 'error',
autoDismiss: true,
}
);
} finally {
revalidate();
}
}}
>
{({ isSubmitting, values, setFieldValue }) => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="name" className="checkbox-label">
<span>
{intl.formatMessage(messages.enablenotifications)}
</span>
</label>
<div className="form-input">
<Field
type="checkbox"
id="enabled"
name="enabled"
onChange={() => {
setFieldValue('enabled', !values.enabled);
}}
/>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
<div className="mt-10 mb-6">
<h3 className="heading">
{intl.formatMessage(messages.notificationAgentsSettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.notificationAgentSettingsDescription)}
</p>
</div>
<div>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a tab
</label>
<select
onChange={(e) => {
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.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
isMobile
key={`mobile-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</select>
</div>
<div className="hidden overflow-x-scroll overflow-y-hidden sm:block hide-scrollbar">
<nav className="flex space-x-4" aria-label="Tabs">
{settingsRoutes.map((route, index) => (
<SettingsLink
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.content}
</SettingsLink>
))}
</nav>
</div>
</div>
<SettingsTabs tabType="button" settingsRoutes={settingsRoutes} />
<div className="section">{children}</div>
</>
);

@ -38,9 +38,8 @@ const messages = defineMessages({
toastPlexConnecting: 'Attempting to connect to Plex…',
toastPlexConnectingSuccess: 'Plex connection established successfully!',
toastPlexConnectingFailure: 'Failed to connect to Plex.',
settingUpPlex: 'Setting Up Plex',
settingUpPlexDescription:
'To set up Plex, you can either enter your details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to check connectivity and retrieve available servers.',
'To set up Plex, you can either enter your details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.',
hostname: 'Hostname or IP Address',
port: 'Port',
enablessl: 'Enable SSL',
@ -81,7 +80,6 @@ interface PresetServerDisplay {
ssl: boolean;
uri: string;
address: string;
host?: string;
port: number;
local: boolean;
status?: boolean;
@ -94,7 +92,6 @@ interface SettingsPlexProps {
const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const [isSyncing, setIsSyncing] = useState(false);
const [isRefreshingPresets, setIsRefreshingPresets] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [availableServers, setAvailableServers] = useState<PlexDevice[] | null>(
null
);
@ -115,7 +112,6 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.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.validationHostnameRequired)
),
@ -135,13 +131,12 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
dev.connection.forEach((conn) =>
finalPresets.push({
name: dev.name,
ssl: conn.protocol === 'https' ? true : false,
ssl: !conn.local && conn.protocol === 'https',
uri: conn.uri,
address: conn.address,
port: conn.port,
local: conn.local,
host: conn.host,
status: conn.status === 200 ? true : false,
status: conn.status === 200,
message: conn.message,
})
);
@ -270,13 +265,13 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{intl.formatMessage(messages.plexsettingsDescription)}
</p>
<div className="section">
<Alert title={intl.formatMessage(messages.settingUpPlex)} type="info">
{intl.formatMessage(messages.settingUpPlexDescription, {
<Alert
title={intl.formatMessage(messages.settingUpPlexDescription, {
RegisterPlexTVLink: function RegisterPlexTVLink(msg) {
return (
<a
href="https://plex.tv"
className="text-indigo-100 hover:text-white hover:underline"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
@ -285,7 +280,8 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
);
},
})}
</Alert>
type="info"
/>
</div>
</div>
<Formik
@ -316,7 +312,6 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
} as PlexSettings);
revalidate();
setSubmitError(null);
if (toastId) {
removeToast(toastId);
}
@ -335,7 +330,6 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
autoDismiss: true,
appearance: 'error',
});
setSubmitError(e.response.data.message);
}
}}
>
@ -394,7 +388,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const targPreset =
availablePresets[Number(e.target.value)];
if (targPreset) {
setFieldValue('hostname', targPreset.host);
setFieldValue('hostname', targPreset.address);
setFieldValue('port', targPreset.port);
setFieldValue('useSsl', targPreset.ssl);
}
@ -515,18 +509,6 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
/>
</div>
</div>
{submitError && (
<div className="mt-6 sm:gap-4 sm:items-start">
<Alert
title={intl.formatMessage(
messages.toastPlexConnectingFailure
)}
type="error"
>
{submitError}
</Alert>
</div>
)}
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -20,11 +20,9 @@ import SonarrModal from './SonarrModal';
const messages = defineMessages({
services: 'Services',
radarrsettings: 'Radarr Settings',
radarrSettingsDescription:
'Configure your Radarr connection below. You can have multiple Radarr configurations, but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server which is used for new requests.',
sonarrsettings: 'Sonarr Settings',
sonarrSettingsDescription:
'Configure your Sonarr connection below. You can have multiple Sonarr configurations, but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server which is used for new requests.',
serviceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL',
default: 'Default',
@ -33,9 +31,12 @@ const messages = defineMessages({
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
nodefault: 'No Default Server',
nodefaultdescription:
'At least one server must be marked as default before any requests will make it to your services.',
noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer:
'If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.',
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
});
interface ServerInstanceProps {
@ -229,7 +230,9 @@ const SettingsServices: React.FC = () => {
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.radarrSettingsDescription)}
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
</div>
{editRadarrModal.open && (
@ -284,13 +287,31 @@ const SettingsServices: React.FC = () => {
{radarrData && !radarrError && (
<>
{radarrData.length > 0 &&
(!radarrData.some((radarr) => radarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Radarr',
mediaType: intl.formatMessage(messages.mediaTypeMovie),
})}
/>
) : (
!radarrData.some(
(radarr) => radarr.isDefault && !radarr.is4k
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert>
)}
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Radarr',
strong: function strong(msg) {
return (
<strong className="font-semibold text-yellow-100">
{msg}
</strong>
);
},
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
@ -347,7 +368,9 @@ const SettingsServices: React.FC = () => {
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.sonarrSettingsDescription)}
{intl.formatMessage(messages.serviceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
</div>
<div className="section">
@ -355,13 +378,31 @@ const SettingsServices: React.FC = () => {
{sonarrData && !sonarrError && (
<>
{sonarrData.length > 0 &&
(!sonarrData.some((sonarr) => sonarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Sonarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
) : (
!sonarrData.some(
(sonarr) => sonarr.isDefault && !sonarr.is4k
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert>
)}
<Alert
title={intl.formatMessage(messages.noDefaultNon4kServer, {
serverType: 'Sonarr',
strong: function strong(msg) {
return (
<strong className="font-semibold text-yellow-100">
{msg}
</strong>
);
},
})}
/>
)
))}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save