Merge branch 'develop' into release/release-20210104

pull/570/head
sct 4 years ago
commit 71169944dc

@ -196,7 +196,8 @@
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
"profile": "https://github.com/danshilm",
"contributions": [
"code"
"code",
"doc"
]
},
{
@ -235,6 +236,15 @@
"contributions": [
"code"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -0,0 +1,5 @@
root: ./docs
structure:
readme: README.md
summary: SUMMARY.md

@ -38,7 +38,7 @@ All help is welcome and greatly appreciated. If you would like to contribute to
```
yarn
yarn install
yarn dev
```
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.

@ -1,5 +1,8 @@
FROM node:12.18-alpine AS BUILD_IMAGE
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
COPY . /app
WORKDIR /app
@ -25,6 +28,8 @@ COPY --from=BUILD_IMAGE /app/dist ./dist
COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
CMD yarn start
EXPOSE 5055

@ -16,7 +16,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>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- 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-25-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-26-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -36,16 +36,21 @@
- User profiles.
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
- Version update notifications in-app.
- 4K requests (Includes multi-radarr/sonarr management for media)
## Planned Features
- More notification types (Slack/Telegram/etc.).
- More notification types.
- Issues system. This will allow users to report issues with content on your media server.
- Local user system (for those who don't use Plex).
- Compatibility APIs (to work with existing tools in your system).
## Getting Started
Check out our documenation for steps on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
## Running Overseerr
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
@ -70,6 +75,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
## Support
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
@ -123,13 +129,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
</tr>
</table>

@ -0,0 +1,22 @@
# Introduction
Welcome to the Overseerr Documentation.
## Features
- **Full Plex integration**. Login and manage user access with Plex.
- **Syncs to your Plex library** to show what titles you already have.
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
- **Simple request management UI**. Don't dig through the app to approve recent requests.
- **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system.
- Localization into other languages.
## Motivation
The primary motivation for starting this project was to have an incredibly performant and easy to use application. There is a heavy focus on the user experience for both the server owner and the users. We feel requesting should be **effortless for the user**. Find the media you want, click request, and branch off efficiently into other titles that interest you, all in one seamless flow. For the server owner, Overseerr takes all the hassle out of approving your users' requests.
## We need your help!
Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub.

@ -0,0 +1,17 @@
# Table of contents
* [Introduction](README.md)
## Getting Started
* [Installation](getting-started/installation.md)
## Support
* [Frequently Asked Questions](support/faq.md)
* [Asking for Support](support/asking-for-support.md)
## Extending Overseerr
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)

@ -0,0 +1,132 @@
# Reverse Proxy Examples
{% hint style="warning" %}
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
{% endhint %}
## LE/SWAG
### Subdomain
Place in the `proxy-confs` folder as `overseerr.subdomain.conf`
Example Configuration:
```text
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name overseerr.*;
include /config/nginx/ssl.conf;
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\)
Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
### Subdomain
Example Configuration:
```text
labels:
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.overseerr-rtr.entrypoints=https"
- "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
- "traefik.http.routers.overseerr-rtr.tls=true"
## HTTP Services
- "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
```
## LE/NGINX
### Subdomain
Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`.
Create a symlink to `/etc/nginx/sites-enabled`:
```text
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
Test the configuration:
```text
sudo nginx -t
```
Reload your configuration for NGINX:
```text
sudo systemctl reload nginx
```
Example Configuration:
```text
server {
listen 80;
server_name overseerr.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name overseerr.example.com;
ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
proxy_set_header Referer $http_referer;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-Host $host:$remote_port;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Port $remote_port;
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)
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'; img-src 'self' data: https://plex.tv https://assets.plex.tv 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;
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;
}
}
```

@ -0,0 +1,196 @@
# 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!
{% endhint %}
{% hint style="info" %}
After running Overseerr for the first time, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps.
{% endhint %}
## Docker
{% tabs %}
{% tab title="Basic" %}
```bash
docker run -d \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="UID/GID" %}
```text
docker run -d \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="Manual Update" %}
```text
# Stop the Overseerr container
docker stop overseerr
# Remove the Overseerr container
docker rm overseerr
# Pull the latest update
docker pull sctx/overseerr
# Run the Overseerr container with the same parameters as before
docker run -d ...
```
{% endtab %}
{% endtabs %}
{% hint style="info" %}
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.
{% endhint %}
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**\(Appdata\) as needed.
5. Click apply and access "Overseerr" at your `<ServerIP:HostPort>` in a web browser.
## Windows
Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
{% hint style="danger" %}
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed! Details below.**
{% endhint %}
```bash
docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/here:/app/config" --restart unless-stopped sctx/overseerr
```
{% hint style="info" %}
Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
{% endhint %}
## Linux \(Unsupported\)
{% tabs %}
{% tab title="Ubuntu 16.04+/Debian" %}
{% hint style="danger" %}
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
{% endhint %}
```bash
# Install nodejs
sudo apt-get install -y curl git gnupg2
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
# Install Overseerr
cd ~ && git clone https://github.com/sct/overseerr.git
cd overseerr
yarn install
yarn build
yarn start
```
**Updating**
In order to update, you will need to re-build overseer.
```bash
cd ~/.overseerr
git pull
yarn install
yarn build
yarn start
```
{% endtab %}
{% tab title="Ubuntu ARM" %}
{% hint style="danger" %}
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
{% endhint %}
```bash
# Install nodejs
sudo apt-get install -y curl git gnupg2 build-essential
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install yarn
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
# Install Overseerr
cd ~ && git clone https://github.com/sct/overseerr.git
cd overseerr
npm config set python "$(which python3)"
yarn install
yarn build
yarn start
```
**Updating**
In order to update, you will need to re-build overseer.
```bash
cd ~/.overseerr
git pull
yarn install
yarn build
yarn start
```
{% endtab %}
{% tab title="ArchLinux \(3rd Party\)" %}
Built from tag \(master\): [https://aur.archlinux.org/packages/overseerr/](https://aur.archlinux.org/packages/overseerr/)
Built from latest \(develop\): [aur.archlinux.org/packages/overseerr-git](https://aur.archlinux.org/packages/overseerr-git/)
**To install these just use your favorite AUR package manager:**
```bash
yay -S overseer
```
{% endtab %}
{% tab title="Gentoo \(3rd Party\)" %}
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
To enable using eselect repository, run:
```bash
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
```
Once complete, you can just run:
```bash
emerge www-apps/overseerr
```
{% endtab %}
{% endtabs %}
## Swizzin \(Third party\)
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
To install, run the following:
```bash
box install overseerr
```
To upgrade, run the following:
```bash
box upgrade overseerr
```

@ -0,0 +1,34 @@
# Asking for Support
## Before asking for support, make sure you try these things first
* Make sure you have **updated** to the latest version.
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
* **Analyzing** 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 them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
## What should I include when asking for support?
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
* 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 different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
* What command did you enter?
* What did you click on?
* What settings did you change?
* 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:
* Did something happen?
* Did something not happen?
* Are there any error messages showing?
* Screenshots can help us see what you are seeing
* The Overseerr logs 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?
First you will need to gather your logs from the install directory.
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.

@ -0,0 +1,95 @@
# Frequently Asked Questions
{% 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.
{% endhint %}
## General
### I receive 409 or 400 errors when requesting a movie or tv show!
**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible.
### 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.
### How can I access Overseerr outside 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 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).
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`.
### 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 for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
**A:** You can find the changelog in the **Settings -&gt; About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases).
### Can I make 4K requests?
**A:** 4K requests are not supported just yet but they will be supported in the future!
### Some media is missing from Overseerr that I know is in Plex!
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. 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.
**Troubleshooting Steps:**
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.
1. Verify that you are using one of the agents mentioned above.
2. Refresh the metadata for just that item.
3. Run a full scan in Overseerr to see if that item is now matched properly.
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
5. Run a full scan on Overseerr after refreshing all unmatched items.
Perform these steps to verify the media item has a guid Overseerr can match.
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
2. Verify that the media item has the same format of one of the examples below.
**Examples:**
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
### Where can I find the logs?
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
## User Management
### Why can't I see all my Plex users?
**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -&gt; General Settings** page beforehand.
### Can I create local users in Overseerr?
**A:** Not at this time. But it is a planned feature!
### Is is possible to set user roles in Overseerr?
**A:** Unfortunately, this is not possible yet. It is planned!
## Requests
### I approved a requested movie and radarr didn't search for it!
**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr.
### Help! My request still shows "requested" even though it's 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.
## Notifications
### I am getting "Username and Password not accepted" when sending email notifications to gmail!
**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833).

@ -1,4 +1,7 @@
module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,

@ -855,6 +855,22 @@ components:
properties:
webhookUrl:
type: string
TelegramSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
botAPI:
type: string
chatId:
type: string
NotificationEmailSettings:
type: object
properties:
@ -870,6 +886,9 @@ components:
emailFrom:
type: string
example: no-reply@example.com
senderName:
type: string
example: Overseerr
smtpHost:
type: string
example: 127.0.0.1
@ -1083,6 +1102,26 @@ components:
name: X-Api-Key
paths:
/status:
get:
summary: Return Overseerr version
description: Returns the current Overseerr version in JSON format
security: []
tags:
- public
responses:
'200':
description: Returned version
content:
application/json:
schema:
type: object
properties:
version:
type: string
example: 1.0.0
commitTag:
type: string
/settings/main:
get:
summary: Returns main settings
@ -1635,6 +1674,52 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/telegram:
get:
summary: Return current telegram notification settings
description: Returns current telegram notification settings in JSON format
tags:
- settings
responses:
'200':
description: Returned telegram settings
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
post:
summary: Update telegram notification settings
description: Update current telegram notification settings with provided values
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
/settings/notifications/telegram/test:
post:
summary: Test the provided telegram settings
description: Sends a test notification to the telegram agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TelegramSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/slack:
get:
summary: Return current slack notification settings

@ -171,7 +171,7 @@
[
"@semantic-release/exec",
{
"prepareCmd": "docker build -t sctx/overseerr ."
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
}
],
"semantic-release-docker",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

@ -40,12 +40,15 @@ class Media {
}
}
public static async getMedia(id: number): Promise<Media | undefined> {
public static async getMedia(
id: number,
mediaType: MediaType
): Promise<Media | undefined> {
const mediaRepository = getRepository(Media);
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id },
where: { tmdbId: id, mediaType },
relations: ['requests'],
});
@ -62,7 +65,7 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ unique: true })
@Column()
@Index()
public tmdbId: number;
@ -70,7 +73,7 @@ class Media {
@Index()
public tvdbId?: number;
@Column({ unique: true, nullable: true })
@Column({ nullable: true })
@Index()
public imdbId?: string;

@ -17,6 +17,7 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import TelegramAgent from './lib/notifications/agents/telegram';
import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
@ -47,6 +48,7 @@ app
new DiscordAgent(),
new EmailAgent(),
new SlackAgent(),
new TelegramAgent(),
]);
// Start Jobs

@ -44,11 +44,11 @@ class JobPlexSync {
this.isRecentOnly = isRecentOnly ?? false;
}
private async getExisting(tmdbId: number) {
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId },
where: { tmdbId: tmdbId, mediaType },
});
return existing;
@ -78,7 +78,10 @@ class JobPlexSync {
}
});
const existing = await this.getExisting(newMedia.tmdbId);
const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`);
@ -115,7 +118,7 @@ class JobPlexSync {
throw new Error('Unable to find TMDB ID');
}
const existing = await this.getExisting(tmdbMovieId);
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
@ -184,9 +187,7 @@ class JobPlexSync {
if (tvShow && metadata) {
// Lets get the available seasons from plex
const seasons = tvShow.seasons;
const media = await mediaRepository.findOne({
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
});
const media = await this.getExisting(tvShow.id, MediaType.TV);
const newSeasons: Season[] = [];

@ -1,5 +1,5 @@
import axios from 'axios';
import { Notification } from '..';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@ -196,10 +196,12 @@ class DiscordAgent
};
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}

@ -1,5 +1,5 @@
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { Notification } from '..';
import { hasNotificationType, Notification } from '..';
import path from 'path';
import { getSettings, NotificationAgentEmail } from '../../settings';
import nodemailer from 'nodemailer';
@ -22,12 +22,13 @@ class EmailAgent
return settings.notifications.agents.email;
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
public shouldSend(type: Notification): boolean {
const settings = this.getSettings();
if (settings.enabled) {
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
@ -60,7 +61,10 @@ class EmailAgent
const settings = this.getSettings();
return new Email({
message: {
from: settings.options.emailFrom,
from: {
name: settings.options.senderName,
address: settings.options.emailFrom,
},
},
send: true,
transport: this.getSmtpTransport(),

@ -1,5 +1,5 @@
import axios from 'axios';
import { Notification } from '..';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@ -187,10 +187,12 @@ class SlackAgent
};
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}

@ -0,0 +1,127 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramPayload {
text: string;
parse_mode: string;
chat_id: string;
}
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
private baseUrl = 'https://api.telegram.org/';
protected getSettings(): NotificationAgentTelegram {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private escapeText(text: string | undefined): string {
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
}
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.username);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New Request\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nPending Approval\n`;
break;
case Notification.MEDIA_APPROVED:
message += `\*Request Approved\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nProcessing Request\n`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*Now available\\!\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n\n`;
message += `\*Status\*\nAvailable\n`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*\n`;
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `\*Requested By\*\n${user}\n`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
}
/* eslint-enable */
return message;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/sendMessage`;
await axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
} as TelegramPayload);
return true;
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default TelegramAgent;

@ -9,6 +9,27 @@ export enum Notification {
TEST_NOTIFICATION = 32,
}
export const hasNotificationType = (
types: Notification | Notification[],
value: number
): boolean => {
let total = 0;
// If we are not checking any notifications, bail out and return true
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
// Combine all notification values into one
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
return !!(value & total);
};
class NotificationManager {
private activeAgents: NotificationAgent[] = [];

@ -81,6 +81,14 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
authUser?: string;
authPass?: string;
allowSelfSigned: boolean;
senderName: string;
};
}
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
botAPI: string;
chatId: string;
};
}
@ -88,6 +96,7 @@ interface NotificationAgents {
email: NotificationAgentEmail;
discord: NotificationAgentDiscord;
slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram;
}
interface NotificationSettings {
@ -140,6 +149,7 @@ class Settings {
smtpPort: 587,
secure: false,
allowSelfSigned: false,
senderName: 'Overseerr',
},
},
discord: {
@ -156,6 +166,14 @@ class Settings {
webhookUrl: '',
},
},
telegram: {
enabled: false,
types: 0,
options: {
botAPI: '',
chatId: '',
},
},
},
},
};

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface {
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
}

@ -1,4 +1,5 @@
import { TmdbCollection } from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search';
@ -23,7 +24,9 @@ export const mapCollection = (
parts: collection.parts.map((part) =>
mapMovieResult(
part,
media?.find((req) => req.tmdbId === part.id)
media?.find(
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE
)
)
),
});

@ -3,6 +3,7 @@ import type {
TmdbPersonResult,
TmdbTvResult,
} from '../api/themoviedb';
import { MediaType as MainMediaType } from '../constants/media';
import Media from '../entity/Media';
export type MediaType = 'tv' | 'movie' | 'person';
@ -122,12 +123,18 @@ export const mapSearchResults = (
case 'movie':
return mapMovieResult(
result,
media?.find((req) => req.tmdbId === result.id)
media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
)
);
case 'tv':
return mapTvResult(
result,
media?.find((req) => req.tmdbId === result.id)
media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
default:
return mapPersonResult(result);

@ -26,7 +26,9 @@ discoverRoutes.get('/movies', async (req, res) => {
results: data.results.map((result) =>
mapMovieResult(
result,
media.find((req) => req.tmdbId === result.id)
media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});

@ -13,10 +13,19 @@ import tvRoutes from './tv';
import mediaRoutes from './media';
import personRoutes from './person';
import collectionRoutes from './collection';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
const router = Router();
router.use(checkUser);
router.get('/status', (req, res) => {
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
});
});
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => {
const settings = getSettings();

@ -5,6 +5,7 @@ import { mapMovieResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
import { MediaType } from '../constants/media';
const movieRoutes = Router();
@ -17,7 +18,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
language: req.query.language as string,
});
const media = await Media.getMedia(tmdbMovie.id);
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
} catch (e) {
@ -49,7 +50,9 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
results: results.results.map((result) =>
mapMovieResult(
result,
media.find((req) => req.tmdbId === result.id)
media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
@ -75,7 +78,9 @@ movieRoutes.get('/:id/similar', async (req, res) => {
results: results.results.map((result) =>
mapMovieResult(
result,
media.find((req) => req.tmdbId === result.id)
media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});

@ -45,13 +45,19 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
cast: combinedCredits.cast.map((result) =>
mapCastCredits(
result,
castMedia.find((med) => med.tmdbId === result.id)
castMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
),
crew: combinedCredits.crew.map((result) =>
mapCrewCredits(
result,
crewMedia.find((med) => med.tmdbId === result.id)
crewMedia.find(
(med) =>
med.tmdbId === result.id && med.mediaType === result.media_type
)
)
),
id: combinedCredits.id,

@ -102,7 +102,7 @@ requestRoutes.post(
: await tmdb.getTvShow({ tvId: req.body.mediaId });
let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId },
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: ['requests'],
});
@ -164,7 +164,7 @@ requestRoutes.post(
if (finalSeasons.length === 0) {
return next({
status: 500,
status: 202,
message: 'No seasons available to request',
});
}

@ -25,6 +25,7 @@ import { Notification } from '../lib/notifications';
import DiscordAgent from '../lib/notifications/agents/discord';
import EmailAgent from '../lib/notifications/agents/email';
import SlackAgent from '../lib/notifications/agents/slack';
import TelegramAgent from '../lib/notifications/agents/telegram';
const settingsRoutes = Router();
@ -503,6 +504,40 @@ settingsRoutes.post('/notifications/slack/test', (req, res, next) => {
return res.status(204).send();
});
settingsRoutes.get('/notifications/telegram', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.telegram);
});
settingsRoutes.post('/notifications/telegram', (req, res) => {
const settings = getSettings();
settings.notifications.agents.telegram = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.telegram);
});
settingsRoutes.post('/notifications/telegram/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const telegramAgent = new TelegramAgent(req.body);
telegramAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings();

@ -5,6 +5,7 @@ import { mapTvResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
import { MediaType } from '../constants/media';
const tvRoutes = Router();
@ -16,7 +17,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
language: req.query.language as string,
});
const media = await Media.getMedia(tv.id);
const media = await Media.getMedia(tv.id, MediaType.TV);
return res.status(200).json(mapTvDetails(tv, media));
} catch (e) {
@ -60,7 +61,9 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
results: results.results.map((result) =>
mapTvResult(
result,
media.find((req) => req.tmdbId === result.id)
media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
)
)
),
});
@ -86,7 +89,9 @@ tvRoutes.get('/:id/similar', async (req, res) => {
results: results.results.map((result) =>
mapTvResult(
result,
media.find((req) => req.tmdbId === result.id)
media.find(
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
)
)
),
});

@ -1,3 +1,20 @@
import { existsSync } from 'fs';
import path from 'path';
import logger from '../logger';
const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json');
let commitTag = 'local';
if (existsSync(COMMIT_TAG_PATH)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
commitTag = require(COMMIT_TAG_PATH).commitTag;
logger.info(`Commit Tag: ${commitTag}`);
}
export const getCommitTag = (): string => {
return commitTag;
};
export const getAppVersion = (): string => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json');
@ -5,7 +22,7 @@ export const getAppVersion = (): string => {
let finalVersion = version;
if (version === '0.1.0') {
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
finalVersion = `develop-${getCommitTag()}`;
}
return finalVersion;

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 24C0 37.2548 10.7452 48 24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24ZM19.6 35L20.0083 28.8823L20.008 28.882L31.1369 18.839C31.6253 18.4055 31.0303 18.1941 30.3819 18.5873L16.6473 27.2523L10.7147 25.4007C9.4335 25.0084 9.4243 24.128 11.0023 23.4951L34.1203 14.5809C35.1762 14.1015 36.1953 14.8345 35.7922 16.4505L31.8552 35.0031C31.5803 36.3215 30.7837 36.6368 29.68 36.0278L23.6827 31.5969L20.8 34.4C20.7909 34.4088 20.7819 34.4176 20.7729 34.4264C20.4505 34.7403 20.1837 35 19.6 35Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>

After

Width:  |  Height:  |  Size: 267 B

@ -102,7 +102,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,

@ -7,7 +7,7 @@ interface ListItemProps {
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4">
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="text-sm font-medium text-gray-200">{title}</dt>
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
<span className="flex-grow">{children}</span>

@ -98,7 +98,7 @@ const Modal: React.FC<ModalProps> = ({
show={!loading}
>
<div
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full max-h-full"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@ -166,7 +166,7 @@ const Modal: React.FC<ModalProps> = ({
<Button
buttonType={cancelButtonType}
onClick={onCancel}
className="ml-3 sm:ml-0 sm:px-4"
className="ml-3 sm:ml-0"
>
{cancelText
? cancelText

@ -61,7 +61,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
>
<div className="w-screen max-w-md" ref={slideoverRef}>
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 py-6 space-y-1 bg-indigo-600 sm:px-6">
<header className="px-4 py-6 space-y-1 bg-indigo-600">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg font-medium leading-7 text-white">
{title}
@ -97,7 +97,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div>
)}
</header>
<div className="relative flex-1 px-4 py-6 text-white sm:px-6">
<div className="relative flex-1 px-4 py-6 text-white">
{children}
</div>
</div>

@ -71,8 +71,8 @@ const TD: React.FC<TDProps> = ({
const Table: React.FC = ({ children }) => {
return (
<div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="my-2 overflow-x-auto -mx-6 md:mx-0 lg:mx-0">
<div className="py-2 align-middle inline-block min-w-full">
<div className="shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full">{children}</table>
</div>

@ -1,6 +1,7 @@
import React from 'react';
import useSearchInput from '../../../hooks/useSearchInput';
import { defineMessages, useIntl } from 'react-intl';
import ClearButton from '../../../assets/xcircle.svg';
const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV',
@ -8,16 +9,16 @@ const messages = defineMessages({
const SearchInput: React.FC = () => {
const intl = useIntl();
const { searchValue, setSearchValue, setIsOpen } = useSearchInput();
const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
return (
<div className="flex-1 flex">
<div className="w-full flex md:ml-0">
<div className="flex flex-1">
<div className="flex w-full md:ml-0">
<label htmlFor="search_field" className="sr-only">
Search
</label>
<div className="relative w-full text-white focus-within:text-gray-200">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
clipRule="evenodd"
@ -27,14 +28,27 @@ const SearchInput: React.FC = () => {
</div>
<input
id="search_field"
className="block w-full h-full pl-8 pr-1 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
className="block w-full h-full py-2 pl-8 text-white placeholder-gray-300 bg-gray-600 border-transparent rounded-md focus:border-transparent focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onBlur={() => {
if (searchValue === '') {
setIsOpen(false);
}
}}
/>
{searchValue.length > 0 && (
<button
className="absolute inset-y-0 right-0 p-1 m-auto text-gray-400 transition border-none outline-none h-7 w-7 focus:outline-none focus:border-none hover:text-white"
onClick={() => clear()}
>
<ClearButton />
</button>
)}
</div>
</div>
</div>

@ -175,11 +175,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
>
<div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50">
<Link href="/">
<a>
<img src="/logo.png" alt="Overseerr Logo" />
</a>
</Link>
<a href="/">
<img src="/logo.png" alt="Overseerr Logo" />
</a>
</span>
</div>
<nav className="mt-5 px-2 space-y-1">
@ -239,11 +237,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4">
<span className="text-2xl text-gray-50">
<Link href="/">
<a>
<img src="/logo.png" alt="Overseerr Logo" />
</a>
</Link>
<a href="/">
<img src="/logo.png" alt="Overseerr Logo" />
</a>
</span>
</div>
<nav className="mt-5 flex-1 px-2 bg-gray-800 space-y-1">

@ -18,18 +18,18 @@ const Layout: React.FC = ({ children }) => {
const router = useRouter();
return (
<div className="min-h-full h-full flex bg-gray-900">
<div className="flex h-full min-w-0 min-h-full bg-gray-900">
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="flex flex-col w-0 flex-1 md:ml-64 relative mb-16">
<div className="z-10 flex-shrink-0 flex h-16 bg-gray-600 shadow fixed right-0 left-0 md:left-64">
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 md:ml-64">
<div className="fixed left-0 right-0 z-10 flex flex-shrink-0 h-16 bg-gray-600 shadow md:left-64">
<button
className="px-4 border-r border-gray-800 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
className="px-4 text-gray-200 border-r border-gray-800 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
>
<svg
className="h-6 w-6"
className="w-6 h-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
@ -42,7 +42,7 @@ const Layout: React.FC = ({ children }) => {
/>
</svg>
</button>
<div className="flex-1 px-4 flex justify-between">
<div className="flex justify-between flex-1 px-4">
<SearchInput />
<div className="flex items-center md:ml-6">
<LanguagePicker />
@ -51,18 +51,15 @@ const Layout: React.FC = ({ children }) => {
</div>
</div>
<main
className="relative z-0 top-16 focus:outline-none right-0"
tabIndex={0}
>
<div className="pt-2 pb-6 md:py-6">
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
<div className="pt-2 pb-6">
<div className="px-4 mx-auto max-w-8xl">
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
<div className="rounded-md bg-indigo-700 p-4 mt-2">
<div className="p-4 mt-2 bg-indigo-700 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-white"
className="w-5 h-5 text-white"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@ -74,14 +71,14 @@ const Layout: React.FC = ({ children }) => {
/>
</svg>
</div>
<div className="ml-3 flex-1 md:flex md:justify-between">
<div className="flex-1 ml-3 md:flex md:justify-between">
<p className="text-sm leading-5 text-white">
<FormattedMessage {...messages.alphawarning} />
</p>
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
<a
href="http://github.com/sct/overseerr"
className="whitespace-nowrap font-medium text-indigo-100 hover:text-white transition ease-in-out duration-150"
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
target="_blank"
rel="noreferrer"
>

@ -51,31 +51,33 @@ const Login: React.FC = () => {
}, [user, router]);
return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative">
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
<ImageFader
backgroundImages={[
'/images/rotate1.jpg',
'/images/rotate2.jpg',
'/images/rotate3.jpg',
'/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]}
/>
<div className="absolute top-4 right-4 z-50">
<div className="absolute z-50 top-4 right-4">
<LanguagePicker />
</div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-md relative z-40">
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img
src="/logo.png"
className="mx-auto max-h-32 w-auto"
className="w-auto mx-auto max-h-32"
alt="Overseerr Logo"
/>
<h2 className="mt-2 text-center text-3xl leading-9 font-extrabold text-gray-100">
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<FormattedMessage {...messages.signinplex} />
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md relative z-50">
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
style={{ backdropFilter: 'blur(5px)' }}
>
<Transition
@ -87,11 +89,11 @@ const Login: React.FC = () => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="rounded-md bg-red-600 p-4 mb-4">
<div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-300"
className="w-5 h-5 text-red-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"

@ -146,7 +146,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,

@ -0,0 +1,70 @@
import React from 'react';
import { NotificationItem, hasNotificationType } from '..';
interface NotificationTypeProps {
option: NotificationItem;
currentTypes: number;
parent?: NotificationItem;
onUpdate: (newTypes: number) => void;
}
const NotificationType: React.FC<NotificationTypeProps> = ({
option,
currentTypes,
onUpdate,
parent,
}) => {
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
!!parent?.value && hasNotificationType(parent.value, currentTypes)
? 'opacity-50'
: ''
}`}
>
<div className="flex items-center h-5">
<input
id={option.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
!!parent?.value && hasNotificationType(parent.value, currentTypes)
}
onClick={() => {
onUpdate(
hasNotificationType(option.value, currentTypes)
? currentTypes - option.value
: currentTypes + option.value
);
}}
defaultChecked={
hasNotificationType(option.value, currentTypes) ||
(!!parent?.value &&
hasNotificationType(parent.value, currentTypes))
}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label htmlFor={option.id} className="font-medium">
{option.name}
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`notification-type-child-${child.id}`} className="pl-6 mt-4">
<NotificationType
option={child}
currentTypes={currentTypes}
onUpdate={(newTypes) => onUpdate(newTypes)}
parent={option}
/>
</div>
))}
</>
);
};
export default NotificationType;

@ -0,0 +1,106 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import NotificationType from './NotificationType';
const messages = defineMessages({
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
mediaapproved: 'Media Approved',
mediaapprovedDescription: 'Sends a notification when media is approved.',
mediaavailable: 'Media Available',
mediaavailableDescription:
'Sends a notification when media becomes available.',
mediafailed: 'Media Failed',
mediafailedDescription:
'Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
});
export const hasNotificationType = (
types: Notification | Notification[],
value: number
): boolean => {
let total = 0;
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
return !!(value & total);
};
export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
}
export interface NotificationItem {
id: string;
name: string;
description: string;
value: Notification;
children?: NotificationItem[];
}
interface NotificationTypeSelectorProps {
currentTypes: number;
onUpdate: (newTypes: number) => void;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
currentTypes,
onUpdate,
}) => {
const intl = useIntl();
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(messages.mediarequestedDescription),
value: Notification.MEDIA_PENDING,
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(messages.mediaapprovedDescription),
value: Notification.MEDIA_APPROVED,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(messages.mediaavailableDescription),
value: Notification.MEDIA_AVAILABLE,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(messages.mediafailedDescription),
value: Notification.MEDIA_FAILED,
},
];
return (
<>
{types.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
currentTypes={currentTypes}
onUpdate={onUpdate}
/>
))}
</>
);
};
export default NotificationTypeSelector;

@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react';
import React, { useContext, useMemo, useState } from 'react';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import type { PersonDetail } from '../../../server/models/Person';
@ -11,6 +11,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import ImageFader from '../Common/ImageFader';
import Ellipsis from '../../assets/ellipsis.svg';
import { groupBy } from 'lodash';
const messages = defineMessages({
appearsin: 'Appears in',
@ -35,6 +36,42 @@ const PersonDetails: React.FC = () => {
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
);
const sortedCast = useMemo(() => {
const grouped = groupBy(combinedCredits?.cast ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
character: objs.map((pos) => pos.character).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
const sortedCrew = useMemo(() => {
const grouped = groupBy(combinedCredits?.crew ?? [], 'id');
const reduced = Object.values(grouped).map((objs) => ({
...objs[0],
job: objs.map((pos) => pos.job).join(', '),
}));
return reduced.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
}, [combinedCredits]);
if (!data && !error) {
return <LoadingSpinner />;
}
@ -43,24 +80,6 @@ const PersonDetails: React.FC = () => {
return <Error statusCode={404} />;
}
const sortedCast = combinedCredits?.cast.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
const isLoading = !combinedCredits && !errorCombinedCredits;
const cast = (sortedCast ?? []).length > 0 && (

@ -43,7 +43,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
return (
<div className="block">
<div className="px-4 py-4 sm:px-6">
<div className="px-4 py-4">
<div className="flex items-center justify-between">
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
<div className="flex flex-nowrap mb-1 white">

@ -100,7 +100,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) {
return (
<tr className="w-full bg-gray-800 animate-pulse h-24" ref={ref}>
<tr className="w-full h-24 bg-gray-800 animate-pulse" ref={ref}>
<td colSpan={6}></td>
</tr>
);
@ -108,61 +108,62 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title || !requestData) {
return (
<tr className="w-full bg-gray-800 animate-pulse h-24">
<tr className="w-full h-24 bg-gray-800 animate-pulse">
<td colSpan={6}></td>
</tr>
);
}
return (
<tr className="w-full bg-gray-800 h-24 p-2 relative text-white">
<Table.TD
noPadding
className="w-20 px-4 relative hidden sm:table-cell align-middle"
>
<Link
href={
request.type === 'movie'
? `/movie/${request.media.tmdbId}`
: `/tv/${request.media.tmdbId}`
}
>
<a>
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md"
/>
</a>
</Link>
</Table.TD>
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<Table.TD>
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="text-white text-xl mr-2 hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
<div className="text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.username,
})}
</div>
{requestData.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{requestData.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
<div className="flex items-center">
<Link
href={
request.type === 'movie'
? `/movie/${request.media.tmdbId}`
: `/tv/${request.media.tmdbId}`
}
>
<a className="flex-shrink-0 hidden mr-4 sm:block">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
/>
</a>
</Link>
<div className="flex-shrink overflow-hidden">
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
<div className="text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.username,
})}
</div>
{requestData.seasons.length > 0 && (
<div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">
{intl.formatMessage(messages.seasons)}
</span>
{requestData.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
</div>
</Table.TD>
<Table.TD>
{requestData.media.status === MediaStatus.UNKNOWN ? (

@ -18,14 +18,28 @@ const messages = defineMessages({
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
next: 'Next',
previous: 'Previous',
filterAll: 'All',
filterPending: 'Pending',
filterApproved: 'Approved',
noresults: 'No Results.',
showallrequests: 'Show All Requests',
sortAdded: 'Request Date',
sortModified: 'Last Modified',
});
type Filter = 'all' | 'approved' | 'pending';
type Sort = 'added' | 'modified';
const RequestList: React.FC = () => {
const intl = useIntl();
const [pageIndex, setPageIndex] = useState(0);
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
const [currentSort, setCurrentSort] = useState<Sort>('added');
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=10&skip=${pageIndex * 10}`
`/api/v1/request?take=10&skip=${
pageIndex * 10
}&filter=${currentFilter}&sort=${currentSort}`
);
if (!data && !error) {
return <LoadingSpinner />;
@ -40,10 +54,86 @@ const RequestList: React.FC = () => {
return (
<>
<Header>{intl.formatMessage(messages.requests)}</Header>
<div className="flex flex-col justify-between md:items-end md:flex-row">
<Header>{intl.formatMessage(messages.requests)}</Header>
<div className="flex flex-col md:flex-row">
<div className="flex mb-2 md:mb-0 md:mr-2">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
clipRule="evenodd"
/>
</svg>
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter);
}}
onBlur={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter);
}}
value={currentFilter}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="all">
{intl.formatMessage(messages.filterAll)}
</option>
<option value="pending">
{intl.formatMessage(messages.filterPending)}
</option>
<option value="approved">
{intl.formatMessage(messages.filterApproved)}
</option>
</select>
</div>
<div className="flex">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 3a1 1 0 000 2h11a1 1 0 100-2H3zM3 7a1 1 0 000 2h7a1 1 0 100-2H3zM3 11a1 1 0 100 2h4a1 1 0 100-2H3zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
</svg>
</span>
<select
id="sort"
name="sort"
onChange={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort);
}}
onBlur={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort);
}}
value={currentSort}
className="flex-1 block w-full py-2 pl-3 pr-10 text-base leading-6 text-white bg-gray-700 border-gray-500 rounded-r-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="added">
{intl.formatMessage(messages.sortAdded)}
</option>
<option value="modified">
{intl.formatMessage(messages.sortModified)}
</option>
</select>
</div>
</div>
</div>
<Table>
<thead>
<Table.TH className="hidden sm:table-cell"></Table.TH>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
@ -60,10 +150,31 @@ const RequestList: React.FC = () => {
/>
);
})}
{data.results.length === 0 && (
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
<Table.TD colSpan={6} noPadding>
<div className="flex flex-col items-center justify-center p-4">
<span>{intl.formatMessage(messages.noresults)}</span>
{currentFilter !== 'all' && (
<div className="mt-4">
<Button
buttonSize="sm"
buttonType="primary"
onClick={() => setCurrentFilter('all')}
>
{intl.formatMessage(messages.showallrequests)}
</Button>
</div>
)}
</div>
</Table.TD>
</tr>
)}
<tr>
<Table.TD colSpan={6} noPadding>
<nav
className="bg-gray-700 px-4 py-3 flex items-center justify-between text-white sm:px-6"
className="flex items-center justify-between px-4 py-3 text-white bg-gray-700"
aria-label="Pagination"
>
<div className="hidden sm:block">
@ -73,7 +184,7 @@ const RequestList: React.FC = () => {
to:
data.results.length < 10
? pageIndex * 10 + data.results.length
: pageIndex + 1 * 10,
: (pageIndex + 1) * 10,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
@ -81,7 +192,7 @@ const RequestList: React.FC = () => {
})}
</p>
</div>
<div className="flex-1 flex justify-start sm:justify-end">
<div className="flex justify-start flex-1 sm:justify-end">
<span className="mr-2">
<Button
disabled={!hasPrevPage}

@ -168,7 +168,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />}
>
{text}
<p className="text-center md:text-left">{text}</p>
</Modal>
);
};

@ -224,7 +224,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
>
<div className="flex flex-col">
<div className="-mx-4 overflow-auto sm:mx-0 max-h-96">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
@ -256,13 +256,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-1 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.season)}
</th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-5 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.numberofepisodes)}
</th>
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-2 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.status)}
</th>
</tr>
@ -320,17 +320,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</td>
<td className="px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
<td className="px-1 md:px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
{season.seasonNumber === 0
? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}
</td>
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="px-5 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{season.episodeCount}
</td>
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="pr-2 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{!seasonRequest && !mediaSeason && (
<Badge>
{intl.formatMessage(messages.notrequested)}

@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
save: 'Save Changes',
@ -19,6 +20,7 @@ const messages = defineMessages({
discordsettingsfailed: 'Discord notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
notificationtypes: 'Notification Types',
});
const NotificationsDiscord: React.FC = () => {
@ -69,7 +71,7 @@ const NotificationsDiscord: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid }) => {
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
@ -87,10 +89,10 @@ const NotificationsDiscord: React.FC = () => {
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.agentenabled)}
</label>
@ -99,19 +101,19 @@ const NotificationsDiscord: React.FC = () => {
type="checkbox"
id="enabled"
name="enabled"
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.webhookUrl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="webhookUrl"
name="webhookUrl"
@ -119,17 +121,41 @@ const NotificationsDiscord: React.FC = () => {
placeholder={intl.formatMessage(
messages.webhookUrlPlaceholder
)}
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.webhookUrl && touched.webhookUrl && (
<div className="text-red-500 mt-2">{errors.webhookUrl}</div>
<div className="mt-2 text-red-500">{errors.webhookUrl}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
@ -142,7 +168,7 @@ const NotificationsDiscord: React.FC = () => {
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
save: 'Save Changes',
@ -28,6 +29,8 @@ const messages = defineMessages({
allowselfsigned: 'Allow Self-Signed Certificates',
ssldisabletip:
'SSL should be disabled on standard TLS connections (Port 587)',
senderName: 'Sender Name',
notificationtypes: 'Notification Types',
});
const NotificationsEmail: React.FC = () => {
@ -37,7 +40,7 @@ const NotificationsEmail: React.FC = () => {
'/api/v1/settings/notifications/email'
);
const NotificationsDiscordSchema = Yup.object().shape({
const NotificationsEmailSchema = Yup.object().shape({
emailFrom: Yup.string().required(
intl.formatMessage(messages.validationFromRequired)
),
@ -65,8 +68,9 @@ const NotificationsEmail: React.FC = () => {
authUser: data.options.authUser,
authPass: data.options.authPass,
allowSelfSigned: data.options.allowSelfSigned,
senderName: data.options.senderName,
}}
validationSchema={NotificationsDiscordSchema}
validationSchema={NotificationsEmailSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/email', {
@ -80,6 +84,7 @@ const NotificationsEmail: React.FC = () => {
authUser: values.authUser,
authPass: values.authPass,
allowSelfSigned: values.allowSelfSigned,
senderName: values.senderName,
},
});
addToast(intl.formatMessage(messages.emailsettingssaved), {
@ -96,7 +101,7 @@ const NotificationsEmail: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid }) => {
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
@ -108,6 +113,7 @@ const NotificationsEmail: React.FC = () => {
secure: values.secure,
authUser: values.authUser,
authPass: values.authPass,
senderName: values.senderName,
},
});
@ -119,10 +125,10 @@ const NotificationsEmail: React.FC = () => {
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.agentenabled)}
</label>
@ -135,10 +141,10 @@ const NotificationsEmail: React.FC = () => {
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="emailFrom"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.emailsender)}
</label>
@ -157,10 +163,29 @@ const NotificationsEmail: React.FC = () => {
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="senderName"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.senderName)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="senderName"
name="senderName"
placeholder="Overseerr"
type="text"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="smtpHost"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.smtpHost)}
</label>
@ -179,10 +204,10 @@ const NotificationsEmail: React.FC = () => {
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="smtpPort"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.smtpPort)}
</label>
@ -201,10 +226,10 @@ const NotificationsEmail: React.FC = () => {
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="secure"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<div className="flex flex-col">
<span>{intl.formatMessage(messages.enableSsl)}</span>
@ -222,10 +247,10 @@ const NotificationsEmail: React.FC = () => {
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="allowSelfSigned"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.allowselfsigned)}
</label>
@ -238,10 +263,10 @@ const NotificationsEmail: React.FC = () => {
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="authUser"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.authUser)}
</label>
@ -256,10 +281,10 @@ const NotificationsEmail: React.FC = () => {
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="authPass"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.authPass)}
</label>
@ -269,11 +294,36 @@ const NotificationsEmail: React.FC = () => {
id="authPass"
name="authPass"
type="password"
autoComplete="off"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -8,6 +8,7 @@ import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
import Alert from '../../../Common/Alert';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
save: 'Save Changes',
@ -23,6 +24,7 @@ const messages = defineMessages({
settingupslack: 'Setting up Slack Notifications',
settingupslackDescription:
'To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.',
notificationtypes: 'Notification Types',
});
const NotificationsSlack: React.FC = () => {
@ -44,24 +46,22 @@ const NotificationsSlack: React.FC = () => {
return (
<>
<p className="mb-">
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
{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"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
})}
</Alert>
</p>
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
{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"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
})}
</Alert>
<Formik
initialValues={{
enabled: data.enabled,
@ -92,7 +92,14 @@ const NotificationsSlack: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
}) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true,
@ -110,10 +117,10 @@ const NotificationsSlack: React.FC = () => {
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.agentenabled)}
</label>
@ -126,10 +133,10 @@ const NotificationsSlack: React.FC = () => {
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.webhookUrl)}
</label>
@ -150,6 +157,30 @@ const NotificationsSlack: React.FC = () => {
)}
</div>
</div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

@ -0,0 +1,255 @@
import React from 'react';
import { Field, Form, Formik } from 'formik';
import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
import Alert from '../../Common/Alert';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
save: 'Save Changes',
saving: 'Saving...',
agentenabled: 'Agent Enabled',
botAPI: 'Bot API',
chatId: 'Chat Id',
validationBotAPIRequired: 'You must provide a Bot API key.',
validationChatIdRequired: 'You must provide a Chat id.',
telegramsettingssaved: 'Telegram notification settings saved!',
telegramsettingsfailed: 'Telegram notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
settinguptelegram: 'Setting up Telegram Notifications',
settinguptelegramDescription:
'To setup Telegram you need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key.\
Additionally, you need the chat id for the chat you want the bot to send notifications to.\
You can do this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat or group chat.',
notificationtypes: 'Notification Types',
});
const NotificationsTelegram: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/telegram'
);
const NotificationsTelegramSchema = Yup.object().shape({
botAPI: Yup.string().required(
intl.formatMessage(messages.validationBotAPIRequired)
),
chatId: Yup.string().required(
intl.formatMessage(messages.validationChatIdRequired)
),
});
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enabled: data?.enabled,
types: data?.types,
botAPI: data?.options.botAPI,
chatId: data?.options.chatId,
}}
validationSchema={NotificationsTelegramSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/telegram', {
enabled: values.enabled,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
},
});
addToast(intl.formatMessage(messages.telegramsettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.telegramsettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/telegram/test', {
enabled: true,
types: values.types,
options: {
botAPI: values.botAPI,
chatId: values.chatId,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<>
<Alert
title={intl.formatMessage(messages.settinguptelegram)}
type="info"
>
{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"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
GetIdBotLink: function GetIdBotLink(msg) {
return (
<a
href="https://telegram.me/get_id_bot"
className="text-indigo-100 hover:text-white hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
})}
</Alert>
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enabled"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.agentenabled)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="enabled"
name="enabled"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="botAPI"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.botAPI)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="botAPI"
name="botAPI"
type="text"
placeholder={intl.formatMessage(messages.botAPI)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.botAPI && touched.botAPI && (
<div className="mt-2 text-red-500">{errors.botAPI}</div>
)}
</div>
<label
htmlFor="chatId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.chatId)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="chatId"
name="chatId"
type="text"
placeholder={intl.formatMessage(messages.chatId)}
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
{errors.chatId && touched.chatId && (
<div className="mt-2 text-red-500">{errors.chatId}</div>
)}
</div>
</div>
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-types"
>
{intl.formatMessage(messages.notificationtypes)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) =>
setFieldValue('types', newTypes)
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)}
</Button>
</span>
</div>
</div>
</Form>
</>
);
}}
</Formik>
);
};
export default NotificationsTelegram;

@ -274,10 +274,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
}
>
<div className="mb-6">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.defaultserver)}
</label>
@ -290,10 +290,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.servername)}
</label>
@ -318,10 +318,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.hostname)}
</label>
@ -347,10 +347,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.port)}
</label>
@ -371,10 +371,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.ssl)}
</label>
@ -391,10 +391,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="apiKey"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.apiKey)}
</label>
@ -419,10 +419,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="baseUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.baseUrl)}
</label>
@ -447,10 +447,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="activeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.qualityprofile)}
</label>
@ -490,10 +490,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-8005">
<label
htmlFor="rootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.rootfolder)}
</label>
@ -531,10 +531,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="minimumAvailability"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.minimumAvailability)}
</label>
@ -560,10 +560,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
</label>

@ -18,6 +18,7 @@ const messages = defineMessages({
timezone: 'Timezone',
supportoverseerr: 'Support Overseerr',
helppaycoffee: 'Help pay for coffee',
documentation: 'Documentation',
});
const SettingsAbout: React.FC = () => {
@ -56,6 +57,16 @@ const SettingsAbout: React.FC = () => {
</div>
<div className="mb-8">
<List title={intl.formatMessage(messages.gettingsupport)}>
<List.Item title={intl.formatMessage(messages.documentation)}>
<a
href="https://docs.overseerr.dev"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
>
https://docs.overseerr.dev
</a>
</List.Item>
<List.Item title={intl.formatMessage(messages.githubdiscussions)}>
<a
href="https://github.com/sct/overseerr/discussions"

@ -159,10 +159,10 @@ const SettingsMain: React.FC = () => {
return (
<Form>
{userHasPermission(Permission.ADMIN) && (
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="username"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.apikey)}
</label>
@ -203,10 +203,10 @@ const SettingsMain: React.FC = () => {
</div>
</div>
)}
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.applicationurl)}
</label>

@ -4,6 +4,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord_white.svg';
import SlackLogo from '../../assets/extlogos/slack.svg';
import TelegramLogo from '../../assets/extlogos/telegram.svg';
const messages = defineMessages({
notificationsettings: 'Notification Settings',
@ -65,6 +66,17 @@ const settingsRoutes: SettingsRoute[] = [
route: '/settings/notifications/slack',
regex: /^\/settings\/notifications\/slack/,
},
{
text: 'Telegram',
content: (
<span className="flex items-center">
<TelegramLogo className="h-4 mr-2" />
Telegram
</span>
),
route: '/settings/notifications/telegram',
regex: /^\/settings\/notifications\/telegram/,
},
];
const SettingsNotifications: React.FC = ({ children }) => {

@ -48,7 +48,7 @@ interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library;
currentLibrary?: Library;
libraries: Library[];
}
@ -86,12 +86,17 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const syncLibraries = async () => {
setIsSyncing(true);
const params: { sync: boolean; enable?: string } = {
sync: true,
};
if (activeLibraries.length > 0) {
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/plex/library', {
params: {
sync: true,
enable:
activeLibraries.length > 0 ? activeLibraries.join(',') : undefined,
},
params,
});
setIsSyncing(false);
revalidate();
@ -118,13 +123,16 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const toggleLibrary = async (libraryId: string) => {
setIsSyncing(true);
if (activeLibraries.includes(libraryId)) {
const params: { enable?: string } = {};
if (activeLibraries.length > 1) {
params.enable = activeLibraries
.filter((id) => id !== libraryId)
.join(',');
}
await axios.get('/api/v1/settings/plex/library', {
params: {
enable:
activeLibraries.length > 0
? activeLibraries.filter((id) => id !== libraryId).join(',')
: undefined,
},
params,
});
} else {
await axios.get('/api/v1/settings/plex/library', {
@ -192,10 +200,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{submitError}
</div>
)}
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<FormattedMessage {...messages.servername} />
</label>
@ -215,10 +223,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div>
</div>
</div>
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<FormattedMessage {...messages.hostname} />
</label>
@ -240,10 +248,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
<FormattedMessage {...messages.port} />
</label>
@ -263,10 +271,10 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.ssl)}
</label>
@ -369,25 +377,29 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
<div className="flex flex-col w-full sm:flex-row">
{dataSync?.running && (
<>
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}
values={{ name: dataSync.currentLibrary.name }}
/>
</Badge>
</div>
{dataSync.currentLibrary && (
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}
values={{ name: dataSync.currentLibrary.name }}
/>
</Badge>
</div>
)}
<div className="flex items-center">
<Badge badgeType="warning">
<FormattedMessage
{...messages.librariesRemaining}
values={{
count: dataSync.libraries.slice(
dataSync.libraries.findIndex(
(library) =>
library.id === dataSync.currentLibrary.id
) + 1
).length,
count: dataSync.currentLibrary
? dataSync.libraries.slice(
dataSync.libraries.findIndex(
(library) =>
library.id === dataSync.currentLibrary?.id
) + 1
).length
: 0,
}}
/>
</Badge>

@ -283,10 +283,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
}
>
<div className="mb-6">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.defaultserver)}
</label>
@ -299,10 +299,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.servername)}
</label>
@ -327,10 +327,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="hostname"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.hostname)}
</label>
@ -356,10 +356,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="port"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.port)}
</label>
@ -380,10 +380,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="ssl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.ssl)}
</label>
@ -400,10 +400,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="apiKey"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.apiKey)}
</label>
@ -428,10 +428,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="baseUrl"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.baseUrl)}
</label>
@ -456,10 +456,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="activeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.qualityprofile)}
</label>
@ -499,10 +499,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="rootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.rootfolder)}
</label>
@ -540,10 +540,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="activeAnimeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.animequalityprofile)}
</label>
@ -584,10 +584,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="activeAnimeRootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.animerootfolder)}
</label>
@ -626,10 +626,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="is4k"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.server4k)}
</label>
@ -642,10 +642,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
/>
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
<label
htmlFor="enableSeasonFolders"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.seasonfolders)}
</label>

@ -43,27 +43,29 @@ const Setup: React.FC = () => {
};
return (
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative">
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
<ImageFader
backgroundImages={[
'/images/rotate1.jpg',
'/images/rotate2.jpg',
'/images/rotate3.jpg',
'/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]}
/>
<div className="absolute top-4 right-4 z-50">
<div className="absolute z-50 top-4 right-4">
<LanguagePicker />
</div>
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-4xl relative z-40">
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-4xl">
<img
src="/logo.png"
className="mx-auto max-h-32 w-auto mb-10"
className="w-auto mx-auto mb-10 max-h-32"
alt="Overseerr Logo"
/>
<nav className="relative z-50">
<ul
className=" bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md divide-y divide-gray-600 md:flex md:divide-y-0"
className="bg-gray-800 bg-opacity-50 border border-gray-600 divide-y divide-gray-600 rounded-md md:flex md:divide-y-0"
style={{ backdropFilter: 'blur(5px)' }}
>
<SetupSteps
@ -86,22 +88,22 @@ const Setup: React.FC = () => {
/>
</ul>
</nav>
<div className="w-full mt-10 p-4 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md">
<div className="w-full p-4 mt-10 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md">
{currentStep === 1 && (
<LoginWithPlex onComplete={() => setCurrentStep(2)} />
)}
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
<div className="mt-4 text-gray-500 text-sm">
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.syncingbackground)}
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!plexSettingsComplete}
@ -117,9 +119,9 @@ const Setup: React.FC = () => {
{currentStep === 3 && (
<div>
<SettingsServices />
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
onClick={() => finishSetup()}

@ -160,7 +160,7 @@ const Slider: React.FC<SliderProps> = ({
return (
<div className="relative">
<div className="absolute flex text-gray-400 right-0 -mt-10">
<div className="absolute right-0 flex -mt-10 text-gray-400">
<button
className={`${
scrollPos.isStart ? 'cursor-not-allowed text-gray-800' : ''
@ -207,14 +207,14 @@ const Slider: React.FC<SliderProps> = ({
</button>
</div>
<div
className="relative overflow-x-scroll whitespace-nowrap hide-scrollbar overscroll-x-contain -ml-4 -mr-4 px-2 overflow-y-auto"
className="relative px-2 -ml-4 -mr-4 overflow-x-scroll overflow-y-auto whitespace-nowrap hide-scrollbar overscroll-x-contain"
ref={containerRef}
onScroll={onScroll}
>
{items?.map((item, index) => (
<div
key={`${sliderKey}-${index}`}
className="px-2 inline-block align-top"
className="inline-block px-2 align-top"
>
{item}
</div>
@ -223,13 +223,13 @@ const Slider: React.FC<SliderProps> = ({
[...Array(10)].map((_item, i) => (
<div
key={`placeholder-${i}`}
className="px-2 inline-block align-top"
className="inline-block px-2 align-top"
>
{placeholder}
</div>
))}
{isEmpty && (
<div className="text-center text-white mt-16 mb-16">
<div className="mt-16 mb-16 text-center text-white">
{emptyMessage ? (
emptyMessage
) : (

@ -0,0 +1,70 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import Modal from '../Common/Modal';
import Transition from '../Transition';
const messages = defineMessages({
newversionavailable: 'New Version Available',
newversionDescription:
'An update is now available. Click the button below to reload the application.',
reloadOverseerr: 'Reload Overseerr',
});
const StatusChecker: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<{ version: string; commitTag: string }>(
'/api/v1/status',
{
refreshInterval: 60 * 1000,
}
);
if (!data && !error) {
return null;
}
if (!data) {
return null;
}
return (
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear
show={data.commitTag !== process.env.commitTag}
>
<Modal
iconSvg={
<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 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
}
title={intl.formatMessage(messages.newversionavailable)}
onOk={() => location.reload()}
okText={intl.formatMessage(messages.reloadOverseerr)}
backgroundClickable={false}
>
{intl.formatMessage(messages.newversionDescription)}
</Modal>
</Transition>
);
};
export default StatusChecker;

@ -1,8 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react';
import type { MediaType } from '../../../server/models/Search';
import Available from '../../assets/available.svg';
import Requested from '../../assets/requested.svg';
import Unavailable from '../../assets/unavailable.svg';
import { withProperties } from '../../utils/typeHelpers';
import Transition from '../Transition';
import Placeholder from './Placeholder';
@ -11,6 +8,7 @@ import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import { defineMessages, useIntl } from 'react-intl';
import { useIsTouch } from '../../hooks/useIsTouch';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
movie: 'Movie',
@ -99,32 +97,63 @@ const TitleCard: React.FC<TitleCardProps> = ({
>
<div className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden shadow-xl">
<div
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${
className={`absolute left-2 top-2 rounded-md z-40 pointer-events-none ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
}`}
>
<div className="flex items-center h-4 px-2 py-1 text-xs font-normal text-center text-white uppercase">
<div className="flex items-center h-4 px-1 py-2 text-xs font-normal tracking-wider text-center text-white uppercase">
{mediaType === 'movie'
? intl.formatMessage(messages.movie)
: intl.formatMessage(messages.tvshow)}
</div>
</div>
<div
className="absolute top-0 right-0 z-40"
style={{
right: '-1px',
}}
>
<div className="absolute z-40 pointer-events-none top-2 right-2">
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<Available className="rounded-tr-md" />
<div className="flex items-center justify-center w-4 h-4 text-white bg-green-400 border border-green-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
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 13l4 4L19 7"
/>
</svg>
</div>
)}
{currentStatus === MediaStatus.PENDING && (
<Requested className="rounded-tr-md" />
<div className="flex items-center justify-center w-4 h-4 text-white bg-yellow-500 border border-yellow-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
</svg>
</div>
)}
{currentStatus === MediaStatus.PROCESSING && (
<Unavailable className="rounded-tr-md" />
<div className="flex items-center justify-center w-4 h-4 text-white bg-indigo-500 border border-indigo-600 rounded-full sm:w-5 sm:h-5">
<svg
className="w-3 h-3 sm:w-4 sm:h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</div>
<Transition
@ -173,7 +202,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
}}
>
<div className="flex items-end w-full h-full">
<div className="px-2 text-white pb-11">
<div
className={`px-2 text-white ${
currentStatus && currentStatus !== MediaStatus.UNKNOWN
? 'pb-2'
: 'pb-11'
}`}
>
{year && <div className="text-sm">{year}</div>}
<h1 className="text-xl leading-tight whitespace-normal">
@ -182,7 +217,11 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
WebkitLineClamp:
currentStatus &&
currentStatus !== MediaStatus.UNKNOWN
? 5
: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
@ -196,42 +235,16 @@ const TitleCard: React.FC<TitleCardProps> = ({
</Link>
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
<Link
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
>
<a className="flex w-full text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm cursor-pointer h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
}}
className="w-full ml-2 text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700"
className="flex items-center justify-center w-full text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700"
>
<svg
className="w-4 mx-auto"
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -244,70 +257,9 @@ const TitleCard: React.FC<TitleCardProps> = ({
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full ml-2 text-center text-yellow-500 border border-yellow-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full ml-2 text-center text-indigo-500 border border-indigo-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full ml-2 text-center text-green-400 border border-green-400 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
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 13l4 4L19 7"
/>
</svg>
<span className="text-xs">
{intl.formatMessage(globalMessages.request)}
</span>
</button>
)}
</div>

@ -3,7 +3,7 @@ import type { ToastProps } from 'react-toast-notifications';
const Toast: React.FC<ToastProps> = ({ appearance, children, onDismiss }) => {
return (
<div className="toast flex items-end justify-center px-2 py-2 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div className="toast flex items-end justify-center px-2 py-2 pointer-events-none sm:items-start sm:justify-end">
<div className="max-w-sm w-full bg-gray-700 shadow-lg rounded-lg pointer-events-auto">
<div className="rounded-lg ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4">

@ -166,7 +166,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,

@ -155,10 +155,10 @@ const UserEdit: React.FC = () => {
return (
<>
<Header extraMargin={4}>
<Header>
<FormattedMessage {...messages.edituser} />
</Header>
<div className="px-4 space-y-6 sm:p-6 lg:pb-8">
<div className="space-y-6">
<div className="flex flex-col space-y-6 text-white lg:flex-row lg:space-y-0 lg:space-x-6">
<div className="flex-grow space-y-6">
<div className="space-y-1">
@ -229,7 +229,7 @@ const UserEdit: React.FC = () => {
</div>
</div>
<div className="text-white">
<div className="sm:border-t sm:border-gray-200 sm:pt-5">
<div className="sm:border-t sm:border-gray-200">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>

@ -150,7 +150,7 @@ const UserList: React.FC = () => {
</Modal>
</Transition>
<div className="flex items-center justify-between">
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<Header>{intl.formatMessage(messages.userlist)}</Header>
<Button
className="mx-4 my-8"
buttonType="primary"

@ -6,6 +6,7 @@ const globalMessages = defineMessages({
processing: 'Processing',
unavailable: 'Unavailable',
requested: 'Requested',
request: 'Request',
failed: 'Failed',
pending: 'Pending',
declined: 'Declined',

@ -60,6 +60,14 @@
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.",
"components.NotificationTypeSelector.mediaavailable": "Media Available",
"components.NotificationTypeSelector.mediaavailableDescription": "Sends a notification when media becomes available.",
"components.NotificationTypeSelector.mediafailed": "Media Failed",
"components.NotificationTypeSelector.mediafailedDescription": "Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.",
"components.NotificationTypeSelector.mediarequested": "Media Requested",
"components.NotificationTypeSelector.mediarequestedDescription": "Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.",
"components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member",
@ -105,6 +113,7 @@
"components.RequestModal.status": "Status",
"components.Search.searchresults": "Search Results",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled",
"components.Settings.Notifications.NotificationsSlack.notificationtypes": "Notification Types",
"components.Settings.Notifications.NotificationsSlack.save": "Save Changes",
"components.Settings.Notifications.NotificationsSlack.saving": "Saving...",
"components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting up Slack Notifications",
@ -120,19 +129,29 @@
"components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates",
"components.Settings.Notifications.authPass": "Auth Pass",
"components.Settings.Notifications.authUser": "Auth User",
"components.Settings.Notifications.botAPI": "Bot API",
"components.Settings.Notifications.chatId": "Chat Id",
"components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.",
"components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!",
"components.Settings.Notifications.emailsender": "Email Sender Address",
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!",
"components.Settings.Notifications.enableSsl": "Enable SSL",
"components.Settings.Notifications.notificationtypes": "Notification Types",
"components.Settings.Notifications.save": "Save Changes",
"components.Settings.Notifications.saving": "Saving…",
"components.Settings.Notifications.senderName": "Sender Name",
"components.Settings.Notifications.settinguptelegram": "Setting up Telegram Notifications",
"components.Settings.Notifications.settinguptelegramDescription": "To setup Telegram you need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key. Additionally, you need the chat id for the chat you want the bot to send notifications to. You can do this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat or group chat.",
"components.Settings.Notifications.smtpHost": "SMTP Host",
"components.Settings.Notifications.smtpPort": "SMTP Port",
"components.Settings.Notifications.ssldisabletip": "SSL should be disabled on standard TLS connections (Port 587)",
"components.Settings.Notifications.telegramsettingsfailed": "Telegram notification settings failed to save.",
"components.Settings.Notifications.telegramsettingssaved": "Telegram notification settings saved!",
"components.Settings.Notifications.test": "Test",
"components.Settings.Notifications.testsent": "Test notification sent!",
"components.Settings.Notifications.validationBotAPIRequired": "You must provide a Bot API key.",
"components.Settings.Notifications.validationChatIdRequired": "You must provide a Chat id.",
"components.Settings.Notifications.validationFromRequired": "You must provide an email sender address",
"components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host",
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port",
@ -186,6 +205,7 @@
"components.Settings.SettingsAbout.Releases.viewchangelog": "View Changelog",
"components.Settings.SettingsAbout.Releases.viewongithub": "View on GitHub",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.",
"components.Settings.SettingsAbout.documentation": "Documentation",
"components.Settings.SettingsAbout.gettingsupport": "Getting Support",
"components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions",
"components.Settings.SettingsAbout.helppaycoffee": "Help pay for coffee",
@ -303,6 +323,9 @@
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results",
"components.StatusChacker.newversionDescription": "An update is now available. Click the button below to reload the application.",
"components.StatusChacker.newversionavailable": "New Version Available",
"components.StatusChacker.reloadOverseerr": "Reload Overseerr",
"components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
@ -398,6 +421,7 @@
"i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending",
"i18n.processing": "Processing…",
"i18n.request": "Request",
"i18n.requested": "Requested",
"i18n.retry": "Retry",
"i18n.tvshows": "Series",

@ -13,6 +13,7 @@ import { LanguageContext, AvailableLocales } from '../context/LanguageContext';
import Head from 'next/head';
import Toast from '../components/Toast';
import { InteractionProvider } from '../context/InteractionContext';
import StatusChecker from '../components/StatusChacker';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: string): Promise<any> => {
@ -74,7 +75,10 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
useEffect(() => {
loadLocaleData(currentLocale).then(setMessages);
setCookie(null, 'locale', currentLocale, { path: '/' });
setCookie(null, 'locale', currentLocale, {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 10,
});
}, [currentLocale]);
if (router.pathname.match(/(login|setup)/)) {
@ -104,6 +108,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
<Head>
<title>Overseerr</title>
</Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>

@ -0,0 +1,17 @@
import { NextPage } from 'next';
import React from 'react';
import NotificationsTelegram from '../../../components/Settings/Notifications/NotificationsTelegram';
import SettingsLayout from '../../../components/Settings/SettingsLayout';
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
const NotificationsPage: NextPage = () => {
return (
<SettingsLayout>
<SettingsNotifications>
<NotificationsTelegram />
</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsPage;

@ -56,3 +56,7 @@ body {
code {
@apply px-2 py-1 bg-gray-800 rounded-md;
}
input[type='search']::-webkit-search-cancel-button {
-webkit-appearance: none;
}

@ -58,7 +58,7 @@ module.exports = {
},
},
variants: {
padding: ['first', 'last'],
padding: ['first', 'last', 'responsive'],
borderWidth: ['first', 'last'],
margin: ['first', 'last', 'responsive'],
boxShadow: ['group-focus'],

Loading…
Cancel
Save