Merge branch 'develop'

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

@ -467,6 +467,42 @@
"contributions": [
"translation"
]
},
{
"login": "Jabster28",
"name": "Jabster28",
"avatar_url": "https://avatars.githubusercontent.com/u/29015942?v=4",
"profile": "https://github.com/Jabster28",
"contributions": [
"code"
]
},
{
"login": "littlerooster",
"name": "littlerooster",
"avatar_url": "https://avatars.githubusercontent.com/u/83890654?v=4",
"profile": "https://github.com/littlerooster",
"contributions": [
"translation"
]
},
{
"login": "dphildebrandt",
"name": "Dustin Hildebrandt",
"avatar_url": "https://avatars.githubusercontent.com/u/154459?v=4",
"profile": "https://github.com/dphildebrandt",
"contributions": [
"code"
]
},
{
"login": "Generator",
"name": "Bruno Guerreiro",
"avatar_url": "https://avatars.githubusercontent.com/u/44146?v=4",
"profile": "https://github.com/Generator",
"contributions": [
"translation"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

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

@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Support via Discord
url: https://discord.gg/PkCWJSeCk7
url: https://discord.gg/overseerr
about: Chat with users and devs on support and setup related topics.
- name: Support via GitHub Discussions
url: https://github.com/sct/overseerr/discussions

@ -15,7 +15,7 @@ jobs:
container: node:14.16-alpine
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
@ -32,31 +32,31 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v1.3.0
- name: Cache Docker layers
uses: actions/cache@v2.1.5
uses: actions/cache@v2.1.6
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v2.5.0
with:
context: .
file: ./Dockerfile
@ -86,7 +86,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |

@ -11,12 +11,12 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Generate Swagger UI
uses: Legion2/swagger-ui-action@v1
uses: Legion2/swagger-ui-action@v1.1.2
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v3.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui

@ -8,7 +8,7 @@ jobs:
support:
runs-on: ubuntu-20.04
steps:
- uses: dessant/support-requests@v2
- uses: dessant/support-requests@v2.0.1
with:
github-token: ${{ github.token }}
support-label: 'invalid:template-incomplete'

@ -11,27 +11,27 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v1.3.0
- name: Log in to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v2.5.0
with:
context: .
file: ./Dockerfile

@ -12,7 +12,7 @@ jobs:
container: node:14.16-alpine
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
with:
fetch-depth: 0
- name: Set up Node.js
@ -36,16 +36,16 @@ jobs:
with:
node-version: 14
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v1.3.0
- name: Log in to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v1.9.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -72,7 +72,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
with:
fetch-depth: 0
- name: Switch to master branch
@ -89,7 +89,7 @@ jobs:
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v1.2.0
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
@ -103,7 +103,7 @@ jobs:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
uses: diddlesnaps/snapcraft-review-tools-action@v1.3.0
with:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
@ -120,7 +120,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |

@ -23,7 +23,7 @@ jobs:
container: node:14.16-alpine
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
@ -46,7 +46,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v2.3.4
- name: Prepare
id: prepare
run: |
@ -57,7 +57,7 @@ jobs:
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v1.2.0
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
@ -71,7 +71,7 @@ jobs:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
uses: diddlesnaps/snapcraft-review-tools-action@v1.3.0
with:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
@ -88,7 +88,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |

@ -8,7 +8,7 @@ jobs:
support:
runs-on: ubuntu-20.04
steps:
- uses: dessant/support-requests@v2
- uses: dessant/support-requests@v2.0.1
with:
github-token: ${{ github.token }}
support-label: 'support'
@ -18,7 +18,7 @@ jobs:
to be a support request. Please use our support channels
to get help with Overseerr.
- [Discord](https://discord.gg/PkCWJSeCk7)
- [Discord](https://discord.gg/overseerr)
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
close-issue: true

@ -76,7 +76,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- You can create a "draft" pull request early to get feedback on your work.
- Your code **must** be formatted correctly, or the tests will fail.
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7).
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/overseerr).
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
### UI Text Style

@ -6,13 +6,13 @@
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/PkCWJSeCk7"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-50-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-54-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -44,7 +44,7 @@ https://docs.overseerr.dev/getting-started/installation
## 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 get support on [Discord](https://discord.gg/overseerr).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
@ -58,7 +58,7 @@ You can also access the API documentation from your local Overseerr install at h
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/PkCWJSeCk7)!
If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/overseerr)!
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
@ -139,6 +139,10 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr>
<tr>
<td align="center"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
</tr>
</table>

@ -12,7 +12,9 @@
- [Users](using-overseerr/users/README.md)
- [Notifications](using-overseerr/notifications/README.md)
- [Email](using-overseerr/notifications/email.md)
- [Web Push](using-overseerr/notifications/webpush.md)
- [Discord](using-overseerr/notifications/discord.md)
- [LunaSea](using-overseerr/notifications/lunasea.md)
- [Pushbullet](using-overseerr/notifications/pushbullet.md)
- [Pushover](using-overseerr/notifications/pushover.md)
- [Slack](using-overseerr/notifications/slack.md)
@ -22,9 +24,10 @@
## Support
- [Frequently Asked Questions (FAQ)](support/faq.md)
- [Asking for Support](support/asking-for-support.md)
- [Need Help?](support/need-help.md)
## Extending Overseerr
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
- [Fail2ban Filter](extending-overseerr/fail2ban.md)
- [Reverse Proxy](extending-overseerr/reverse-proxy.md)
- [Fail2ban](extending-overseerr/fail2ban.md)
- [Third-Party Integrations](extending-overseerr/third-party.md)

@ -1,4 +1,4 @@
# Reverse Proxy Examples
# Reverse Proxy
{% hint style="warning" %}
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
@ -6,7 +6,10 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomai
A Nginx subfolder workaround configuration is provided below, but it is not officially supported.
{% endhint %}
## SWAG
## Nginx
{% tabs %}
{% tab title="SWAG" %}
A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag).
@ -39,27 +42,29 @@ server {
}
```
## Traefik (v2)
{% endtab %}
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
{% tab title="Nginx Proxy Manager" %}
```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"
```
Add a new proxy host with the following settings:
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
### Details
## Nginx
- **Domain Names:** Your desired external Overseerr hostname; e.g., `overseerr.example.com`
- **Scheme:** `http`
- **Forward Hostname / IP:** Internal Overseerr hostname or IP
- **Forward Port:** `5055`
- **Cache Assets:** yes
- **Block Common Exploits:** yes
### SSL
- **SSL Certificate:** Select one of the options; if you are not sure, pick “Request a new SSL Certificate”
- **Force SSL:** yes
- **HTTP/2 Support:** yes
{% endtab %}
{% tabs %}
{% tab title="Subdomain" %}
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
@ -148,14 +153,20 @@ location ^~ /overseerr {
{% endtab %}
{% endtabs %}
Next, test the configuration:
```bash
sudo nginx -t
```
## Traefik (v2)
Finally, reload `nginx` for the new configuration to take effect:
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
```bash
sudo systemctl reload nginx
```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"
```
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).

@ -0,0 +1,13 @@
# Third-Party Integrations
{% hint style="warning" %}
We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself!
{% endhint %}
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
- [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter

@ -1,7 +1,7 @@
# Installation
{% hint style="danger" %}
Overseerr is currently in beta. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
**Overseerr is currently in BETA.** If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
{% endhint %}
{% hint style="info" %}
@ -92,17 +92,17 @@ Use a 3rd party updating mechanism such as [Watchtower](https://github.com/conta
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
2. Inside the **Community 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.
Please refer to the [Docker Desktop for Windows user manual](https://docs.docker.com/docker-for-windows/) for details on how to install Docker on Windows.
{% 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!**
**WSL2 will need to be installed to prevent DB corruption!** Please see the [Docker Desktop WSL 2 backend documentation](https://docs.docker.com/docker-for-windows/wsl/) for instructions on how to enable WSL2. The command below will only work with WSL2 installed!
{% endhint %}
```bash
@ -110,13 +110,17 @@ docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/her
```
{% 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.
Docker on Windows works differently than it does on Linux; it runs Docker inside of a stripped-down Linux VM. Volume mounts are exposed to Docker inside 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 Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
{% endhint %}
## Linux
{% hint style="info" %}
The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supported Linux install method aside from [Docker](#docker). Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supported Linux install method aside from [Docker](#docker).
Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
{% endhint %}
**To install:**
@ -151,7 +155,7 @@ Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
Efforts will be made to keep up-to-date with the latest releases; however, this cannot be guaranteed.
**To enable:**
To enable using `eselect repository`, run:

@ -1,7 +1,7 @@
# Frequently Asked Questions (FAQ)
{% hint style="info" %}
If you can't find the solution to your problem here, please seek help on [Discord](https://discord.gg/PkCWJSeCk7).
If you can't find the solution to your problem here, please read [Need Help?](./need-help.md) and reach out to us on [Discord](https://discord.gg/overseerr).
_Please do not post questions or support requests on the GitHub issue tracker!_
{% endhint %}
@ -16,7 +16,7 @@ Use a third-party update mechanism (such as [Watchtower](https://github.com/cont
The easiest but least secure method is to simply forward an external port (e.g., `5055`) on your router to the internal port used by Overseerr (default is TCP `5055`). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You would then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
A more advanced, user-friendly, and secure (if using SSL) method is to set up a web server and use a reverse proxy to access Overseerr. Please refer to our [reverse proxy examples](../extending-overseerr/reverse-proxy-examples.md) for more information.
A more advanced, user-friendly, and secure (if using SSL) method is to set up a web server and use a reverse proxy to access Overseerr. Please refer to our [reverse proxy examples](../extending-overseerr/reverse-proxy.md) for more information.
The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`.
@ -26,9 +26,9 @@ You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr
### Where can I find the changelog?
You can find the changelog in the **Settings &rarr; About** page in your Overseerr instance if you are using the `latest` tag. You can alternatively review the [release/version history on GitHub](https://github.com/sct/overseerr/releases).
You can find the changelog for your version (stable/`latest`,s or `develop`) in the **Settings &rarr; About** page in your Overseerr instance.
If you are using the `develop` tag, please refer to the [commit history for that branch on GitHub](https://github.com/sct/overseerr/commits/develop).
You can alternatively review the [stable release history](https://github.com/sct/overseerr/releases) and [`develop` branch commit history](https://github.com/sct/overseerr/commits/develop) on GitHub.
### Some media is missing from Overseerr that I know is in Plex!
@ -68,17 +68,17 @@ You can also perform the following to verify the media item has a GUID Overseerr
### Where can I find the log files?
Please see [these instructions on how to locate and share your logs](./asking-for-support#how-can-i-share-my-logs).
Please see [these instructions on how to locate and share your logs](./need-help.md#how-can-i-share-my-logs).
## Users
### Why can't I see all of my Plex users?
Please see the [documentation for importing users from Plex](../using-overseerr/users#importing-users-from-plex).
Please see the [documentation for importing users from Plex](../using-overseerr/users/README.md#importing-users-from-plex).
### Can I create local users in Overseerr?
Yes! Please see the [documentation for creating local users](../using-overseerr/users#creating-local-users).
Yes! Please see the [documentation for creating local users](../using-overseerr/users/README.md#creating-local-users).
### Is is possible to set user roles in Overseerr?
@ -104,15 +104,15 @@ Check the minimum availability setting in your Radarr server. If a movie does no
### Help! My request still shows "requested" even though it is in Plex!
See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
See "[Some media is missing from Overseerr that I know is in Plex!](#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
### Series requests keep failing!
If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr.
If you configured a URL base in Sonarr, make sure you have also configured the [URL Base](../using-overseerr/settings/README.md#url-base) setting for your Sonarr server in Overseerr.
Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings &rarr; Services**.
Language profile support for Sonarr was added in [v1.20.0](https://github.com/sct/overseerr/releases/tag/v1.20.0) along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings &rarr; Services**.
## Notifications

@ -1,21 +1,21 @@
# Asking for Support
# Need Help?
Before seeking help, please make sure you have first tried these following:
Before seeking assistance, please make sure you have first tried these following:
- **Updating** Overseerr to the latest version.
- **Stopping and restarting** Overseerr.
- **Restarting** your machine.
- **Clearing** your browser cache.
- **Analyzing** your logs, you just might find the solution yourself!
- **Searching** the [documentation](../), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
- **Searching** the [documentation](../README.md), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/overseerr)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
Be sure to also include a link to your logs. (Please see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.)
Be sure to also include a link to your logs. (Please see [How can I share my logs?](#how-can-i-share-my-logs) below.)
## What should I include when asking for support?
## What should I include when requesting support?
When contacting support, please try to include as much information as possible. A vague statement like "it doesn't work" provides very little to go on, and makes it difficult for us to help you.
Please try to include as much information as possible. A vague statement like "it doesn't work" provides very little to go on, and makes it difficult for us to help you.
Try to answer the following questions:
@ -31,10 +31,10 @@ Try to answer the following questions:
- Did something not happen?
- Are there any error messages showing?
- Provide screenshots to help us see what you are seeing.
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues (see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below).
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues (see [How can I share my logs?](#how-can-i-share-my-logs) below).
## How can I share my logs?
1. Locate the current log file at `<your Overseeerr config directory>/logs/overseerr.log`.
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/overseerr).

@ -5,7 +5,9 @@
Overseerr currently supports the following notification agents:
- [Email](./email.md)
- [Web Push](./webpush.md)
- [Discord](./discord.md)
- [LunaSea](./lunasea.md)
- [Pushbullet](./pushbullet.md)
- [Pushover](./pushover.md)
- [Slack](./slack.md)
@ -14,11 +16,9 @@ Overseerr currently supports the following notification agents:
## Setting Up Notifications
Configuring your notifications is quite simple. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
Simply configure your desired notification agents in **Settings &rarr; Notifications**.
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
Note that some notifications are intended for the user who submitted the relevant request, while others are for administrators. For details, please see the documentation for the specific agent you would like to use.
Users can customize their personal notification preferences in their own user notification settings.
## Requesting New Notification Agents

@ -1,26 +1,16 @@
# Discord
{% hint style="info" %}
The following notification types will mention _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
- Media Requested
- Media Automatically Approved
- Media Failed
On the other hand, the notification types below will only mention the user who submitted the request:
- Media Approved (does not include automatic approvals)
- Media Declined
- Media Available
The Discord notification agent enables you to post notifications to a channel in a server you manage.
In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured and **Enable Mentions** checked in their Discord notification user settings.
{% hint style="info" %}
Users can optionally opt-in to being mentioned in Discord notifications by configuring their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) in their user settings.
{% endhint %}
## Configuration
{% hint style="info" %}
To configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
{% endhint %}
### Webhook URL
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Bot Username (optional)
@ -29,7 +19,3 @@ If you would like to override the name you configured for your bot in Discord, y
### Bot Avatar URL (optional)
Similar to the bot username, you can override the avatar for your bot.
### Webhook URL
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.

@ -1,22 +1,14 @@
# Email
{% hint style="info" %}
The following email notification types are sent to _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
- Media Requested
- Media Automatically Approved
- Media Failed
On the other hand, the email notification types below are only sent to the user who submitted the request:
- Media Approved (does not include automatic approvals)
- Media Declined
- Media Available
## Configuration
In order for users to receive email notifications, they must have **Enable Notifications** checked in their email notification user settings.
{% hint style="info" %}
If the [Application URL](../settings/README.md#application-url) setting is configured in **Settings &rarr; General**, Overseerr will explicitly set the origin server hostname when connecting to the SMTP host.
{% endhint %}
## Configuration
### Sender Name (optional)
Configure a friendly name for the email sender (e.g., "Overseerr").
### Sender Address
@ -24,10 +16,6 @@ Set this to the email address you would like to appear in the "from" field of th
Depending on your email provider, this may need to be an address you own. For example, Gmail requires this to be your actual email address.
### Sender Name (optional)
Configure a friendly name for the email sender.
### SMTP Host
Set this to the hostname or IP address of your SMTP host/server.

@ -0,0 +1,17 @@
# LunaSea
## Configuration
### Webhook URL
Copy either a device- or user-based webhook URL from the LunaSea app into this field.
### Profile Name (optional)
If not using the `default` profile in the LunaSea app, specify the name of the profile here.
Note that the entered profile name **_must_** match the name in LunaSea exactly (including any capitalization, punctuation, and/or whitespace).
{% hint style="info" %}
Please refer to the [LunaSea documentation](https://docs.lunasea.app/lunasea/notifications/overseerr) for more details on configuring these notifications.
{% endhint %}

@ -4,4 +4,8 @@
### Webhook URL
Simply [create a webhook](https://catflixserver.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks) and enter the URL in this field.
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
{% hint style="info" %}
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
{% endhint %}

@ -1,14 +1,7 @@
# Telegram
{% hint style="info" %}
All notification types will be sent to the chat ID configured in your Overseerr application settings.
If a user has configured a chat ID and has **Enable Notifications** checked in their Telegram notification user settings as well, they will be sent the following notification types for requests which they submit:
- Media Approved (does not include automatic approvals)
- Media Declined
- Media Available
Users can optionally configure their own notifications in their user settings.
{% endhint %}
## Configuration
@ -16,12 +9,12 @@ If a user has configured a chat ID and has **Enable Notifications** checked in t
{% hint style="info" %}
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
Bots **cannot** initiate conversations with users, users must have your bot added to a conversation in order to receive notifications.
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
{% endhint %}
### Bot Username (optional)
If this value is configured, users will be able to start a chat with your bot and configure their own personal notifications.
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
The bot username should end with `_bot`, and the `@` prefix should be omitted.
@ -35,4 +28,4 @@ To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https:
### Send Silently (optional)
Instagram allows you to enable silent notifications. Those will present a pop-up to the user, but will not make any sound. That's a per user configuration.
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.

@ -1,6 +1,6 @@
# Webhook
The webhook notification agent allows you to send a custom JSON payload to any endpoint.
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
## Configuration
@ -18,7 +18,7 @@ This value will be sent as an `Authorization` HTTP header.
### JSON Payload
Customize the JSON payload to suit your needs. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
Customize the JSON payload to suit your needs. Overseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
## Template Variables
@ -67,12 +67,12 @@ The following variables must be used as a key in the JSON payload (e.g., `"{{ext
These `{{media}}` special variables are only included in media-related notifications, such as requests.
- `{{media_type}}` Media type. Either `movie` or `tv`.
- `{{media_type}}` Media type (`movie` or `tv`).
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
#### Request
@ -82,5 +82,5 @@ The `{{request}}` special variables are only included in request-related notific
- `{{requestedBy_username}}` Requesting user's username.
- `{{requestedBy_email}}` Requesting user's email address.
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if one is set).
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if one is set).
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).

@ -0,0 +1,17 @@
# Web Push
The web push notification agent enables you and your users to receive Overseerr notifications in a supported browser.
This notification agent does not require any configuration, but is not enabled in Overseerr by default.
{% hint style="warning" %}
**The web push agent only works via HTTPS.** Refer to our [reverse proxy examples](../../extending-overseerr/reverse-proxy.md) for help on proxying Overseerr traffic via HTTPS.
{% endhint %}
To set up web push notifications, simply enable the agent in **Settings &rarr; Notifications &rarr; Web Push**. You and your users will then be prompted to allow notifications in your web browser.
Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings.
{% hint style="info" %}
Web push notifications offer a native notification experience without the need to install an app. iOS devices do not have support for these notifications at this time, however.
{% endhint %}

@ -14,11 +14,13 @@ If you aren't a huge fan of the name "Overseerr" and would like to display somet
### Application URL
Set this to the externally-accessible URL of your Overseerr instance. If configured, [notifications](../notifications/README.md) will include links!
Set this to the externally-accessible URL of your Overseerr instance.
You must configure this setting in order to enable password reset and [generation](../users/README.md#automatically-generate-password) emails.
### Enable Proxy Support
If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy-examples.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html).
If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html).
This setting is **disabled** by default.
@ -28,16 +30,20 @@ This setting is **disabled** by default.
**This is an advanced setting.** We do not recommend enabling it unless you understand the implications of doing so.
{% endhint %}
CSRF stands for **Cross-Site Request Forgery**. When this setting is enabled, all external API access that alters Overseerr application data is blocked.
CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Overseerr application data is blocked.
If you do not use Overseerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
One caveat, however, is that **HTTPS is required**, meaning that once this setting is enabled, you will no longer be able to access your Overseerr instance over HTTP (including using an IP address and port number).
One caveat, however, is that _HTTPS is required_, meaning that once this setting is enabled, you will no longer be able to access your Overseerr instance over HTTP (including using an IP address and port number).
If you enable this setting and find yourself unable to access Overseerr, you can disable the setting by modifying `settings.json` in `/app/config`.
This setting is **disabled** by default.
### Display Language
Set the default display language for Overseerr. Users can override this setting in their user settings.
### Discover Region & Discover Language
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
@ -58,7 +64,7 @@ This setting is **enabled** by default.
## Users
### Enable Local User Sign-In
### Enable Local Sign-In
When enabled, users who have configured passwords will be allowed to sign in using their email address.
@ -66,6 +72,12 @@ When disabled, Plex OAuth becomes the only sign-in option, and any "local users"
This setting is **enabled** by default.
### Enable New Plex Sign-In
When enabled, users with access to your Plex server will be able to sign in to Overseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
This setting is **enabled** by default.
### Global Movie Request Limit & Global Series Request Limit
Select the request limits you would like granted to users.
@ -74,11 +86,11 @@ Unless an [override](../users/README.md#movie-request-limit-and-series-request-l
Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users.
### Default User Permissions
### Default Permissions
Select the permissions you would like assigned to new users to have by default upon account creation.
It is important to configure this, as any user with access to your Plex server will be able to sign in to Overseerr, and they will be granted the permissions you select here upon first sign-in.
If [Enable New Plex Sign-In](#enable-new-plex-sign-in) is enabled, any user with access to your Plex server will be able to sign in to Overseerr, and they will be granted the permissions you select here upon first sign-in.
This setting only affects new users, and has no impact on existing users. In order to modify permissions for existing users, you will need to [edit the users](../users/README.md#editing-users).
@ -92,10 +104,6 @@ To set up Plex, you can either enter your details manually or select a server re
Depending on your setup/configuration, you may need to enter your Plex server details manually in order to establish a connection from Overseerr.
{% endhint %}
#### Server Name
This value is automatically retrieved from Plex, and cannot be edited manually in Overseerr.
#### Hostname or IP Address
If you have Overseerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`).
@ -104,15 +112,21 @@ If you have Overseerr installed on the same network as Plex, you can set this to
This value should be set to the port that your Plex server listens on. The default port that Plex uses is `32400`, but you may need to set this to `443` or some other value if your Plex server is hosted on a VPS or cloud provider.
#### SSL
#### Use SSL
Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
#### Web App URL (optional)
Tick this box to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported.
The **Play on Plex** buttons on media pages link to items on your Plex server. By default, these links use the [Plex Web App](https://support.plex.tv/articles/200288666-opening-plex-web-app/) hosted from plex.tv, but you can provide the URL to the web app on your Plex server and we'll use that instead!
Note that you will need to enter the full path to the web app (e.g., `https://plex.myawesomeserver.com/web`).
### Plex Libraries
In this section, simply select the libraries you would like Overseerr to scan. Overseerr will periodically check the selected libraries for available content to update the media status that is displayed to users.
If you do not see your Plex libraries listed, verify your Plex settings and then click the "Scan Plex Libraries" button.
If you do not see your Plex libraries listed, verify your Plex settings are correct and click the **Sync Libraries** button.
### Manual Library Scan
@ -121,15 +135,19 @@ Overseerr will perform a full scan of your Plex libraries once every 24 hours (r
## Services
{% hint style="info" %}
If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Overseerr.
**If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Overseerr.**
Overseerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media_.
If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.
**If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.**
{% endhint %}
### Radarr/Sonarr Settings
{% hint style="warning" %}
**Only v3 Radarr/Sonarr servers are supported!** If your Radarr/Sonarr server is still running v2, you will need to upgrade in order to add it to Overseerr.
{% endhint %}
#### Default Server
At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr.
@ -138,7 +156,7 @@ If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K
#### 4K Server
Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do **not** check this box!
Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do _not_ check this box!
#### Server Name
@ -152,35 +170,35 @@ If you have Overseerr installed on the same network as Radarr/Sonarr, you can se
This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider.
#### SSL
#### Use SSL
Tick this box to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported.
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
#### API Key
Enter your Radarr/Sonarr API key here. Do **not** share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers!
Enter your Radarr/Sonarr API key here. Do _not_ share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers!
You can locate the required API keys in Radarr/Sonarr in **Settings &rarr; General &rarr; Security**.
#### Base URL
#### URL Base
If you have configured a base URL for Radarr/Sonarr, you **must** enter it here in order for Overseerr to connect to those services!
If you have configured a URL base for your Radarr/Sonarr server, you _must_ enter it here in order for Overseerr to connect to those services!
You can verify whether or not you have a base URL configured in Radarr/Sonarr in **Settings &rarr; General &rarr; Host**. (Note that a restart of your Radarr/Sonarr servers is required if you modify this setting!)
You can verify whether or not you have a URL base configured in your Radarr/Sonarr server at **Settings &rarr; General &rarr; Host**. (Note that a restart of your Radarr/Sonarr server is required if you modify this setting!)
#### Profiles, Root Folder, Minimum Availability
Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured!
#### External URL
#### External URL (optional)
If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages.
#### Enable Scan
#### Enable Scan (optional)
Enable this setting if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
#### Enable Automatic Search
#### Enable Automatic Search (optional)
Enable this setting to have Radarr/Sonarr to automatically search for media upon approval of a request.

@ -6,13 +6,13 @@ The user account created during Overseerr setup is the "Owner" account, which ca
## Adding Users
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-user-permissions) defined in **Settings &rarr; Users**.
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings &rarr; Users**.
### Importing Users from Plex
Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-user-permissions) upon their first login.
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login.
### Creating Local Users
@ -24,7 +24,7 @@ Enter a valid email address at which the user can receive messages pertaining to
#### Automatically Generate Password
If [email notifications](../notifications/email.md) have been configured and enabled, Overseerr can automatically generate a password for the new user.
If an [application URL](../settings/README.md#application-url) is set and [email notifications](../notifications/email.md) have been configured and enabled, Overseerr can automatically generate a password for the new user.
#### Password
@ -42,6 +42,10 @@ You can also click the check boxes and click the **Bulk Edit** button to set use
You can optionally set a "friendly name" for any user. This name will be used in lieu of their Plex username (for users imported from Plex) or their email address (for manually-created local users).
#### Display Language
Users can override the [global display language](../settings/README.md#display-language) to use Overseerr in their preferred language.
#### Discover Region & Discover Language
Users can override the [global filter settings](../settings/README.md#discover-region-and-discover-language) to suit their own preferences.

@ -171,6 +171,9 @@ components:
readOnly: true
items:
$ref: '#/components/schemas/PlexLibrary'
webAppUrl:
type: string
example: 'https://app.plex.tv/desktop'
required:
- name
- machineId
@ -1210,8 +1213,6 @@ components:
type: string
userToken:
type: string
priority:
type: number
LunaSeaSettings:
type: object
properties:
@ -1596,6 +1597,9 @@ components:
nullable: true
discordEnabled:
type: boolean
discordEnabledTypes:
type: number
nullable: true
discordId:
type: string
nullable: true
@ -4533,7 +4537,7 @@ paths:
name: language
schema:
type: string
example: en-US
example: en
responses:
'200':
description: TV details

@ -17,34 +17,33 @@
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^1.1.1",
"@headlessui/react": "^1.2.0",
"@heroicons/react": "^1.0.1",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"@tanem/react-nprogress": "^3.0.64",
"@tanem/react-nprogress": "^3.0.67",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"body-parser": "^1.19.0",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"copy-to-clipboard": "^3.3.1",
"country-flag-icons": "^1.2.10",
"csurf": "^1.11.0",
"email-templates": "^8.0.4",
"email-templates": "^8.0.7",
"express": "^4.17.1",
"express-openapi-validator": "^4.12.9",
"express-openapi-validator": "^4.12.11",
"express-rate-limit": "^5.2.6",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"express-session": "^1.17.2",
"formik": "^2.2.9",
"gravatar-url": "3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
"next": "10.1.3",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.6.0",
"nodemailer": "^6.6.1",
"openpgp": "^5.0.0-2",
"plex-api": "^5.3.1",
"pug": "^3.0.2",
@ -52,13 +51,13 @@
"react-ace": "^9.3.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.2",
"react-intersection-observer": "^8.31.1",
"react-intl": "5.17.4",
"react-markdown": "^6.0.1",
"react-select": "^4.3.0",
"react-spring": "^8.0.27",
"react-intersection-observer": "^8.32.0",
"react-intl": "5.19.0",
"react-markdown": "^6.0.2",
"react-select": "^4.3.1",
"react-spring": "^9.2.3",
"react-toast-notifications": "^2.4.4",
"react-transition-group": "^4.4.1",
"react-transition-group": "^4.4.2",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"reflect-metadata": "^0.1.13",
@ -66,43 +65,42 @@
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.1.6",
"swr": "^0.5.6",
"typeorm": "^0.2.32",
"typeorm": "0.2.32",
"uuid": "^8.3.2",
"web-push": "^3.4.4",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.3",
"winston-daily-rotate-file": "^4.5.5",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.9"
},
"devDependencies": {
"@babel/cli": "^7.13.16",
"@commitlint/cli": "^12.1.1",
"@commitlint/config-conventional": "^12.1.1",
"@babel/cli": "^7.14.3",
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/exec": "^5.0.0",
"@semantic-release/git": "^9.0.0",
"@tailwindcss/aspect-ratio": "^0.2.0",
"@tailwindcss/forms": "^0.3.2",
"@tailwindcss/typography": "^0.4.0",
"@types/bcrypt": "^3.0.1",
"@types/body-parser": "^1.19.0",
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/forms": "^0.3.3",
"@tailwindcss/typography": "^0.4.1",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.1",
"@types/email-templates": "^8.0.3",
"@types/express": "^4.17.11",
"@types/express": "^4.17.12",
"@types/express-rate-limit": "^5.1.1",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
"@types/node": "^15.0.1",
"@types/lodash": "^4.14.170",
"@types/node": "^15.6.1",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.1",
"@types/react": "^17.0.4",
"@types/react-dom": "^17.0.3",
"@types/nodemailer": "^6.4.2",
"@types/react": "^17.0.9",
"@types/react-dom": "^17.0.6",
"@types/react-select": "^4.0.15",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-toast-notifications": "^2.4.1",
"@types/react-transition-group": "^4.4.1",
"@types/secure-random-password": "^0.2.0",
"@types/swagger-ui-express": "^4.1.2",
@ -111,32 +109,32 @@
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"autoprefixer": "^10.2.5",
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"autoprefixer": "^10.2.6",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.3",
"commitizen": "^4.2.4",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.25.0",
"eslint": "^7.27.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-formatjs": "^2.14.10",
"eslint-plugin-formatjs": "^2.15.5",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "4.3.8",
"lint-staged": "^10.5.4",
"lint-staged": "^11.0.0",
"nodemon": "^2.0.7",
"postcss": "^8.2.13",
"prettier": "^2.2.1",
"semantic-release": "^17.4.2",
"postcss": "^8.3.0",
"prettier": "^2.3.1",
"semantic-release": "^17.4.3",
"semantic-release-docker-buildx": "^1.0.1",
"tailwindcss": "^2.1.2",
"tailwindcss": "^2.1.4",
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
"typescript": "^4.3.2"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -1,3 +1,4 @@
/* eslint-disable no-undef */
// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
@ -33,7 +34,7 @@ self.addEventListener("activate", (event) => {
);
// Tell the active service worker to take control of the page immediately.
self.clients.claim();
clients.claim();
});
self.addEventListener("fetch", (event) => {
@ -57,6 +58,7 @@ self.addEventListener("fetch", (event) => {
// due to a network error.
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
// eslint-disable-next-line no-console
console.log("Fetch failed; returning offline page instead.", error);
const cache = await caches.open(CACHE_NAME);
@ -73,6 +75,7 @@ self.addEventListener('push', (event) => {
const options = {
body: payload.message,
badge: 'badge-128x128.png',
icon: payload.image ? payload.image : 'android-chrome-192x192.png',
vibrate: [100, 50, 100],
data: {
@ -109,7 +112,7 @@ self.addEventListener('push', (event) => {
event.waitUntil(
self.registration.showNotification(payload.subject, options)
);
})
});
self.addEventListener('notificationclick', (event) => {
const notificationData = event.notification.data;
@ -117,20 +120,20 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'viewmedia') {
self.clients.openWindow(notificationData.actionUrl);
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
self.clients.openWindow(notificationData.actionUrl);
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
self.clients.openWindow(notificationData.actionUrl);
clients.openWindow(notificationData.actionUrl);
} else if (notificationData.actionUrl) {
self.clients.openWindow(notificationData.actionUrl);
clients.openWindow(notificationData.actionUrl);
}
}, false);

@ -399,7 +399,7 @@ class TheMovieDb extends ExternalAPI {
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en-US',
language = 'en',
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,

@ -147,12 +147,22 @@ class Media {
@AfterLoad()
public setPlexUrls(): void {
const machineId = getSettings().plex.machineId;
const { machineId, webAppUrl } = getSettings().plex;
if (this.ratingKey) {
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
this.plexUrl = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey
}`;
}
if (this.ratingKey4k) {
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
this.plexUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
}
}

@ -48,11 +48,17 @@ export class User {
@PrimaryGeneratedColumn()
public id: number;
@Column({ unique: true })
@Column({
unique: true,
transformer: {
from: (value: string): string => (value ?? '').toLowerCase(),
to: (value: string): string => (value ?? '').toLowerCase(),
},
})
public email: string;
@Column({ nullable: true })
public plexUsername: string;
public plexUsername?: string;
@Column({ nullable: true })
public username?: string;
@ -220,7 +226,7 @@ export class User {
@AfterLoad()
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername;
this.displayName = this.username || this.plexUsername || this.email;
}
public async getQuota(): Promise<QuotaResponse> {

@ -108,7 +108,10 @@ export class UserSettings {
})
public notificationTypes: Partial<NotificationAgentTypes>;
public hasNotificationType(key: NotificationAgentKey, type: Notification) {
public hasNotificationType(
key: NotificationAgentKey,
type: Notification
): boolean {
return hasNotificationType(type, this.notificationTypes[key] ?? 0);
}
}

@ -1,5 +1,4 @@
import { getClientIp } from '@supercharge/request-ip';
import bodyParser from 'body-parser';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
@ -71,9 +70,9 @@ app
server.enable('trust proxy');
}
server.use(cookieParser());
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.use((req, res, next) => {
server.use(express.json());
server.use(express.urlencoded({ extended: true }));
server.use((req, _res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {

@ -22,6 +22,7 @@ export interface SettingsAboutResponse {
export interface PublicSettingsResponse {
initialized: boolean;
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
@ -33,6 +34,7 @@ export interface PublicSettingsResponse {
vapidPublic: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
}
export interface CacheItem {

@ -20,6 +20,7 @@ export interface UserSettingsNotificationsResponse {
emailEnabled?: boolean;
pgpKey?: string;
discordEnabled?: boolean;
discordEnabledTypes?: number;
discordId?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;

@ -24,6 +24,6 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
}
export interface NotificationAgent {
shouldSend(type: Notification): boolean;
shouldSend(): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
}

@ -91,7 +91,8 @@ interface DiscordWebhookPayload {
class DiscordAgent
extends BaseAgent<NotificationAgentDiscord>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentDiscord {
if (this.settings) {
return this.settings;
@ -192,12 +193,10 @@ class DiscordAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -208,6 +207,12 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Discord notification', {
label: 'Notifications',
type: Notification[type],
@ -217,16 +222,6 @@ class DiscordAgent
let content = undefined;
try {
const {
botUsername,
botAvatarUrl,
webhookUrl,
} = this.getSettings().options;
if (!webhookUrl) {
return false;
}
if (payload.notifyUser) {
// Mention user who submitted the request
if (
@ -251,15 +246,18 @@ class DiscordAgent
NotificationAgentKey.DISCORD,
type
) &&
user.settings?.discordId
user.settings?.discordId &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
}
await axios.post(webhookUrl, {
username: botUsername,
avatar_url: botAvatarUrl,
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
} as DiscordWebhookPayload);

@ -1,7 +1,7 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
@ -16,7 +16,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentEmail {
if (this.settings) {
return this.settings;
@ -27,12 +28,14 @@ class EmailAgent
return settings.notifications.agents.email;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types)
settings.options.emailFrom &&
settings.options.smtpHost &&
settings.options.smtpPort
) {
return true;
}
@ -207,7 +210,10 @@ class EmailAgent
NotificationAgentKey.EMAIL,
type
) ??
true))
true)) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map(async (user) => {
logger.debug('Sending email notification', {

@ -7,7 +7,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentLunaSea {
if (this.settings) {
return this.settings;
@ -49,12 +50,10 @@ class LunaSeaAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -65,6 +64,12 @@ class LunaSeaAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
@ -72,19 +77,19 @@ class LunaSeaAgent
});
try {
const { webhookUrl, profileName } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: `Basic ${Buffer.from(`${profileName}:`).toString(
'base64'
)}`,
},
});
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
? {
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
}
: undefined
);
return true;
} catch (e) {
@ -93,7 +98,7 @@ class LunaSeaAgent
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
response: e.response?.data,
});
return false;

@ -12,7 +12,8 @@ interface PushbulletPayload {
class PushbulletAgent
extends BaseAgent<NotificationAgentPushbullet>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentPushbullet {
if (this.settings) {
return this.settings;
@ -23,12 +24,10 @@ class PushbulletAgent
return settings.notifications.agents.pushbullet;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.accessToken) {
return true;
}
@ -136,6 +135,12 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
@ -143,14 +148,10 @@ class PushbulletAgent
});
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const { accessToken } = this.getSettings().options;
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
endpoint,
'https://api.pushbullet.com/v2/pushes',
{
type: 'note',
title: title,
@ -158,7 +159,7 @@ class PushbulletAgent
} as PushbulletPayload,
{
headers: {
'Access-Token': accessToken,
'Access-Token': settings.options.accessToken,
},
}
);

@ -18,7 +18,8 @@ interface PushoverPayload {
class PushoverAgent
extends BaseAgent<NotificationAgentPushover>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentPushover {
if (this.settings) {
return this.settings;
@ -29,12 +30,13 @@ class PushoverAgent
return settings.notifications.agents.pushover;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
this.getSettings().options.userToken &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
@ -160,6 +162,12 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
@ -168,19 +176,12 @@ class PushoverAgent
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
const { accessToken, userToken } = this.getSettings().options;
const {
title,
message,
url,
url_title,
priority,
} = this.constructMessageDetails(type, payload);
const { title, message, url, url_title, priority } =
this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
token: accessToken,
user: userToken,
token: settings.options.accessToken,
user: settings.options.userToken,
title: title,
message: message,
url: url,

@ -43,7 +43,8 @@ interface SlackBlockEmbed {
class SlackAgent
extends BaseAgent<NotificationAgentSlack>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentSlack {
if (this.settings) {
return this.settings;
@ -217,12 +218,10 @@ class SlackAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -233,19 +232,22 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildEmbed(type, payload));
await axios.post(
settings.options.webhookUrl,
this.buildEmbed(type, payload)
);
return true;
} catch (e) {

@ -1,7 +1,10 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@ -26,7 +29,8 @@ interface TelegramPhotoPayload {
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
implements NotificationAgent
{
private baseUrl = 'https://api.telegram.org/';
protected getSettings(): NotificationAgentTelegram {
@ -39,12 +43,13 @@ class TelegramAgent
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.botAPI &&
settings.options.chatId
) {
return true;
}
@ -58,8 +63,10 @@ class TelegramAgent
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
payload: NotificationPayload,
chatId: string,
sendSilently: boolean
): TelegramMessagePayload | TelegramPhotoPayload {
const settings = getSettings();
let message = '';
@ -152,67 +159,36 @@ class TelegramAgent
}
/* eslint-enable */
return message;
return payload.image
? ({
photo: payload.image,
caption: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramPhotoPayload)
: ({
text: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramMessagePayload);
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const endpoint = `${this.baseUrl}bot${this.getSettings().options.botAPI}/${
const settings = this.getSettings();
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
// Send system notification
try {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: this.getSettings().options.chatId,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
if (
payload.notifyUser &&
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
// Send notification to the user who submitted the request
if (hasNotificationType(type, settings.types ?? 0)) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
@ -220,27 +196,16 @@ class TelegramAgent
try {
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload)
this.buildMessage(
type,
payload,
settings.options.chatId,
settings.options.sendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
@ -251,6 +216,103 @@ class TelegramAgent
}
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
payload.notifyUser.settings.telegramChatId,
!!payload.notifyUser.settings.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map(async (user) => {
if (
user.settings?.telegramChatId &&
user.settings.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
user.settings.telegramChatId,
!!user.settings?.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
})
);
}
return true;
}
}

@ -40,7 +40,8 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
class WebhookAgent
extends BaseAgent<NotificationAgentWebhook>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentWebhook {
if (this.settings) {
return this.settings;
@ -112,12 +113,10 @@ class WebhookAgent
return this.parseKeys(parsedJSON, payload, type);
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -128,6 +127,12 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending webhook notification', {
label: 'Notifications',
type: Notification[type],
@ -135,17 +140,17 @@ class WebhookAgent
});
try {
const { webhookUrl, authHeader } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: authHeader,
},
});
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
);
return true;
} catch (e) {

@ -1,6 +1,6 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
@ -26,7 +26,8 @@ interface PushNotificationPayload {
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentConfig {
if (this.settings) {
return this.settings;
@ -42,6 +43,11 @@ class WebPushAgent
payload: NotificationPayload
): PushNotificationPayload {
switch (type) {
case Notification.NONE:
return {
notificationType: Notification[type],
subject: 'Unknown',
};
case Notification.TEST_NOTIFICATION:
return {
notificationType: Notification[type],
@ -129,11 +135,8 @@ class WebPushAgent
}
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
if (this.getSettings().enabled) {
return true;
}
@ -144,11 +147,6 @@ class WebPushAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending web push notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
const userRepository = getRepository(User);
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
@ -184,7 +182,10 @@ class WebPushAgent
NotificationAgentKey.WEBPUSH,
type
) ??
true)
true) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
);
const allSubs = await userPushSubRepository
@ -204,8 +205,15 @@ class WebPushAgent
settings.vapidPrivate
);
Promise.all(
await Promise.all(
pushSubs.map(async (sub) => {
logger.debug('Sending web push notification', {
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await webpush.sendNotification(
{
@ -221,12 +229,24 @@ class WebPushAgent
)
);
} catch (e) {
logger.error(
'Error sending web push notification; removing subscription',
{
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
}
);
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(sub);
}
})
);
}
return true;
}
}

@ -2,6 +2,7 @@ import logger from '../../logger';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
NONE = 0,
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
@ -29,6 +30,11 @@ export const hasNotificationType = (
total = types;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
@ -50,7 +56,7 @@ class NotificationManager {
});
this.activeAgents.forEach((agent) => {
if (agent.shouldSend(type)) {
if (agent.shouldSend()) {
agent.send(type, payload);
}
});

@ -145,9 +145,8 @@ class BaseScanner<T> {
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
changedExisting = true;
}
@ -156,9 +155,8 @@ class BaseScanner<T> {
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
externalServiceSlug;
changedExisting = true;
}
@ -389,15 +387,13 @@ class BaseScanner<T> {
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
externalServiceSlug;
}
// If the show is already available, and there are no new seasons, dont adjust
@ -420,7 +416,8 @@ class BaseScanner<T> {
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
: !seasons.length ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
@ -435,7 +432,8 @@ class BaseScanner<T> {
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
: !seasons.length ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING

@ -30,7 +30,8 @@ type SyncStatus = StatusBase & {
class PlexScanner
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private plexClient: PlexAPI;
private libraries: Library[];
private currentLibrary: Library;

@ -10,7 +10,8 @@ type SyncStatus = StatusBase & {
class RadarrScanner
extends BaseScanner<RadarrMovie>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;

@ -16,7 +16,8 @@ type SyncStatus = StatusBase & {
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;

@ -30,6 +30,7 @@ export interface PlexSettings {
port: number;
useSsl?: boolean;
libraries: Library[];
webAppUrl?: string;
}
export interface DVRSettings {
@ -97,6 +98,7 @@ interface PublicSettings {
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
@ -108,11 +110,12 @@ interface FullPublicSettings extends PublicSettings {
vapidPublic: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
}
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
types?: number;
options: Record<string, unknown>;
}
export interface NotificationAgentDiscord extends NotificationAgentConfig {
@ -149,7 +152,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
export interface NotificationAgentLunaSea extends NotificationAgentConfig {
options: {
webhookUrl: string;
profileName: string;
profileName?: string;
};
}
@ -172,7 +175,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
priority: number;
};
}
@ -180,7 +182,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
options: {
webhookUrl: string;
jsonPayload: string;
authHeader: string;
authHeader?: string;
};
}
@ -271,7 +273,6 @@ class Settings {
agents: {
email: {
enabled: false,
types: 0,
options: {
emailFrom: '',
smtpHost: '',
@ -287,8 +288,6 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAvatarUrl: '',
webhookUrl: '',
},
},
@ -297,7 +296,6 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
profileName: '',
},
},
slack: {
@ -311,7 +309,6 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAPI: '',
chatId: '',
sendSilently: false,
@ -330,7 +327,6 @@ class Settings {
options: {
accessToken: '',
userToken: '',
priority: 0,
},
},
webhook: {
@ -338,14 +334,12 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
authHeader: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
},
},
webpush: {
enabled: false,
types: 0,
options: {},
},
},
@ -404,6 +398,7 @@ class Settings {
return {
...this.data.public,
applicationTitle: this.data.main.applicationTitle,
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movie4kEnabled: this.data.radarr.some(
@ -419,6 +414,7 @@ class Settings {
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
};
}

@ -5,36 +5,35 @@ import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
const settings = getSettings();
let user: User | undefined;
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);
let userId = 1; // Work on original administrator account
// If a User ID is provided, we will act on that users behalf
// If a User ID is provided, we will act on that user's behalf
if (req.header('X-API-User')) {
userId = Number(req.header('X-API-User'));
}
const user = await userRepository.findOne({ where: { id: userId } });
if (user) {
req.user = user;
}
user = await userRepository.findOne({ where: { id: userId } });
} else if (req.session?.userId) {
const userRepository = getRepository(User);
const user = await userRepository.findOne({
user = await userRepository.findOne({
where: { id: req.session.userId },
});
}
if (user) {
req.user = user;
req.locale = user.settings?.locale
? user.settings?.locale
: settings.main.locale;
}
if (user) {
req.user = user;
}
req.locale = user?.settings?.locale
? user.settings.locale
: settings.main.locale;
next();
};

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddUserRequestDeleteCascades1608219049304';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface {
implements MigrationInterface
{
name = 'ForceDropImdbUniqueConstraint1608477467936';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface {
implements MigrationInterface
{
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaAddedFieldToMedia1610522845513
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddMediaAddedFieldToMedia1610522845513';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface {
implements MigrationInterface
{
name = 'SonarrRadarrSyncServiceFields1611757511674';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserSettingsRegions1613955393450
implements MigrationInterface {
implements MigrationInterface
{
name = 'UpdateUserSettingsRegions1613955393450';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramSettingsToUserSettings1614334195680
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddTelegramSettingsToUserSettings1614334195680';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface {
implements MigrationInterface
{
name = 'CreateTagsFieldonMediaRequest1617624225464';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddUserSettingsNotificationAgentsField1617730837489';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserPushSubscriptions1618912653565
implements MigrationInterface {
implements MigrationInterface
{
name = 'CreateUserPushSubscriptions1618912653565';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationTypes1619339817343
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddUserSettingsNotificationTypes1619339817343';
public async up(queryRunner: QueryRunner): Promise<void> {

@ -40,9 +40,13 @@ authRoutes.post('/plex', async (req, res, next) => {
const account = await plextv.getUser();
// Next let's see if the user already exists
let user = await userRepository.findOne({
where: { plexId: account.id },
});
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (user) {
// Let's check if their Plex token is up-to-date
@ -55,9 +59,12 @@ authRoutes.post('/plex', async (req, res, next) => {
user.email = account.email;
user.plexUsername = account.username;
if (user.username === account.username) {
user.username = '';
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = account.id;
}
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
@ -164,10 +171,11 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
try {
const user = await userRepository.findOne({
select: ['id', 'password'],
where: { email: body.email },
});
const user = await userRepository
.createQueryBuilder('user')
.select(['user.id', 'user.password'])
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
const isCorrectCredentials = await user?.passwordMatch(body.password);
@ -231,9 +239,10 @@ authRoutes.post('/reset-password', async (req, res) => {
.json({ error: 'You must provide an email address.' });
}
const user = await userRepository.findOne({
where: { email: body.email },
});
const user = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
if (user) {
await user.resetPassword();

@ -84,7 +84,7 @@ router.use('/user', isAuthenticated(), user);
router.get('/settings/public', async (req, res) => {
const settings = getSettings();
if (!req.user?.settings?.notificationTypes.webpush) {
if (!(req.user?.settings?.notificationTypes.webpush ?? true)) {
return res
.status(200)
.json({ ...settings.fullPublicSettings, enablePushRegistration: false });

@ -15,10 +15,8 @@ mediaRoutes.get('/', async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 20;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let statusFilter:
| MediaStatus
| FindOperator<MediaStatus>
| undefined = undefined;
let statusFilter: MediaStatus | FindOperator<MediaStatus> | undefined =
undefined;
switch (req.query.filter) {
case 'available':

@ -251,20 +251,22 @@ requestRoutes.post('/', async (req, res, next) => {
}
if (req.body.mediaType === MediaType.MOVIE) {
const existing = await requestRepository.findOne({
where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
});
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.where('request.is4k = :is4k', { is4k: req.body.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('request.status != :requestStatus', {
requestStatus: MediaRequestStatus.DECLINED,
})
.getOne();
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
is4k: req.body.is4k,
label: 'Media Request',
});
return next({
status: 409,

@ -31,7 +31,7 @@ router.get('/', async (req, res, next) => {
break;
case 'displayname':
query = query.orderBy(
'(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)',
"(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
'ASC'
);
break;
@ -82,9 +82,12 @@ router.post(
const body = req.body;
const userRepository = getRepository(User);
const existingUser = await userRepository.findOne({
where: { email: body.email },
});
const existingUser = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', {
email: body.email.toLowerCase(),
})
.getOne();
if (existingUser) {
return next({
@ -393,47 +396,45 @@ router.post(
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: [{ plexId: account.id }, { email: account.email }],
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// in-case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
if (user.username === account.username) {
user.username = '';
if (account.email) {
const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (user) {
// Update the user's avatar with their Plex thumbnail, in case it changed
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
} else {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (
account.email &&
account.username &&
(await mainPlexTv.checkUserAccess(Number(account.id)))
) {
const newUser = new User({
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });

@ -238,7 +238,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings();
const settings = getSettings()?.notifications.agents;
try {
const user = await userRepository.findOne({
@ -250,16 +250,18 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
emailEnabled: settings?.notifications.agents.email.enabled,
emailEnabled: settings?.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled: settings?.notifications.agents.discord.enabled,
discordEnabled: settings?.discord.enabled,
discordEnabledTypes: settings?.discord.enabled
? settings?.discord.types
: 0,
discordId: user.settings?.discordId,
telegramEnabled: settings?.notifications.agents.telegram.enabled,
telegramBotUsername:
settings?.notifications.agents.telegram.options.botUsername,
telegramEnabled: settings?.telegram.enabled,
telegramBotUsername: settings?.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.notifications.agents.webpush.enabled,
webPushEnabled: settings?.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {

@ -31,7 +31,9 @@ export class MediaSubscriber implements EntitySubscriberInterface {
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
notifyUser: request.requestedBy,
subject: movie.title,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: movie.overview,
media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
@ -84,7 +86,9 @@ export class MediaSubscriber implements EntitySubscriberInterface {
);
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
subject: tv.name,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: tv.overview,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,

@ -11,22 +11,22 @@ confinement: strict
parts:
overseerr:
plugin: nodejs
nodejs-version: "14.16.1"
nodejs-package-manager: "yarn"
nodejs-version: '14.16.1'
nodejs-package-manager: 'yarn'
nodejs-yarn-version: v1.22.10
build-packages:
- git
- on arm64:
- build-essential
- automake
- python-gi
- python-gi-dev
- build-essential
- automake
- python-gi
- python-gi-dev
- on armhf:
- libatomic1
- build-essential
- automake
- python-gi
- python-gi-dev
- libatomic1
- build-essential
- automake
- python-gi
- python-gi-dev
source: .
override-pull: |
snapcraftctl pull
@ -56,7 +56,7 @@ parts:
snapcraftctl set-version "$SNAP_VERSION"
snapcraftctl set-grade "$GRADE"
build-environment:
- PATH: "$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH"
- PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH'
override-build: |
set -e
# Set COMMIT_TAG before the build begins
@ -72,11 +72,9 @@ parts:
rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml
stage-packages:
- on armhf:
- libatomic1
stage:
[ .next, ./* ]
prime:
[ .next, ./* ]
- libatomic1
stage: [.next, ./*]
prime: [.next, ./*]
apps:
deamon:
@ -89,8 +87,8 @@ apps:
- network
- network-bind
environment:
PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH"
OVERSEERR_SNAP: "True"
PATH: '$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH'
OVERSEERR_SNAP: 'True'
CONFIG_DIRECTORY: $SNAP_USER_COMMON
LOG_LEVEL: "debug"
NODE_ENV: "production"
LOG_LEVEL: 'debug'
NODE_ENV: 'production'

@ -1 +1 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path d="m81.9 83.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1 0.1-6.1-4.5-11.1-10.2-11.1zm36.5 0c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z" fill="currentColor"/><path d="m167 0h-134c-11.3 0-20.5 9.2-20.5 20.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19v-179.4c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-0.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-0.9-4.8-2-7.3-3.4-0.3-0.2-0.6-0.3-0.9-0.5-0.2-0.1-0.3-0.2-0.4-0.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-0.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3 0.6-0.1 1.1-0.2 1.7-0.2 6.1-0.8 13-1 20.2-0.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-0.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z" fill="currentColor"/></svg>
<svg viewBox="0 0 71 71" xmlns="http://www.w3.org/2000/svg"><path transform="translate(-1.0994e-7 8.0294)" d="m60.104 4.8978c-4.5253-2.0764-9.378-3.6062-14.452-4.4824-0.0924-0.01691-0.1847 0.025349-0.2323 0.10987-0.6241 1.11-1.3154 2.5581-1.7995 3.6963-5.4572-0.817-10.886-0.817-16.232 0-0.4842-1.1635-1.2006-2.5863-1.8275-3.6963-0.0476-0.0817-0.1399-0.12396-0.2323-0.10987-5.071 0.87338-9.9237 2.4032-14.452 4.4824-0.0392 0.0169-0.0728 0.0451-0.0951 0.0817-9.2046 13.751-11.726 27.165-10.489 40.412 0.005597 0.0648 0.041978 0.1268 0.092353 0.1662 6.0729 4.4598 11.956 7.1673 17.729 8.9619 0.0924 0.0282 0.1903-0.0056 0.2491-0.0817 1.3657-1.865 2.5831-3.8315 3.6269-5.8995 0.0616-0.1211 0.0028-0.2648-0.1231-0.3127-1.931-0.7325-3.7697-1.6256-5.5384-2.6398-0.1399-0.0817-0.1511-0.2818-0.0224-0.3776 0.3722-0.2789 0.7445-0.5691 1.0999-0.8621 0.0643-0.0535 0.1539-0.0648 0.2295-0.031 11.62 5.3051 24.199 5.3051 35.682 0 0.0756-0.0366 0.1652-0.0253 0.2323 0.0282 0.3555 0.293 0.7277 0.586 1.1027 0.8649 0.1287 0.0958 0.1203 0.2959-0.0196 0.3776-1.7687 1.0339-3.6074 1.9073-5.5412 2.637-0.1259 0.0479-0.1819 0.1944-0.1203 0.3155 1.0662 2.0651 2.2836 4.0316 3.6241 5.8967 0.056 0.0789 0.1567 0.1127 0.2491 0.0845 5.8014-1.7946 11.684-4.5021 17.757-8.9619 0.0532-0.0394 0.0868-0.0986 0.0924-0.1634 1.4804-15.315-2.4796-28.618-10.498-40.412-0.0196-0.0394-0.0531-0.0676-0.0923-0.0845zm-36.379 32.428c-3.4983 0-6.3808-3.2117-6.3808-7.156s2.8266-7.156 6.3808-7.156c3.5821 0 6.4367 3.2399 6.3807 7.156 0 3.9443-2.8266 7.156-6.3807 7.156zm23.592 0c-3.4982 0-6.3807-3.2117-6.3807-7.156s2.8265-7.156 6.3807-7.156c3.5822 0 6.4367 3.2399 6.3808 7.156 0 3.9443-2.7986 7.156-6.3808 7.156z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -60,9 +60,8 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
}
);
const { data: genres } = useSWR<{ id: number; name: string }[]>(
`/api/v1/genres/movie`
);
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
if (!data && !error) {
return <LoadingSpinner />;

@ -6,6 +6,7 @@ import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
import Button, { ButtonType } from '../Button';
import CachedImage from '../CachedImage';
import LoadingSpinner from '../LoadingSpinner';
interface ModalProps {
@ -29,6 +30,7 @@ interface ModalProps {
backgroundClickable?: boolean;
iconSvg?: ReactNode;
loading?: boolean;
backdrop?: string;
}
const Modal: React.FC<ModalProps> = ({
@ -53,6 +55,7 @@ const Modal: React.FC<ModalProps> = ({
tertiaryDisabled = false,
tertiaryText,
onTertiary,
backdrop,
}) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
@ -66,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-50"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-70"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
@ -98,7 +101,7 @@ const Modal: React.FC<ModalProps> = ({
show={!loading}
>
<div
className="relative inline-block w-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
className="relative inline-block w-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl ring-1 ring-gray-500 sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@ -107,7 +110,25 @@ const Modal: React.FC<ModalProps> = ({
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
}}
>
<div className="sm:flex sm:items-center">
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
<CachedImage
alt=""
src={backdrop}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(55, 65, 81, 0.85) 0%, rgba(55, 65, 81, 1) 100%)',
}}
/>
</div>
)}
<div className="relative sm:flex sm:items-center">
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
@ -125,12 +146,12 @@ const Modal: React.FC<ModalProps> = ({
</div>
</div>
{children && (
<div className="mt-4 text-sm leading-5 text-gray-300">
<div className="relative mt-4 text-sm leading-5 text-gray-300">
{children}
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
<div className="relative flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}

@ -43,6 +43,7 @@ const SensitiveInput: React.FC<SensitiveInputProps> = ({
e.preventDefault();
setHidden(!isHidden);
}}
type="button"
className="input-action"
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}

@ -136,34 +136,32 @@ const SettingsTabs: React.FC<{
</nav>
</div>
) : (
<div className="hidden sm:block">
<div className="border-b border-gray-600">
<nav className="flex -mb-px">
{settingsRoutes
.filter(
(route) =>
!route.hidden &&
(route.requiredPermission
? hasPermission(
route.requiredPermission,
currentUser?.permissions ?? 0,
route.permissionType
)
: true)
)
.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
<div className="hidden overflow-x-scroll border-b border-gray-600 sm:block hide-scrollbar">
<nav className="flex">
{settingsRoutes
.filter(
(route) =>
!route.hidden &&
(route.requiredPermission
? hasPermission(
route.requiredPermission,
currentUser?.permissions ?? 0,
route.permissionType
)
: true)
)
.map((route, index) => (
<SettingsLink
tabType={tabType}
currentPath={router.pathname}
route={route.route}
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
</SettingsLink>
))}
</nav>
</div>
)}
</>

@ -44,7 +44,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-70 bg-gray-800`}
onClick={() => onClose()}
onKeyDown={(e) => {
if (e.key === 'Escape') {

@ -35,13 +35,11 @@ const Discover: React.FC = () => {
{ revalidateOnMount: true }
);
const {
data: requests,
error: requestError,
} = useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
);
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
);
return (
<>

@ -3,7 +3,7 @@ import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
availableLanguages,
AvailableLocales,
AvailableLocale,
} from '../../../context/LanguageContext';
import useClickOutside from '../../../hooks/useClickOutside';
import useLocale from '../../../hooks/useLocale';
@ -58,16 +58,18 @@ const LanguagePicker: React.FC = () => {
id="language"
className="rounded-md"
onChange={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
setLocale && setLocale(e.target.value as AvailableLocale)
}
onBlur={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
setLocale && setLocale(e.target.value as AvailableLocale)
}
defaultValue={locale}
>
{(Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]).map((key) => (
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option key={key} value={availableLanguages[key].code}>
{availableLanguages[key].display}
</option>

@ -3,6 +3,9 @@ import { ArrowLeftIcon, InformationCircleIcon } from '@heroicons/react/solid';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { AvailableLocale } from '../../context/LanguageContext';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import SearchInput from './SearchInput';
import Sidebar from './Sidebar';
@ -16,9 +19,21 @@ const messages = defineMessages({
const Layout: React.FC = ({ children }) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
const { currentSettings } = useSettings();
const { setLocale } = useLocale();
useEffect(() => {
if (setLocale && user) {
setLocale(
(user?.settings?.locale
? user.settings.locale
: currentSettings.locale) as AvailableLocale
);
}
}, [setLocale, currentSettings.locale, user]);
useEffect(() => {
const updateScrolled = () => {
@ -54,8 +69,8 @@ const Layout: React.FC = ({ children }) => {
}}
>
<button
className={`px-4 ${
isScrolled ? 'text-gray-200' : 'text-gray-400'
className={`px-4 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} focus:outline-none md:hidden transition duration-300`}
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
@ -64,15 +79,15 @@ const Layout: React.FC = ({ children }) => {
</button>
<div className="flex items-center justify-between flex-1 pr-4 md:pr-4 md:pl-4">
<button
className={`mr-2 ${
isScrolled ? 'text-gray-200' : 'text-gray-400'
className={`mr-2 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} transition duration-300 hover:text-white pwa-only focus:outline-none focus:text-white`}
onClick={() => router.back()}
>
<ArrowLeftIcon className="w-7" />
</button>
<SearchInput />
<div className="flex items-center ml-2">
<div className="flex items-center">
<UserDropdown />
</div>
</div>

@ -5,6 +5,7 @@ import Link from 'next/link';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
import useSettings from '../../hooks/useSettings';
import Button from '../Common/Button';
import SensitiveInput from '../Common/SensitiveInput';
@ -25,6 +26,7 @@ interface LocalLoginProps {
const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
const intl = useIntl();
const settings = useSettings();
const [loginError, setLoginError] = useState<string | null>(null);
const LoginSchema = Yup.object().shape({
@ -36,6 +38,10 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
),
});
const passwordResetEnabled =
settings.currentSettings.applicationUrl &&
settings.currentSettings.emailEnabled;
return (
<Formik
initialValues={{
@ -60,7 +66,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
return (
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
@ -101,17 +107,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
)}
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<SupportIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
@ -126,6 +122,18 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
</span>
</Button>
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<SupportIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
)}
</div>
</div>
</Form>

@ -98,9 +98,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
`/api/v1/movie/${router.query.movieId}/ratings`
);
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
data,
]);
const sortedCrew = useMemo(
() => sortCrewPriority(data?.credits.crew ?? []),
[data]
);
if (!data && !error) {
return <LoadingSpinner />;

@ -38,7 +38,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
: currentTypes + option.value
);
}}
defaultChecked={
checked={
hasNotificationType(option.value, currentTypes) ||
(!!parent?.value &&
hasNotificationType(parent.value, currentTypes))
@ -46,10 +46,12 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="font-medium text-white">
{option.name}
<label htmlFor={option.id} className="block font-medium text-white">
<div className="flex flex-col">
<span>{option.name}</span>
<span className="text-gray-500">{option.description}</span>
</div>
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (

@ -1,27 +1,42 @@
import React from 'react';
import { sortBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import { Permission, User, useUser } from '../../hooks/useUser';
import NotificationType from './NotificationType';
const messages = defineMessages({
notificationTypes: 'Notification Types',
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when media is requested and requires approval.',
'Send notifications when users submit new media requests which require approval.',
usermediarequestedDescription:
'Get notified when other users submit new media requests which require approval.',
mediaapproved: 'Media Approved',
mediaapprovedDescription:
'Sends a notification when requested media is manually approved.',
'Send notifications when media requests are manually approved.',
usermediaapprovedDescription:
'Get notified when your media requests are approved.',
mediaAutoApproved: 'Media Automatically Approved',
mediaAutoApprovedDescription:
'Sends a notification when requested media is automatically approved.',
'Send notifications when users submit new media requests which are automatically approved.',
usermediaAutoApprovedDescription:
'Get notified when other users submit new media requests which are automatically approved.',
mediaavailable: 'Media Available',
mediaavailableDescription:
'Sends a notification when requested media becomes available.',
'Send notifications when media requests become available.',
usermediaavailableDescription:
'Get notified when your media requests become available.',
mediafailed: 'Media Failed',
mediafailedDescription:
'Sends a notification when requested media fails to be added to Radarr or Sonarr.',
'Send notifications when media requests fail to be added to Radarr or Sonarr.',
usermediafailedDescription:
'Get notified when media requests fail to be added to Radarr or Sonarr.',
mediadeclined: 'Media Declined',
mediadeclinedDescription:
'Sends a notification when a media request is declined.',
'Send notifications when media requests are declined.',
usermediadeclinedDescription:
'Get notified when your media requests are declined.',
});
export const hasNotificationType = (
@ -30,20 +45,28 @@ export const hasNotificationType = (
): 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;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
export enum Notification {
NONE = 0,
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
@ -62,69 +85,183 @@ export interface NotificationItem {
name: string;
description: string;
value: Notification;
hasNotifyUser?: boolean;
children?: NotificationItem[];
hidden?: boolean;
}
interface NotificationTypeSelectorProps {
user?: User;
enabledTypes?: number;
currentTypes: number;
onUpdate: (newTypes: number) => void;
error?: string;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
user,
enabledTypes = ALL_NOTIFICATIONS,
currentTypes,
onUpdate,
error,
}) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser({ id: user?.id });
const [allowedTypes, setAllowedTypes] = useState(enabledTypes);
const availableTypes = useMemo(() => {
const allRequestsAutoApproved =
user &&
// Has Manage Requests perm, which grants all Auto-Approve perms
(hasPermission(Permission.MANAGE_REQUESTS) ||
// Cannot submit requests of any type
!hasPermission(
[
Permission.REQUEST,
Permission.REQUEST_MOVIE,
Permission.REQUEST_TV,
Permission.REQUEST_4K,
Permission.REQUEST_4K_MOVIE,
Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) ||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
{ type: 'or' }
)) &&
// Cannot submit 4K movie requests OR has Auto-Approve perms for 4K movies
(!settings.currentSettings.movie4kEnabled ||
!hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit 4K series requests OR has Auto-Approve perms for 4K series
(!settings.currentSettings.series4kEnabled ||
!hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_TV],
{ type: 'or' }
))));
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(
user
? messages.usermediarequestedDescription
: messages.mediarequestedDescription
),
value: Notification.MEDIA_PENDING,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-auto-approved',
name: intl.formatMessage(messages.mediaAutoApproved),
description: intl.formatMessage(
user
? messages.usermediaAutoApprovedDescription
: messages.mediaAutoApprovedDescription
),
value: Notification.MEDIA_AUTO_APPROVED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(
user
? messages.usermediaapprovedDescription
: messages.mediaapprovedDescription
),
value: Notification.MEDIA_APPROVED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(
user
? messages.usermediadeclinedDescription
: messages.mediadeclinedDescription
),
value: Notification.MEDIA_DECLINED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(
user
? messages.usermediaavailableDescription
: messages.mediaavailableDescription
),
value: Notification.MEDIA_AVAILABLE,
hasNotifyUser: true,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(
user
? messages.usermediafailedDescription
: messages.mediafailedDescription
),
value: Notification.MEDIA_FAILED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
];
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(messages.mediarequestedDescription),
value: Notification.MEDIA_PENDING,
},
{
id: 'media-auto-approved',
name: intl.formatMessage(messages.mediaAutoApproved),
description: intl.formatMessage(messages.mediaAutoApprovedDescription),
value: Notification.MEDIA_AUTO_APPROVED,
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(messages.mediaapprovedDescription),
value: Notification.MEDIA_APPROVED,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(messages.mediadeclinedDescription),
value: Notification.MEDIA_DECLINED,
},
{
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,
},
];
const filteredTypes = types.filter(
(type) => !type.hidden && hasNotificationType(type.value, enabledTypes)
);
const newAllowedTypes = filteredTypes.reduce((a, v) => a + v.value, 0);
if (newAllowedTypes !== allowedTypes) {
setAllowedTypes(newAllowedTypes);
}
return user
? sortBy(filteredTypes, 'hasNotifyUser', 'DESC')
: filteredTypes;
}, [user, hasPermission, settings, intl, allowedTypes, enabledTypes]);
if (!availableTypes.length) {
return null;
}
return (
<div role="group" aria-labelledby="group-label" className="form-group">
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationTypes)}
<span className="label-required">*</span>
{!user && <span className="label-required">*</span>}
</span>
<div className="form-input">
<div className="max-w-lg">
{types.map((type) => (
{availableTypes.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
@ -133,6 +270,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
/>
))}
</div>
{error && <div className="error">{error}</div>}
</div>
</div>
</div>

@ -32,12 +32,10 @@ const PersonDetails: React.FC = () => {
);
const [showBio, setShowBio] = useState(false);
const {
data: combinedCredits,
error: errorCombinedCredits,
} = useSWR<PersonCombinedCreditsResponse>(
`/api/v1/person/${router.query.personId}/combined_credits`
);
const { data: combinedCredits, error: errorCombinedCredits } =
useSWR<PersonCombinedCreditsResponse>(
`/api/v1/person/${router.query.personId}/combined_credits`
);
const sortedCast = useMemo(() => {
const grouped = groupBy(combinedCredits?.cast ?? [], 'id');

@ -2,8 +2,13 @@ import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
movieRequestLimit: '{quotaLimit} movie(s) per {quotaDays} day(s)',
tvRequestLimit: '{quotaLimit} season(s) per {quotaDays} day(s)',
movieRequests:
'{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>',
tvRequests:
'{quotaLimit} <quotaUnits>{seasons} per {quotaDays} {days}</quotaUnits>',
movies: '{count, plural, one {movie} other {movies}}',
seasons: '{count, plural, one {season} other {seasons}}',
days: '{count, plural, one {day} other {days}}',
unlimited: 'Unlimited',
});
@ -47,9 +52,7 @@ const QuotaSelector: React.FC<QuotaSelectorProps> = ({
return (
<div className={`${isDisabled ? 'opacity-50' : ''}`}>
{intl.formatMessage(
mediaType === 'movie'
? messages.movieRequestLimit
: messages.tvRequestLimit,
mediaType === 'movie' ? messages.movieRequests : messages.tvRequests,
{
quotaLimit: (
<select
@ -82,6 +85,16 @@ const QuotaSelector: React.FC<QuotaSelectorProps> = ({
))}
</select>
),
movies: intl.formatMessage(messages.movies, { count: quotaLimit }),
seasons: intl.formatMessage(messages.seasons, { count: quotaLimit }),
days: intl.formatMessage(messages.days, { count: quotaDays }),
quotaUnits: function quotaUnits(msg) {
return (
<span className={limitOverride || quotaLimit ? '' : 'hidden'}>
{msg}
</span>
);
},
}
)}
</div>

@ -1,9 +1,16 @@
import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid';
import {
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import {
MediaRequestStatus,
@ -18,10 +25,12 @@ import { withProperties } from '../../utils/typeHelpers';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import RequestModal from '../RequestModal';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
failedretry: 'Something went wrong while retrying the request.',
mediaerror: 'The associated title for this request is no longer available.',
deleterequest: 'Delete Request',
});
@ -89,7 +98,10 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
triggerOnce: true,
});
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const { addToast } = useToasts();
const [isRetrying, setRetrying] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
@ -113,6 +125,30 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
};
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
const retryRequest = async () => {
setRetrying(true);
try {
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
if (response) {
revalidate();
}
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setRetrying(false);
}
};
useEffect(() => {
if (title && onTitleData) {
onTitleData(request.id, title);
@ -136,25 +172,211 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
return (
<div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700">
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
<>
<RequestModal
show={showEditModal}
tmdbId={request.media.tmdbId}
type={request.type}
is4k={request.is4k}
editRequest={request}
onCancel={() => setShowEditModal(false)}
onComplete={() => {
revalidate();
setShowEditModal(false);
}}
/>
<div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700">
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4">
<div className="hidden text-xs text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
</div>
<Link
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="overflow-hidden text-base text-white sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
<div className="items-center my-0.5 sm:my-1 text-sm hidden sm:flex">
<span className="mr-2 font-medium">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
{title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
<div className="flex items-center mt-2 text-sm sm:mt-1">
<span className="hidden mr-2 font-medium sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/>
)}
</div>
<div className="flex items-end flex-1 space-x-2">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<RefreshIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ marginRight: '0', animationDirection: 'reverse' }}
/>
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id &&
(requestData.type === 'tv' ||
hasPermission(Permission.REQUEST_ADVANCED)) && (
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)}
className={`${
hasPermission(Permission.MANAGE_REQUESTS) ? 'sm:hidden' : ''
}`}
>
<PencilIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.cancel)}
</span>
</Button>
)}
</div>
</div>
)}
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4">
<Link
href={
request.type === 'movie'
@ -162,129 +384,22 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="pb-0.5 sm:pb-1 overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
{!isMovie(title) && request.seasons.length > 0 && (
<div className="sm:flex items-center my-0.5 sm:my-1 text-sm hidden">
<span className="mr-2 font-medium">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
{title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
<div className="flex items-center mt-2 text-sm sm:mt-1">
<span className="hidden mr-2 font-medium sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
<a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
alt=""
layout="responsive"
width={600}
height={900}
/>
)}
</div>
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex items-end flex-1 space-x-2">
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</div>
)}
</a>
</Link>
</div>
<Link
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
/>
</a>
</Link>
</div>
</>
);
};

@ -32,6 +32,7 @@ const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
failedretry: 'Something went wrong while retrying the request.',
requested: 'Requested',
requesteddate: 'Requested',
modified: 'Modified',
modifieduserdate: '{date} by {user}',
mediaerror: 'The associated title for this request is no longer available.',
@ -105,12 +106,13 @@ const RequestItem: React.FC<RequestItemProps> = ({
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
);
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
initialData: request,
}
);
const {
data: requestData,
revalidate,
mutate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
initialData: request,
});
const [isRetrying, setRetrying] = useState(false);
@ -219,33 +221,23 @@ const RequestItem: React.FC<RequestItemProps> = ({
</a>
</Link>
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
<div className="card-field">
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-lg text-white truncate xl:text-xl hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
</div>
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="text-sm text-gray-300 truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
<div className="pt-0.5 sm:pt-1 text-xs text-white">
{(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-lg text-white truncate xl:text-xl hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{!isMovie(title) && request.seasons.length > 0 && (
<div className="card-field">
<span className="card-field-name">
@ -276,7 +268,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
)}
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
@ -308,29 +300,20 @@ const RequestItem: React.FC<RequestItemProps> = ({
)}
</div>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.requested)}
</span>
<span className="text-gray-300">
{intl.formatDate(requestData.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.modified)}
</span>
<span className="truncate">
{requestData.modifiedBy ? (
<span className="flex text-sm text-gray-300">
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requested)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() -
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
@ -339,26 +322,77 @@ const RequestItem: React.FC<RequestItemProps> = ({
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="flex items-center group">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center truncate group">
<img
src={requestData.modifiedBy.avatar}
src={requestData.requestedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.modifiedBy.displayName}
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
) : (
<span className="text-sm text-gray-300">N/A</span>
)}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex text-sm text-gray-300 truncate">
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</>
)}
</div>
{requestData.modifiedBy && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.modified)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="flex items-center truncate group">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</div>
)}
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 space-y-2 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">

@ -26,7 +26,7 @@ type OptionType = {
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
advancedoptions: 'Advanced Options',
advancedoptions: 'Advanced',
destinationserver: 'Destination Server',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
@ -97,21 +97,19 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
defaultOverrides?.tags ?? []
);
const {
data: serverData,
isValidating,
} = useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
? `/api/v1/service/${
type === 'movie' ? 'radarr' : 'sonarr'
}/${selectedServer}`
: null,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
}
);
const { data: serverData, isValidating } =
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
? `/api/v1/service/${
type === 'movie' ? 'radarr' : 'sonarr'
}/${selectedServer}`
: null,
{
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
}
);
const [selectedUser, setSelectedUser] = useState<User | null>(
requestUser ?? null
@ -276,9 +274,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</div>
<div className="p-4 bg-gray-600 rounded-md shadow">
{!!data && selectedServer !== null && (
<>
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
<div className="flex flex-col md:flex-row">
{data.filter((server) => server.is4k === is4k).length > 1 && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
@ -288,7 +286,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
>
{data
.filter((server) => server.is4k === is4k)
@ -306,7 +304,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
)}
{(isValidating ||
!serverData ||
serverData.profiles.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
@ -316,7 +318,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@ -346,11 +348,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
))}
</select>
</div>
<div
className={`flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0 ${
type === 'tv' ? 'md:pr-4' : ''
}`}
>
)}
{(isValidating ||
!serverData ||
serverData.rootFolders.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
@ -360,7 +362,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@ -399,8 +401,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
))}
</select>
</div>
{type === 'tv' && (
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
)}
{type === 'tv' &&
(isValidating ||
!serverData ||
(serverData.languageProfiles ?? []).length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<label htmlFor="language">
{intl.formatMessage(messages.languageprofile)}
</label>
@ -414,7 +420,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
onBlur={(e) =>
setSelectedLanguage(parseInt(e.target.value))
}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
className="bg-gray-800 border-gray-700"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@ -447,147 +453,151 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</select>
</div>
)}
</div>
</>
)}
{!!data && selectedServer !== null && (
<div className="mt-0 sm:mt-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
isDisabled={isValidating || !serverData}
placeholder={
isValidating || !serverData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container react-select-container-dark"
classNamePrefix="react-select"
value={selectedTags.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
}}
noOptionsMessage={() => intl.formatMessage(messages.notagoptions)}
/>
</div>
)}
{selectedServer !== null &&
(isValidating || !serverData || !!serverData?.tags?.length) && (
<div className="mb-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
isDisabled={isValidating || !serverData}
placeholder={
isValidating || !serverData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container react-select-container-dark"
classNamePrefix="react-select"
value={selectedTags.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="mt-2 first:mt-0">
<Listbox
as="div"
value={selectedUser}
onChange={(value) => setSelectedUser(value)}
className="space-y-1"
>
{({ open }) => (
<>
<Listbox.Label>
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="block ml-3">
{selectedUser.displayName}
</span>
<Listbox
as="div"
value={selectedUser}
onChange={(value) => setSelectedUser(value)}
className="space-y-1"
>
{({ open }) => (
<>
<Listbox.Label>
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="block ml-3">
{selectedUser.displayName}
</span>
{selectedUser.displayName.toLowerCase() !==
selectedUser.email && (
<span className="ml-1 text-gray-400 truncate">
({selectedUser.email})
</span>
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
</span>
</Listbox.Button>
</span>
)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
</span>
</Listbox.Button>
</span>
<Transition
show={open}
enter="transition ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
<Transition
show={open}
enter="transition ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
{userData?.results.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div
{userData?.results.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
{user.displayName}
</span>
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
{user.displayName}
</span>
{user.displayName.toLowerCase() !==
user.email && (
<span className="ml-1 text-gray-400 truncate">
({user.email})
</span>
</span>
{selected && (
<span
className={`${
active
? 'text-white'
: 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</div>
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{isAnime && (
<div className="mt-4 italic">

@ -51,10 +51,8 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
is4k = false,
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const [
requestOverrides,
setRequestOverrides,
] = useState<RequestOverrides | null>(null);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails>(`/api/v1/movie/${tmdbId}`, {
revalidateOnMount: true,
@ -237,6 +235,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
secondaryButtonType="danger"
cancelText={intl.formatMessage(globalMessages.close)}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{isOwner
? intl.formatMessage(messages.pendingapproval)
@ -295,6 +294,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}
okButtonType={'primary'}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{hasAutoApprove && !quota?.movie.restricted && (
<div className="mt-6">

@ -74,10 +74,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
(season) => season.seasonNumber
);
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
const [
requestOverrides,
setRequestOverrides,
] = useState<RequestOverrides | null>(null);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
const [selectedSeasons, setSelectedSeasons] = useState<number[]>(
editRequest ? editingSeasons : []
);
@ -94,7 +92,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
);
const currentlyRemaining =
(quota?.tv.remaining ?? 0) - selectedSeasons.length;
(quota?.tv.remaining ?? 0) -
selectedSeasons.length +
(editRequest?.seasons ?? []).length;
const updateRequest = async () => {
if (!editRequest) {
@ -420,6 +420,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
: intl.formatMessage(globalMessages.cancel)
}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{editRequest
? isOwner

@ -94,7 +94,7 @@ const ResetPassword: React.FC = () => {
{({ errors, touched, isSubmitting, isValid }) => {
return (
<Form>
<div className="sm:border-t sm:border-gray-800">
<div>
<label
htmlFor="email"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"

@ -108,7 +108,7 @@ const ResetPassword: React.FC = () => {
{({ errors, touched, isSubmitting, isValid }) => {
return (
<Form>
<div className="sm:border-t sm:border-gray-800">
<div>
<label
htmlFor="password"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"

@ -23,6 +23,7 @@ const messages = defineMessages({
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
validationTypes: 'You must select at least one notification type',
});
const NotificationsDiscord: React.FC = () => {
@ -46,6 +47,13 @@ const NotificationsDiscord: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -88,7 +96,15 @@ const NotificationsDiscord: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -211,8 +227,20 @@ const NotificationsDiscord: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

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

Loading…
Cancel
Save