diff --git a/.all-contributorsrc b/.all-contributorsrc index 7902f4aa..bc7fea69 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -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": "\"All-orange.svg\"/>", diff --git a/.gitbook.yaml b/.gitbook.yaml index 6c5133ed..2b0a6c4e 100644 --- a/.gitbook.yaml +++ b/.gitbook.yaml @@ -1,5 +1,5 @@ root: ./docs ​structure: - readme: README.md - summary: SUMMARY.md​ + readme: README.md + summary: SUMMARY.md​ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 9d966899..6b2dc700 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -github: [sct] +github: [sct] patreon: overseerr diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 522628a6..f65cfa76 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e1212b5..06cccb78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 13e3e01d..809d4706 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -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 diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml index 641b1d6a..24f95d74 100644 --- a/.github/workflows/invalid_template.yml +++ b/.github/workflows/invalid_template.yml @@ -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' diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 9f05f86e..a4246428 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ff0fbb7..13464f61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index b0b5a399..c74e6b13 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -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: | diff --git a/.github/workflows/support.yml b/.github/workflows/support.yml index 9c4bf49e..09d86bc6 100644 --- a/.github/workflows/support.yml +++ b/.github/workflows/support.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c9b31dc..9e16f9ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 3bd9f66e..3dbf40fb 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ Overseerr CI

-Discord +Discord Docker pulls Translation status Language grade: JavaScript GitHub -All Contributors +All Contributors

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

🌍 +
Jabster28

💻 +
littlerooster

🌍 +
Dustin Hildebrandt

💻 +
Bruno Guerreiro

🌍 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 36afc905..2b309dcc 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy.md similarity index 89% rename from docs/extending-overseerr/reverse-proxy-examples.md rename to docs/extending-overseerr/reverse-proxy.md index 2a06d9f4..970adb16 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy.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: +## Traefik (v2) -```bash -sudo nginx -t +Add the following labels to the Overseerr service in your `docker-compose.yml` file: + +```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" ``` -Finally, reload `nginx` for the new configuration to take effect: - -```bash -sudo systemctl reload nginx -``` +For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md new file mode 100644 index 00000000..c7d57fd4 --- /dev/null +++ b/docs/extending-overseerr/third-party.md @@ -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 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 55f8a5f0..2f4a6354 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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 `` 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: diff --git a/docs/support/faq.md b/docs/support/faq.md index b74bf918..2add67a4 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -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 → 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 → 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 → 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 → Services**. ## Notifications diff --git a/docs/support/asking-for-support.md b/docs/support/need-help.md similarity index 62% rename from docs/support/asking-for-support.md rename to docs/support/need-help.md index ad696926..ff2a9eb4 100644 --- a/docs/support/asking-for-support.md +++ b/docs/support/need-help.md @@ -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 `/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). diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index 5836e089..c894b0b2 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -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 → 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 diff --git a/docs/using-overseerr/notifications/discord.md b/docs/using-overseerr/notifications/discord.md index f46b93ac..5112b5c0 100644 --- a/docs/using-overseerr/notifications/discord.md +++ b/docs/using-overseerr/notifications/discord.md @@ -1,26 +1,16 @@ # Discord +The Discord notification agent enables you to post notifications to a channel in a server you manage. + {% hint style="info" %} -The following notification types will mention _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users: - -- Media Requested -- Media Automatically Approved -- Media Failed - -On the other hand, the notification types below will only mention the user who submitted the request: - -- Media Approved (does not include automatic approvals) -- Media Declined -- Media Available - -In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured and **Enable Mentions** checked in their Discord notification user settings. +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 → Integrations → 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 → Integrations → Webhooks**. diff --git a/docs/using-overseerr/notifications/email.md b/docs/using-overseerr/notifications/email.md index 1cd5839a..75a51d94 100644 --- a/docs/using-overseerr/notifications/email.md +++ b/docs/using-overseerr/notifications/email.md @@ -1,22 +1,14 @@ # Email +## Configuration + {% 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 - -In order for users to receive email notifications, they must have **Enable Notifications** checked in their email notification user settings. +If the [Application URL](../settings/README.md#application-url) setting is configured in **Settings → 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. diff --git a/docs/using-overseerr/notifications/lunasea.md b/docs/using-overseerr/notifications/lunasea.md new file mode 100644 index 00000000..5271a2c1 --- /dev/null +++ b/docs/using-overseerr/notifications/lunasea.md @@ -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 %} diff --git a/docs/using-overseerr/notifications/slack.md b/docs/using-overseerr/notifications/slack.md index 5b9d0fd7..0d5a9892 100644 --- a/docs/using-overseerr/notifications/slack.md +++ b/docs/using-overseerr/notifications/slack.md @@ -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 %} diff --git a/docs/using-overseerr/notifications/telegram.md b/docs/using-overseerr/notifications/telegram.md index ddbd992e..d0e6f6fc 100644 --- a/docs/using-overseerr/notifications/telegram.md +++ b/docs/using-overseerr/notifications/telegram.md @@ -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. diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 3717c2e4..c1637480 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -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). diff --git a/docs/using-overseerr/notifications/webpush.md b/docs/using-overseerr/notifications/webpush.md new file mode 100644 index 00000000..65d914f0 --- /dev/null +++ b/docs/using-overseerr/notifications/webpush.md @@ -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 → Notifications → 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 %} diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md index bdf9cd34..82043073 100644 --- a/docs/using-overseerr/settings/README.md +++ b/docs/using-overseerr/settings/README.md @@ -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 -Tick this box to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported. +Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported. + +#### Web App URL (optional) + +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 → General → 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 → General → 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 → General → 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. diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md index bff049ba..275e469c 100644 --- a/docs/using-overseerr/users/README.md +++ b/docs/using-overseerr/users/README.md @@ -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 → 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 → 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. diff --git a/overseerr-api.yml b/overseerr-api.yml index 637af162..463d8ad1 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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 diff --git a/package.json b/package.json index 4828b603..b2395292 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/badge-128x128.png b/public/badge-128x128.png new file mode 100644 index 00000000..0ef0e6a0 Binary files /dev/null and b/public/badge-128x128.png differ diff --git a/public/sw.js b/public/sw.js index d6672e60..a3c816e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 3436e1bd..6e3e13ec 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 3d821651..9666ac28 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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 + }`; } } diff --git a/server/entity/User.ts b/server/entity/User.ts index 5e83dd06..59d347cb 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -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 { diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 96227bf0..02f39111 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -108,7 +108,10 @@ export class UserSettings { }) public notificationTypes: Partial; - public hasNotificationType(key: NotificationAgentKey, type: Notification) { + public hasNotificationType( + key: NotificationAgentKey, + type: Notification + ): boolean { return hasNotificationType(type, this.notificationTypes[key] ?? 0); } } diff --git a/server/index.ts b/server/index.ts index e76d0e3d..8471926e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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) { diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index a55b71b3..924023d4 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -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 { diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 8fb6ae87..18e3c7ab 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -20,6 +20,7 @@ export interface UserSettingsNotificationsResponse { emailEnabled?: boolean; pgpKey?: string; discordEnabled?: boolean; + discordEnabledTypes?: number; discordId?: string; telegramEnabled?: boolean; telegramBotUsername?: string; diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 132683e5..66c52a16 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -24,6 +24,6 @@ export abstract class BaseAgent { } export interface NotificationAgent { - shouldSend(type: Notification): boolean; + shouldSend(): boolean; send(type: Notification, payload: NotificationPayload): Promise; } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 1b79f7e3..97be2cba 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -91,7 +91,8 @@ interface DiscordWebhookPayload { class DiscordAgent extends BaseAgent - 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 { + 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); diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index d3f34186..6a06d718 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -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 - 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', { diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index 9fc332f6..5d5c5216 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -7,7 +7,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; class LunaSeaAgent extends BaseAgent - 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 { + 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; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index ab4b811e..160eed87 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -12,7 +12,8 @@ interface PushbulletPayload { class PushbulletAgent extends BaseAgent - 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 { + 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, }, } ); diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 858da0c6..b37b5446 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -18,7 +18,8 @@ interface PushoverPayload { class PushoverAgent extends BaseAgent - 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 { + 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, diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 7004fe4b..8065f9a6 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -43,7 +43,8 @@ interface SlackBlockEmbed { class SlackAgent extends BaseAgent - 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 { + 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) { diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 1a22ddce..b63fbd62 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -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 - 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,95 +159,53 @@ 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 { - 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 { + if (hasNotificationType(type, settings.types ?? 0)) { 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 - logger.debug('Sending Telegram notification', { - label: 'Notifications', - recipient: payload.notifyUser.displayName, - type: Notification[type], - subject: payload.subject, - }); - try { await axios.post( endpoint, - payload.image - ? ({ - photo: payload.image, - caption: this.buildMessage(type, payload), - parse_mode: 'MarkdownV2', - chat_id: payload.notifyUser.settings.telegramChatId, - disable_notification: - payload.notifyUser.settings.telegramSendSilently, - } as TelegramPhotoPayload) - : ({ - text: this.buildMessage(type, payload), - parse_mode: 'MarkdownV2', - chat_id: payload.notifyUser.settings.telegramChatId, - disable_notification: - payload.notifyUser.settings.telegramSendSilently, - } as TelegramMessagePayload) + 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; } } diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 7d8cbd86..2959f81c 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -40,7 +40,8 @@ const KeyMap: Record = { class WebhookAgent extends BaseAgent - 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 { + 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) { diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index fb337670..968c1435 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -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 - 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 { - 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; } } diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 70af56ba..a2eb0141 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -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); } }); diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index b7e36547..ac76d61c 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -145,9 +145,8 @@ class BaseScanner { existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== externalServiceId ) { - existing[ - is4k ? 'externalServiceId4k' : 'externalServiceId' - ] = externalServiceId; + existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] = + externalServiceId; changedExisting = true; } @@ -156,9 +155,8 @@ class BaseScanner { existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== externalServiceSlug ) { - existing[ - is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' - ] = externalServiceSlug; + existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + externalServiceSlug; changedExisting = true; } @@ -389,15 +387,13 @@ class BaseScanner { } 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 { 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 { season.status4k === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE - : media.seasons.some( + : !seasons.length || + media.seasons.some( (season) => season.status4k === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index dc136900..5a4bfc08 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -30,7 +30,8 @@ type SyncStatus = StatusBase & { class PlexScanner extends BaseScanner - implements RunnableScanner { + implements RunnableScanner +{ private plexClient: PlexAPI; private libraries: Library[]; private currentLibrary: Library; diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 4c4e6e7f..71d687dc 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -10,7 +10,8 @@ type SyncStatus = StatusBase & { class RadarrScanner extends BaseScanner - implements RunnableScanner { + implements RunnableScanner +{ private servers: RadarrSettings[]; private currentServer: RadarrSettings; private radarrApi: RadarrAPI; diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 73500db9..db3aef98 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -16,7 +16,8 @@ type SyncStatus = StatusBase & { class SonarrScanner extends BaseScanner - implements RunnableScanner { + implements RunnableScanner +{ private servers: SonarrSettings[]; private currentServer: SonarrSettings; private sonarrApi: SonarrAPI; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index a9e459b4..656be868 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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; } 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, }; } diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index f8f7b9ad..68869222 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -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(); }; diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts index ce3de849..e2aa8865 100644 --- a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserRequestDeleteCascades1608219049304'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts index 89bb4317..fba7af7f 100644 --- a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddLastSeasonChangeMedia1608477467935'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts index 9cd006ec..6a109e4d 100644 --- a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts +++ b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 - implements MigrationInterface { + implements MigrationInterface +{ name = 'ForceDropImdbUniqueConstraint1608477467936'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts index 0be26699..2cd5415e 100644 --- a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts +++ b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 - implements MigrationInterface { + implements MigrationInterface +{ name = 'RemoveTmdbIdUniqueConstraint1609236552057'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts index 78dbc06e..25e42a74 100644 --- a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddMediaAddedFieldToMedia1610522845513'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts index 49f47e40..355384a0 100644 --- a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 - implements MigrationInterface { + implements MigrationInterface +{ name = 'SonarrRadarrSyncServiceFields1611757511674'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts index 01278c01..7d191d10 100644 --- a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddResetPasswordGuidAndExpiryDate1612482778137'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts index 17c25ec2..d33df4ee 100644 --- a/server/migration/1613955393450-UpdateUserSettingsRegions.ts +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 - implements MigrationInterface { + implements MigrationInterface +{ name = 'UpdateUserSettingsRegions1613955393450'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts index 1e0175cc..5e480d48 100644 --- a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddTelegramSettingsToUserSettings1614334195680'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts index c8bd6dd4..d498a8b1 100644 --- a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 - implements MigrationInterface { + implements MigrationInterface +{ name = 'CreateTagsFieldonMediaRequest1617624225464'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts index 86a52c08..79cd061b 100644 --- a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts +++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserSettingsNotificationAgentsField1617730837489'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts index 90ea0d3f..539221d1 100644 --- a/server/migration/1618912653565-CreateUserPushSubscriptions.ts +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 - implements MigrationInterface { + implements MigrationInterface +{ name = 'CreateUserPushSubscriptions1618912653565'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts index 67d77072..cccdae2f 100644 --- a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserSettingsNotificationTypes1619339817343'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ca94e2a8..03fd0bad 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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(); diff --git a/server/routes/index.ts b/server/routes/index.ts index 72e396c5..e99ab3da 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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 }); diff --git a/server/routes/media.ts b/server/routes/media.ts index c77f7708..34819782 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -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 - | undefined = undefined; + let statusFilter: MediaStatus | FindOperator | undefined = + undefined; switch (req.query.filter) { case 'available': diff --git a/server/routes/request.ts b/server/routes/request.ts index 96f47694..0819bdfa 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -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, diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 60d5c33e..4bccc772 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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 (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 users avatar with their plex thumbnail (incase it changed) - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; + 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); - - 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 = 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 }); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index f37b8c86..226dcae0 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -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) { diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index b434f6c0..32c0193e 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -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}`, diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 0d73f8ad..0f03c059 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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' diff --git a/src/assets/extlogos/discord.svg b/src/assets/extlogos/discord.svg index 736d9ddd..64aef202 100644 --- a/src/assets/extlogos/discord.svg +++ b/src/assets/extlogos/discord.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 180bd2ed..56b368d9 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -60,9 +60,8 @@ const CollectionDetails: React.FC = ({ } ); - 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 ; diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 126c81a6..893b8114 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -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 = ({ @@ -53,6 +55,7 @@ const Modal: React.FC = ({ tertiaryDisabled = false, tertiaryText, onTertiary, + backdrop, }) => { const intl = useIntl(); const modalRef = useRef(null); @@ -66,7 +69,7 @@ const Modal: React.FC = ({ return ReactDOM.createPortal( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ if (e.key === 'Escape') { typeof onCancel === 'function' && backgroundClickable @@ -98,7 +101,7 @@ const Modal: React.FC = ({ show={!loading} >
= ({ maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)', }} > -
+ {backdrop && ( +
+ +
+
+ )} +
{iconSvg &&
{iconSvg}
}
= ({
{children && ( -
+
{children}
)} {(onCancel || onOk || onSecondary || onTertiary) && ( -
+
{typeof onOk === 'function' && (
) : ( -
-
- -
+
+
)} diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index 4dac751a..f2aa09b2 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -44,7 +44,7 @@ const SlideOver: React.FC = ({ > {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
onClose()} onKeyDown={(e) => { if (e.key === 'Escape') { diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 0d7b1da7..3ebd6226 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -35,13 +35,11 @@ const Discover: React.FC = () => { { revalidateOnMount: true } ); - const { - data: requests, - error: requestError, - } = useSWR( - '/api/v1/request?filter=all&take=10&sort=modified&skip=0', - { revalidateOnMount: true } - ); + const { data: requests, error: requestError } = + useSWR( + '/api/v1/request?filter=all&take=10&sort=modified&skip=0', + { revalidateOnMount: true } + ); return ( <> diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index cd589dde..83629b0a 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -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) => ( diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 66286835..3825970c 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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 }) => { }} >
-
+
diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 71b815fd..ece109fe 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -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 = ({ revalidate }) => { const intl = useIntl(); + const settings = useSettings(); const [loginError, setLoginError] = useState(null); const LoginSchema = Yup.object().shape({ @@ -36,6 +38,10 @@ const LocalLogin: React.FC = ({ revalidate }) => { ), }); + const passwordResetEnabled = + settings.currentSettings.applicationUrl && + settings.currentSettings.emailEnabled; + return ( = ({ revalidate }) => { return ( <>
-
+
@@ -101,17 +107,7 @@ const LocalLogin: React.FC = ({ revalidate }) => { )}
-
- - - - - +
+ {passwordResetEnabled && ( + + + + + + )}
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index eb7de776..242c3fdf 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -98,9 +98,10 @@ const MovieDetails: React.FC = ({ 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 ; diff --git a/src/components/NotificationTypeSelector/NotificationType/index.tsx b/src/components/NotificationTypeSelector/NotificationType/index.tsx index 4085b2a6..360c89c7 100644 --- a/src/components/NotificationTypeSelector/NotificationType/index.tsx +++ b/src/components/NotificationTypeSelector/NotificationType/index.tsx @@ -38,7 +38,7 @@ const NotificationType: React.FC = ({ : currentTypes + option.value ); }} - defaultChecked={ + checked={ hasNotificationType(option.value, currentTypes) || (!!parent?.value && hasNotificationType(parent.value, currentTypes)) @@ -46,10 +46,12 @@ const NotificationType: React.FC = ({ />
-
{(option.children ?? []).map((child) => ( diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index 27350007..0b71f670 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -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 = ({ + 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 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 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 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 (
{intl.formatMessage(messages.notificationTypes)} - * + {!user && *}
- {types.map((type) => ( + {availableTypes.map((type) => ( = ({ /> ))}
+ {error &&
{error}
}
diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 3ea148c0..82391445 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -32,12 +32,10 @@ const PersonDetails: React.FC = () => { ); const [showBio, setShowBio] = useState(false); - const { - data: combinedCredits, - error: errorCombinedCredits, - } = useSWR( - `/api/v1/person/${router.query.personId}/combined_credits` - ); + const { data: combinedCredits, error: errorCombinedCredits } = + useSWR( + `/api/v1/person/${router.query.personId}/combined_credits` + ); const sortedCast = useMemo(() => { const grouped = groupBy(combinedCredits?.cast ?? [], 'id'); diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 3ccb2021..831c8fad 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -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} {movies} per {quotaDays} {days}', + tvRequests: + '{quotaLimit} {seasons} per {quotaDays} {days}', + 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 = ({ return (
{intl.formatMessage( - mediaType === 'movie' - ? messages.movieRequestLimit - : messages.tvRequestLimit, + mediaType === 'movie' ? messages.movieRequests : messages.tvRequests, { quotaLimit: ( ), + 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 ( + + {msg} + + ); + }, } )}
diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index afa6216e..6bfbed4a 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ request, onTitleData }) => { } return ( -
- {title.backdropPath && ( -
- -
+ <> + setShowEditModal(false)} + onComplete={() => { + revalidate(); + setShowEditModal(false); + }} + /> +
+ {title.backdropPath && ( +
+ +
+
+ )} +
+
+ {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( + 0, + 4 + )} +
+ + + {isMovie(title) ? title.title : title.name} + + + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) && ( + + )} + {!isMovie(title) && request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons, { + seasonCount: + title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length + ? 0 + : request.seasons.length, + })} + + {title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length ? ( + + {intl.formatMessage(globalMessages.all)} + + ) : ( +
+ {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
+ )} +
+ )} +
+ + {intl.formatMessage(globalMessages.status)} + + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN || + requestData.status === MediaRequestStatus.DECLINED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={requestData.media.plexUrl} + plexUrl4k={requestData.media.plexUrl4k} + /> + )} +
+
+ {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN && + requestData.status !== MediaRequestStatus.DECLINED && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} + {requestData.status === MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + <> + + + + )} + {requestData.status === MediaRequestStatus.PENDING && + !hasPermission(Permission.MANAGE_REQUESTS) && + requestData.requestedBy.id === user?.id && + (requestData.type === 'tv' || + hasPermission(Permission.REQUEST_ADVANCED)) && ( + + )} + {requestData.status === MediaRequestStatus.PENDING && + !hasPermission(Permission.MANAGE_REQUESTS) && + requestData.requestedBy.id === user?.id && ( + + )} +
- )} -
= ({ request, onTitleData }) => { : `/tv/${requestData.media.tmdbId}` } > - - {isMovie(title) ? title.title : title.name} + + - - {!isMovie(title) && request.seasons.length > 0 && ( -
- - {intl.formatMessage(messages.seasons, { - seasonCount: - title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length - ? 0 - : request.seasons.length, - })} - - {title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length ? ( - - {intl.formatMessage(globalMessages.all)} - - ) : ( -
- {request.seasons.map((season) => ( - - {season.seasonNumber} - - ))} -
- )} -
- )} -
- - {intl.formatMessage(globalMessages.status)} - - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN || - requestData.status === MediaRequestStatus.DECLINED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - - ) : ( - 0 - } - is4k={requestData.is4k} - plexUrl={requestData.media.plexUrl} - plexUrl4k={requestData.media.plexUrl4k} - /> - )} -
- {requestData.status === MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( -
- - -
- )}
- - - - - -
+ ); }; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 6642f54d..3c4fbd30 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -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 = ({ const { data: title, error } = useSWR( inView ? `${url}` : null ); - const { data: requestData, revalidate, mutate } = useSWR( - `/api/v1/request/${request.id}`, - { - initialData: request, - } - ); + const { + data: requestData, + revalidate, + mutate, + } = useSWR(`/api/v1/request/${request.id}`, { + initialData: request, + }); const [isRetrying, setRetrying] = useState(false); @@ -219,33 +221,23 @@ const RequestItem: React.FC = ({
- -
- - - - - {requestData.requestedBy.displayName} - - - +
+ {(isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)}
+ + + {isMovie(title) ? title.title : title.name} + + {!isMovie(title) && request.seasons.length > 0 && (
@@ -276,7 +268,7 @@ const RequestItem: React.FC = ({ )}
-
+
{intl.formatMessage(globalMessages.status)} @@ -308,29 +300,20 @@ const RequestItem: React.FC = ({ )}
- - {intl.formatMessage(messages.requested)} - - - {intl.formatDate(requestData.createdAt, { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - -
-
- - {intl.formatMessage(messages.modified)} - - - {requestData.modifiedBy ? ( - + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) ? ( + <> + + {intl.formatMessage(messages.requested)} + + {intl.formatMessage(messages.modifieduserdate, { date: ( = ({ /> ), user: ( - - + + - {requestData.modifiedBy.displayName} + {requestData.requestedBy.displayName} ), })} - ) : ( - N/A - )} - + + ) : ( + <> + + {intl.formatMessage(messages.requesteddate)} + + + + + + )}
+ {requestData.modifiedBy && ( +
+ + {intl.formatMessage(messages.modified)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.modifiedBy.displayName} + + + + ), + })} + +
+ )}
diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 8ed0adf4..2db67a6d 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -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 = ({ defaultOverrides?.tags ?? [] ); - const { - data: serverData, - isValidating, - } = useSWR( - selectedServer !== null - ? `/api/v1/service/${ - type === 'movie' ? 'radarr' : 'sonarr' - }/${selectedServer}` - : null, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - } - ); + const { data: serverData, isValidating } = + useSWR( + selectedServer !== null + ? `/api/v1/service/${ + type === 'movie' ? 'radarr' : 'sonarr' + }/${selectedServer}` + : null, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + } + ); const [selectedUser, setSelectedUser] = useState( requestUser ?? null @@ -276,9 +274,9 @@ const AdvancedRequester: React.FC = ({
{!!data && selectedServer !== null && ( - <> -
-
+
+ {data.filter((server) => server.is4k === is4k).length > 1 && ( +
@@ -288,7 +286,7 @@ const AdvancedRequester: React.FC = ({ 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 = ({ ))}
-
+ )} + {(isValidating || + !serverData || + serverData.profiles.length > 1) && ( +
@@ -316,7 +318,7 @@ const AdvancedRequester: React.FC = ({ 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 = ({ ))}
-
+ )} + {(isValidating || + !serverData || + serverData.rootFolders.length > 1) && ( +
@@ -360,7 +362,7 @@ const AdvancedRequester: React.FC = ({ 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 = ({ ))}
- {type === 'tv' && ( -
+ )} + {type === 'tv' && + (isValidating || + !serverData || + (serverData.languageProfiles ?? []).length > 1) && ( +
@@ -414,7 +420,7 @@ const AdvancedRequester: React.FC = ({ 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 = ({
)} -
- - )} - {!!data && selectedServer !== null && ( -
- - ({ + label: tag.label, + value: tag.id, + }))} + isMulti + isDisabled={isValidating || !serverData} + placeholder={ + isValidating || !serverData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.selecttags) + } + className="react-select-container react-select-container-dark" + classNamePrefix="react-select" + value={selectedTags.map((tagId) => { + const foundTag = serverData?.tags.find( + (tag) => tag.id === tagId + ); + return { + value: foundTag?.id, + label: foundTag?.label, + }; + })} + onChange={( + value: OptionTypeBase | OptionsType | null + ) => { + if (!Array.isArray(value)) { + return; + } + setSelectedTags(value?.map((option) => option.value)); + }} + noOptionsMessage={() => + intl.formatMessage(messages.notagoptions) + } + /> +
+ )} {hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) && selectedUser && ( -
- setSelectedUser(value)} - className="space-y-1" - > - {({ open }) => ( - <> - - {intl.formatMessage(messages.requestas)} - -
- - - - - - {selectedUser.displayName} - + setSelectedUser(value)} + className="space-y-1" + > + {({ open }) => ( + <> + + {intl.formatMessage(messages.requestas)} + +
+ + + + + + {selectedUser.displayName} + + {selectedUser.displayName.toLowerCase() !== + selectedUser.email && ( ({selectedUser.email}) - - - - - - + )} + + + + + + - + - - {userData?.results.map((user) => ( - - {({ selected, active }) => ( -
( + + {({ selected, active }) => ( +
+ - - - - {user.displayName} - + + + {user.displayName} + + {user.displayName.toLowerCase() !== + user.email && ( ({user.email}) - - {selected && ( - - - )} -
- )} -
- ))} - - -
- - )} - -
+
+ {selected && ( + + + + )} +
+ )} + + ))} + + +
+ + )} + )} {isAnime && (
diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 11d4e9e0..ccfa4f1f 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -51,10 +51,8 @@ const MovieRequestModal: React.FC = ({ is4k = false, }) => { const [isUpdating, setIsUpdating] = useState(false); - const [ - requestOverrides, - setRequestOverrides, - ] = useState(null); + const [requestOverrides, setRequestOverrides] = + useState(null); const { addToast } = useToasts(); const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`, { revalidateOnMount: true, @@ -237,6 +235,7 @@ const MovieRequestModal: React.FC = ({ secondaryButtonType="danger" cancelText={intl.formatMessage(globalMessages.close)} iconSvg={} + 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 = ({ } okButtonType={'primary'} iconSvg={} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} > {hasAutoApprove && !quota?.movie.restricted && (
diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index e0687392..1d6a9e64 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -74,10 +74,8 @@ const TvRequestModal: React.FC = ({ (season) => season.seasonNumber ); const { data, error } = useSWR(`/api/v1/tv/${tmdbId}`); - const [ - requestOverrides, - setRequestOverrides, - ] = useState(null); + const [requestOverrides, setRequestOverrides] = + useState(null); const [selectedSeasons, setSelectedSeasons] = useState( editRequest ? editingSeasons : [] ); @@ -94,7 +92,9 @@ const TvRequestModal: React.FC = ({ ); 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 = ({ : intl.formatMessage(globalMessages.cancel) } iconSvg={} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} > {editRequest ? isOwner diff --git a/src/components/ResetPassword/RequestResetLink.tsx b/src/components/ResetPassword/RequestResetLink.tsx index 0cf20abb..49fc5afe 100644 --- a/src/components/ResetPassword/RequestResetLink.tsx +++ b/src/components/ResetPassword/RequestResetLink.tsx @@ -94,7 +94,7 @@ const ResetPassword: React.FC = () => { {({ errors, touched, isSubmitting, isValid }) => { return (
-
+