diff --git a/.all-contributorsrc b/.all-contributorsrc index 356696f3..3671ef22 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -225,7 +225,8 @@ "profile": "https://github.com/ankarhem", "contributions": [ "doc", - "code" + "code", + "translation" ] }, { @@ -262,7 +263,8 @@ "profile": "https://github.com/TheCatLady", "contributions": [ "code", - "translation" + "translation", + "doc" ] }, { @@ -301,6 +303,15 @@ "contributions": [ "doc" ] + }, + { + "login": "dancarter", + "name": "Daniel Carter", + "avatar_url": "https://avatars.githubusercontent.com/u/4387516?v=4", + "profile": "https://github.com/dancarter", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..7babdcc7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Global code ownership +* @sct + +# Documentation +docs/ @TheCatLady @samwiseg0 + +# Snap-related files +.github/workflows/snap.yaml @samwiseg0 +snap/ @samwiseg0 + +# i18n locale files +src/i18n/locale/ @sct @TheCatLady diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 972ad2f3..4b1d3790 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,45 +1,45 @@ --- name: Bug report -about: Create a report to help us improve +about: Submit a report to help us improve title: '' labels: 'awaiting-triage, type:bug' assignees: '' --- -**Describe the bug** -A clear and concise description of what the bug is. +#### Description -**What version of Overseerr are you running?** -Please fill in the version you are currently running. +Please provide a clear and concise description of the bug or issue. -You can find it under: Settings -> About -> Version +#### Version -**To Reproduce** -Steps to reproduce the behavior: +What version of Overseerr are you running? (You can find this in Settings → About → Version.) -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +#### Steps to Reproduce -**Expected behavior** -A clear and concise description of what you expected to happen. +Please tell us how we can reproduce the undesired behavior. -**Screenshots** -If applicable, add screenshots to help explain your problem. +1. Go to [...] +2. Click on [...] +3. Scroll down to [...] +4. See error in [...] -**Desktop (please complete the following information):** +#### Expected Behavior -- OS: [e.g. iOS] -- Browser [e.g. chrome, safari] -- Version [e.g. 22] +Please provide a clear and concise description of what you expected to happen. -**Smartphone (please complete the following information):** +#### Screenshots -- Device: [e.g. iPhone6] -- OS: [e.g. iOS8.1] -- Browser [e.g. stock browser, safari] -- Version [e.g. 22] +If applicable, please provide screenshots depicting the problem. -**Additional context** -Add any other context about the problem here. +#### Device + +What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug. + +- **Platform:** [e.g., desktop, smartphone, tablet] +- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab] +- **OS:** [e.g., iOS 8.1, Windows 10, Android 11] +- **Browser:** [e.g., Chrome, Safari, Edge, Firefox] + +#### Additional Context + +Please provide any additional information that may be relevant or helpful. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b9ea9edd..29b26fbd 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -6,14 +6,14 @@ labels: 'awaiting-triage, type:enhancement' assignees: '' --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +#### Description -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]." -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +#### Desired Behavior -**Additional context** -Add any other context or screenshots about the feature request here. +Provide a clear and concise description of what you want to happen. + +#### Additional Context + +Provide any additional information or screenshots that may be relevant or helpful. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b65966e7..d8187ecf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,13 +1,13 @@ #### Description -#### Screenshot (if UI related) +#### Screenshot (if UI-related) -#### Todos +#### To-Dos -- [ ] Sucessfully builds `yarn build` -- [ ] Translation Keys `yarn i18n:extract` -- [ ] Database migration created (if required) +- [ ] Successful build `yarn build` +- [ ] Translation keys `yarn i18n:extract` +- [ ] Database migration (if required) -#### Issues Fixed or Closed by this PR +#### Issues Fixed or Closed - Fixes #XXXX diff --git a/.github/stale.yml b/.github/stale.yml index 6a6201e3..eeed081e 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -8,7 +8,7 @@ exemptLabels: - security - dependencies # Label to use when marking an issue as stale -staleLabel: wontfix +staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142bb5c6..ffc74754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,13 @@ jobs: uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 + - name: Cache Docker layers + uses: actions/cache@v2.1.4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- - name: Login to DockerHub uses: docker/login-action@v1 with: @@ -60,6 +67,8 @@ jobs: sctx/overseerr:${{ github.sha }} ghcr.io/sct/overseerr:develop ghcr.io/sct/overseerr:${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache,mode=max discord: name: Send Discord Notification needs: build_and_push @@ -67,7 +76,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.2 - name: Combine Job Status id: status diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml index f8be80fe..641b1d6a 100644 --- a/.github/workflows/invalid_template.yml +++ b/.github/workflows/invalid_template.yml @@ -13,7 +13,7 @@ jobs: github-token: ${{ github.token }} support-label: 'invalid:template-incomplete' issue-comment: > - :wave: @{issue-author}, please edit your issue and follow the template provided. - close-issue: false - lock-issue: false + :wave: @{issue-author}, please follow the template provided. + close-issue: true + lock-issue: true issue-lock-reason: 'resolved' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6feacbb..9e929ce9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,8 @@ jobs: - name: Set Up QEMU uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde - name: Build Snap Package uses: diddlesnaps/snapcraft-multiarch-action@v1 @@ -89,7 +91,7 @@ jobs: path: ${{ steps.build.outputs.snap }} - name: Review Snap Package - uses: diddlesnaps/snapcraft-review-tools-action@v1 + uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0 with: snap: ${{ steps.build.outputs.snap }} @@ -106,7 +108,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.2 - name: Combine Job Status id: status diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index b7a2278a..ac68a327 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -5,10 +5,19 @@ on: branches: [develop] jobs: - test: - name: Lint & Test Build + jobs: + name: Job Check runs-on: ubuntu-20.04 if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.8.0 + with: + access_token: ${{ secrets.GITHUB_TOKEN }} + test: + name: Lint & Test Build + needs: jobs + runs-on: ubuntu-20.04 container: node:12.18-alpine steps: - name: checkout @@ -48,6 +57,8 @@ jobs: - name: Set Up QEMU uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde - name: Build Snap Package uses: diddlesnaps/snapcraft-multiarch-action@v1 @@ -62,7 +73,7 @@ jobs: path: ${{ steps.build.outputs.snap }} - name: Review Snap Package - uses: diddlesnaps/snapcraft-review-tools-action@v1 + uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0 with: snap: ${{ steps.build.outputs.snap }} @@ -79,7 +90,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.2 - name: Combine Job Status id: status diff --git a/.gitignore b/.gitignore index b13c9472..8e186622 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ config/settings.json # logs config/logs/*.log* config/logs/*.json +config/logs/*.log.gz +config/logs/*-audit.json # anidb mapping file config/anime-list.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0da05476..9a97a796 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Overseerr -All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started: +All help is welcome and greatly appreciated. If you would like to contribute to the project, the instructions below can get you started... ## Development @@ -14,29 +14,31 @@ All help is welcome and greatly appreciated. If you would like to contribute to ### Getting Started 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. - ``` + + ```bash git clone https://github.com/YOUR_USERNAME/overseerr.git cd overseerr/ ``` + 2. Add the remote upstream. - ``` + ```bash git remote add upstream https://github.com/sct/overseerr.git ``` 3. Create a new branch - ``` + ```bash git checkout -b BRANCH_NAME develop ``` - - Its recommended to name the branch something relevant to the feature or fix you are working on. + - It is recommended to name the branch something relevant to the feature or fix you are working on. - An example of this would be `fix-title-cards` or `feature-new-system`. - Bad examples would be `patch` or `bug`. 4. Run development environment - ``` + ```bash yarn yarn dev ``` @@ -47,9 +49,9 @@ All help is welcome and greatly appreciated. If you would like to contribute to 6. Follow the [guidelines](#contributing-code). -7. Should you need to update your fork you can do so by rebasing from upstream. +7. Should you need to update your fork, you can do so by rebasing from `upstream`: - ``` + ```bash git fetch upstream git rebase upstream/develop git push origin BRANCH_NAME -f @@ -61,18 +63,36 @@ All help is welcome and greatly appreciated. If you would like to contribute to - All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - It is okay if you squash your PR down to be a single commit that fits this standard. - PRs with commits not following this standard will not be merged. -- Please make meaningful commits, or squash them -- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch. -- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch. -- You can create a Draft pull request early to get feedback on your work. +- Please make meaningful commits, or squash them. +- Always rebase your commit to the latest `develop` branch. Do not merge `develop` into your branch. +- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest `develop` branch. +- 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 codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save. + - We use Prettier to format our codebase. 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 in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7). -- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed. +- Only open pull requests to `develop`. Never `master`. Any PRs opened to `master` will be closed. + +### UI Text Style + +When adding new UI text, please be sure to adhere to the following guidelines: + +1. Be concise and clear, and use as few words as possible to make your point. +2. Use the Oxford comma where appropriate. +3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. +4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'. +5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized). +6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation. +7. Ensure that toast notification strings are complete sentences ending in punctuation. +8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`. +9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name. +10. Do your best to check for spelling errors and grammatical mistakes. +11. Do not misspell "Overseerr." ## Translation -We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr! +We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose). + +Translation status ## Attribution diff --git a/README.md b/README.md index f3b3c27b..70bd55d5 100644 --- a/README.md +++ b/README.md @@ -6,43 +6,34 @@ Overseerr CI

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

-**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)** and **[Plex](https://www.plex.tv/)**! +**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Plex](https://www.plex.tv/)**! ## Current Features -- Full Plex integration. Login and manage user access with Plex! -- Easy integration with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come! -- Plex libraries sync to know what titles you already have. -- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI. +- Full Plex integration. Authenticate and manage user access with Plex! +- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come! +- Plex library sync, to keep track of the titles which are already available. +- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface. - Incredibly simple request management UI. Don't dig through the app to simply approve recent requests! - Granular permission system. +- Support for various notification agents. - Mobile-friendly design, for when you need to approve requests on the go! -## In Development - -- User profiles. -- User settings page (to give users the ability to modify their Overseerr experience to their liking). -- Local user system (for those who don't use Plex). - ## Planned Features -- More notification types. +- Additional notification types. - Issues system. This will allow users to report issues with content on your media server. -- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested. +- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested. ## Getting Started @@ -52,7 +43,7 @@ https://docs.overseerr.dev/getting-started/installation ## Running Overseerr -Currently, Overseerr is primarily distributed as Docker images. If you have Docker, you can run Overseerr with: +Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with: ``` docker run -d \ @@ -64,7 +55,9 @@ docker run -d \ sctx/overseerr ``` -After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps. +After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps + +For more information or alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation). ⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️ @@ -83,19 +76,19 @@ After running Overseerr for the first time, configure it by visiting the web UI Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev -Also, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs +You can also access the API documentation from your local Overseerr install at http://localhost:5055/api-docs ## Community You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions). -If you would like to chat with community members you can join the [Overseerr Discord](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/PkCWJSeCk7)! Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels. ## Contributing -You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started. +You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started. ## Contributors ✨ @@ -135,17 +128,18 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
doob187

🚇
johnpyp

💻 -
Jakob Ankarhem

📖 💻 +
Jakob Ankarhem

📖 💻 🌍
Jayesh

💻
flying-sausages

📖
hirenshah

📖 -
TheCatLady

💻 🌍 +
TheCatLady

💻 🌍 📖
Chris Pritchard

💻 📖
Tamberlox

🌍
David

💻
Douglas Parker

📖 +
Daniel Carter

💻 diff --git a/docs/README.md b/docs/README.md index ea46f76a..a5564530 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,4 +19,6 @@ The primary motivation for starting this project was to have an incredibly perfo ## We need your help! -Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub. +Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation. + +If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md). diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4a25b678..0dc3de7c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,4 +1,4 @@ -# Table of contents +# Table of Contents - [Introduction](README.md) @@ -13,10 +13,10 @@ ## Support -- [Frequently Asked Questions](support/faq.md) +- [Frequently Asked Questions (FAQ)](support/faq.md) - [Asking for Support](support/asking-for-support.md) ## Extending Overseerr -* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) -* [Fail2ban Filter](extending-overseerr/fail2ban.md) +- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) +- [Fail2ban Filter](extending-overseerr/fail2ban.md) diff --git a/docs/extending-overseerr/fail2ban.md b/docs/extending-overseerr/fail2ban.md index 14ffd974..4f6b7e16 100644 --- a/docs/extending-overseerr/fail2ban.md +++ b/docs/extending-overseerr/fail2ban.md @@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you failregex = .*\[info\]\[Auth\]\: Failed login attempt.*"ip":"" ``` -You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. \ No newline at end of file +You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md index 609d017c..8fa120ee 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy-examples.md @@ -4,15 +4,11 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. {% endhint %} -## LE/SWAG +## [SWAG (Secure Web Application Gateway, formerly known as `letsencrypt`)](https://github.com/linuxserver/docker-swag) -### Subdomain +A sample proxy configuration is included in SWAG. However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls). -A sample is bundled in SWAG. This page is still the only source of truth, so the sample is not guaranteed to be up to date. If you catch an inconsistency, report it to the linuxserver team, or do a pull-request against the proxy-confs repository to update the sample. - -Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below. - -Example Configuration: +To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration: ```nginx server { @@ -41,11 +37,7 @@ server { ## Traefik \(v2\) -Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). - -### Subdomain - -Example Configuration: +Add the following labels to the Overseerr service in your `docker-compose.yml` file: ```text labels: @@ -59,31 +51,11 @@ labels: - "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055" ``` -## LE/NGINX +For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). -### Subdomain +## `nginx` -Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`. - -Create a symlink to `/etc/nginx/sites-enabled`: - -```text -sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf -``` - -Test the configuration: - -```text -sudo nginx -t -``` - -Reload your configuration for NGINX: - -```text -sudo systemctl reload nginx -``` - -Example Configuration: +Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`: ```text server { @@ -133,3 +105,21 @@ server { } } ``` + +Then, create a symlink to `/etc/nginx/sites-enabled`: + +```bash +sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf +``` + +Next, test the configuration: + +```bash +sudo nginx -t +``` + +Finally, reload `nginx` for the new configuration to take effect: + +```bash +sudo systemctl reload nginx +``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index a7757744..b0efe68d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -163,6 +163,11 @@ This version can break any moment. Be prepared to troubleshoot any issues that a {% endtab %} {% tab title="Swizzin" %} + +{% hint style="danger" %} +This implementation is not yet merged to master due to missing functionality. You can beta test the limited implementation or follow the status on [the pull request](https://github.com/swizzin/swizzin/pull/567). +{% endhint %} + The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used. Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information. diff --git a/docs/support/asking-for-support.md b/docs/support/asking-for-support.md index eecfc2c6..2743827e 100644 --- a/docs/support/asking-for-support.md +++ b/docs/support/asking-for-support.md @@ -1,34 +1,33 @@ # Asking for Support -## Before asking for support, make sure you try these things first +## Before Asking for Support -* Make sure you have **updated** to the latest version. -* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8) -* **Analyzing** your logs, you just might find the solution yourself! -* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md). -* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details. +Before seeking help, please make sure you have tried these following first: + +- **Update** to the latest version. +- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8) +- **Analyze** your logs, you just might find the solution yourself! +- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md). +- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below. ## What should I include when asking for support? -When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following: +When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions: -* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here: - * What command did you enter? - * What did you click on? - * What settings did you change? - * Provide a step-by-step list of what you tried. -* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on: - * Did something happen? - * Did something not happen? - * Are there any error messages showing? - * Screenshots can help us see what you are seeing - * The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\). +- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request: + - What command did you enter? + - What did you click on? + - What settings did you change? + - Provide a step-by-step list of what you tried. +- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on: + - Did something happen? + - Did something not happen? + - Are there any error messages showing? + - 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\). ## How can I share my logs? -First you will need to gather your logs from the install directory. - -1. Collect the log file from `/logs/overseerr.log` -2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents. -3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page. - +1. Locate the log file at `/logs/overseerr.log` +2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist. +3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7). diff --git a/docs/support/faq.md b/docs/support/faq.md index e30cd38b..0fd6938f 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -1,4 +1,4 @@ -# Frequently Asked Questions +# Frequently Asked Questions (FAQ) {% hint style="info" %} If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker. @@ -6,9 +6,9 @@ If you can't find a solution here, please ask on [Discord](https://discord.gg/Pk ## General -### I receive 409 or 400 errors when requesting a movie or tv show! +### I receive 409 or 400 errors when requesting a movie or TV series! -**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible. +**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions. ### How do I keep Overseerr up-to-date? @@ -24,15 +24,15 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne ### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations? -**A:** You sure can! We are using Weblate for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose). +**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose). ### Where can I find the changelog? -**A:** You can find the changelog in the **Settings -> About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases). +**A:** You can find the changelog in the **Settings → About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases). ### Can I make 4K requests? -**A:** 4K requests are not supported just yet but they will be supported in the future! +**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings → Services**, tick the `4K Server` checkbox. You also need to tick the `Default Server` checkbox if it is the default server you would like to use for 4K content requests. (To enable 4K requests, there need to be default Sonarr/Radarr servers for both 4K content **and** non-4K content.) ### Some media is missing from Overseerr that I know is in Plex! @@ -40,7 +40,7 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne **Troubleshooting Steps:** -Check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item. +First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item. 1. Verify that you are using one of the agents mentioned above. 2. Refresh the metadata for just that item. @@ -48,48 +48,50 @@ Check the Overseerr logs for media items that are missing. The logs will contain 4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library. 5. Run a full scan on Overseerr after refreshing all unmatched items. -Perform these steps to verify the media item has a guid Overseerr can match. +You can also perform the following to verify the media item has a GUID Overseerr can match: 1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**. -2. Verify that the media item has the same format of one of the examples below. +2. Verify that the media item's GUID follows one of the below formats: -**Examples:** + 1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` + 2. New Plex Movie agent `` + 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` + 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` -1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` -2. New Plex Movie agent `` -3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` -4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` +### TV series requests are failing after I updated Overseerr! + +**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**. ### Where can I find the logs? **A:** The logs are located at `/logs/overseerr.log` -## User Management +## User management ### Why can't I see all my Plex users? -**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings -> General Settings** page beforehand. +**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings → General Settings** page beforehand. ### Can I create local users in Overseerr? -**A:** Not at this time. But it is a planned feature! +**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address. ### Is is possible to set user roles in Overseerr? -**A:** Unfortunately, this is not possible yet. It is planned! +**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub. ## Requests -### I approved a requested movie and radarr didn't search for it! +### I approved a requested movie and Radarr didn't search for it! -**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr. +**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request. -### Help! My request still shows "requested" even though it's in Plex!?! +### Help! My request still shows "requested" even though it is in Plex! **A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps. ## Notifications -### I am getting "Username and Password not accepted" when sending email notifications to gmail! +### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail! -**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833). +**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833). diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index a4afbc00..3f13c0a9 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -4,25 +4,26 @@ Overseerr already supports a good number of notification agents, such as **Disco ## Currently Supported Notification Agents -- Email - Discord +- Email +- Pushbullet +- Pushover - Slack - Telegram -- Pushover - [Webhooks](./webhooks.md) ## Setting Up Notifications Configuring your notifications is _very 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. -You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive any notifications! +You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications! -Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page. +Some agents may have specific configuration "gotchas" covered in their documentation pages. {% hint style="danger" %} -Currently, you will **not receive notifications** for any auto-approved requests. However, you will still receive a notification when the media becomes available. +You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled. {% endhint %} -## Requesting new agents +## Requesting New Notification Agents -If we do not currently support a notification agent you would like, feel free to request it on our [GitHub Issues](https://github.com/sct/overseerr/issues). Make sure to search first to see if someone else already requested it! +If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent! diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 1a30511a..7adbc24a 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -6,7 +6,7 @@ Webhooks let you post a custom JSON payload to any endpoint you like. You can al The following configuration options are available: -### Webhook URL (Required) +### Webhook URL (required) The URL you would like to post notifications to. Your JSON will be sent as the body of the request. @@ -14,28 +14,27 @@ The URL you would like to post notifications to. Your JSON will be sent as the b Custom authorization header. Anything entered for this will be sent as an `Authorization` header. -### Custom JSON Payload (Required) +### JSON Payload (required) -Design your JSON payload as you see fit. JSON is validated before you can save or test. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload which will be replaced with actual values when the notifications are sent. - -You can always reset back to the default custom payload setting by clicking the `Reset to Default JSON Payload` button under the editor. +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. ## Template Variables -### Main +### General - `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`) - `{{subject}}` The notification subject message. (For request notifications, this is the media title) - `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis) - `{{image}}` Associated image with the request. (For request notifications, this is the media's poster) -### Notify User +### User These variables are usually the target user of the notification. - `{{notifyuser_username}}` Target user's username. - `{{notifyuser_email}}` Target user's email. - `{{notifyuser_avatar}}` Target user's avatar. +- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set). ### Media @@ -45,12 +44,13 @@ These variables are only included in media related notifications, such as reques - `{{media_tmdbid}}` Media's TMDb ID. - `{{media_imdbid}}` Media's IMDb ID. - `{{media_tvdbid}}` Media's TVDB ID. -- `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`) -- `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`) +- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`). +- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`). -### Special Key Variables +### Special -These variables must be used as a key in the JSON Payload. (Ex, `"{{extra}}": []`). +The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`). -- `{{extra}}` This will override the value of the property to be the pre-formatted "extra" array that can come along with certain notifications. Using this variable is _not required_. -- `{{media}}` This will override the value of the property to `null` if there is no media object passed along with the notification. +- `{{request}}` This object will be `null` if there is no relevant request object for the notification. +- `{{media}}` This object will be `null` if there is no relevant media object for the notification. +- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications. diff --git a/overseerr-api.yml b/overseerr-api.yml index 380979b9..90b90d11 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -27,13 +27,13 @@ tags: - name: tv description: Endpoints related to retrieving TV series and their details. - name: person - description: Endpoints related to retrieving Person details. + description: Endpoints related to retrieving person details. - name: media description: Endpoints related to media management. - name: collection - description: Endpoints related to retrieving Collection details. + description: Endpoints related to retrieving collection details. - name: service - description: Endpoinst related to getting Service (Radarr/Sonarr) details. + description: Endpoints related to getting service (Radarr/Sonarr) details. servers: - url: '{server}/api/v1' variables: @@ -82,11 +82,23 @@ components: readOnly: true items: $ref: '#/components/schemas/MediaRequest' + settings: + $ref: '#/components/schemas/UserSettings' required: - id - email - createdAt - updatedAt + UserSettings: + type: object + properties: + enableNotifications: + type: boolean + default: true + discordId: + type: string + required: + - enableNotifications MainSettings: type: object properties: @@ -376,9 +388,16 @@ components: activeDirectory: type: string example: '/tv/' + activeLanguageProfileId: + type: number + example: 1 + nullable: true activeAnimeProfileId: type: number nullable: true + activeAnimeLanguageProfileId: + type: number + nullable: true activeAnimeProfileName: type: string example: 720p/1080p @@ -637,6 +656,41 @@ components: type: string releaseDate: type: string + releases: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + nullable: true + release_dates: + type: array + items: + type: object + properties: + certification: + type: string + example: 'PG-13' + iso_639_1: + type: string + nullable: true + note: + type: string + nullable: true + example: 'Blu ray' + release_date: + type: string + example: '2017-07-12T00:00:00.000Z' + type: + type: number + example: 1 revenue: type: number nullable: true @@ -745,6 +799,20 @@ components: type: string posterPath: type: string + contentRatings: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + example: 'TV-14' createdBy: type: array items: @@ -1002,9 +1070,6 @@ components: pages: type: number example: 10 - pageSize: - type: number - example: 10 results: type: number example: 100 @@ -1068,6 +1133,20 @@ components: type: string chatId: type: string + PushbulletSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + accessToken: + type: string PushoverSettings: type: object properties: @@ -1086,8 +1165,6 @@ components: type: string priority: type: number - sound: - type: string NotificationSettings: type: object properties: @@ -1446,6 +1523,17 @@ components: searchForMissingEpisodes: type: boolean nullable: true + UserSettingsNotifications: + type: object + properties: + enableNotifications: + type: boolean + default: true + discordId: + type: string + nullable: true + required: + - enableNotifications securitySchemes: cookieAuth: type: apiKey @@ -1530,7 +1618,7 @@ paths: schema: $ref: '#/components/schemas/MainSettings' /settings/main/regenerate: - get: + post: summary: Get main settings with newly-generated API key description: Returns main settings in a JSON object, using the new API key. tags: @@ -1605,21 +1693,50 @@ paths: $ref: '#/components/schemas/PlexLibrary' /settings/plex/sync: get: + summary: Get status of full Plex library sync + description: Returns sync progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Plex sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: number + example: 0 + total: + type: number + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + post: summary: Start full Plex library sync description: Runs a full Plex library sync and returns the progress in a JSON array. tags: - settings - parameters: - - in: query - name: cancel - schema: - type: boolean - example: false - - in: query - name: start - schema: - type: boolean - example: false + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false responses: '200': description: Status of Plex sync @@ -1939,7 +2056,7 @@ paths: schema: $ref: '#/components/schemas/PublicSettings' /settings/initialize: - get: + post: summary: Initialize application description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. tags: @@ -1983,7 +2100,7 @@ paths: type: boolean example: false /settings/jobs/{jobId}/run: - get: + post: summary: Invoke a specific job description: Invokes a specific job to run. Will return the new job status in JSON format. tags: @@ -2018,7 +2135,7 @@ paths: type: boolean example: false /settings/jobs/{jobId}/cancel: - get: + post: summary: Cancel a specific job description: Cancels a specific job. Will return the new job status in JSON format. tags: @@ -2031,7 +2148,7 @@ paths: type: string responses: '200': - description: Cancelled job returned + description: Canceled job returned content: application/json: schema: @@ -2088,7 +2205,7 @@ paths: vsize: type: number /settings/cache/{cacheId}/flush: - get: + post: summary: Flush a specific cache description: Flushes all data from the cache ID provided tags: @@ -2271,6 +2388,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/pushbullet: + get: + summary: Get Pushbullet notification settings + description: Returns current Pushbullet notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushbullet settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + post: + summary: Update Pushbullet notification settings + description: Update Pushbullet notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + /settings/notifications/pushbullet/test: + post: + summary: Test Pushover settings + description: Sends a test notification to the Pushover agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/pushover: get: summary: Get Pushover notification settings @@ -2285,7 +2448,7 @@ paths: schema: $ref: '#/components/schemas/PushoverSettings' post: - summary: Update pushover notification settings + summary: Update Pushover notification settings description: Update Pushover notification settings with the provided values. tags: - settings @@ -2504,7 +2667,7 @@ paths: - email - password /auth/logout: - get: + post: summary: Sign out and clear session cookie description: Completely clear the session cookie and associated values, effectively signing the user out. tags: @@ -2520,21 +2683,103 @@ paths: status: type: string example: 'ok' + /auth/reset-password: + post: + summary: Send a reset password email + description: Sends a reset password email to the email if the user exists + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + /auth/reset-password/{guid}: + post: + summary: Reset the password for a user + description: Resets the password for a user if the given guid is connected to a user + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + required: + - password /user: get: summary: Get all users - description: Returns all users in a JSON array. + description: Returns all users in a JSON object. tags: - users + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [created, updated, requests, displayname] + default: created responses: '200': description: A JSON array of all users content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/User' + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/User' post: summary: Create new user description: | @@ -2603,7 +2848,6 @@ paths: type: array items: $ref: '#/components/schemas/User' - /user/{userId}: get: summary: Get user by ID @@ -2669,6 +2913,250 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + /user/{userId}/requests: + get: + summary: Get user by ID + description: | + Retrieves a user's requests in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + responses: + '200': + description: User's requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + /user/{userId}/settings/main: + get: + summary: Get general settings for a user + description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User general settings returned + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + post: + summary: Update general settings for a user + description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + nullable: true + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + /user/{userId}/settings/password: + get: + summary: Get password page informatiom + description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User password page information returned + content: + application/json: + schema: + type: object + properties: + hasPassword: + type: boolean + example: true + post: + summary: Update password for a user + description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newPassword: + type: string + required: + - newPassword + responses: + '204': + description: User password updated + /user/{userId}/settings/notifications: + get: + summary: Get notification settings for a user + description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + post: + summary: Update notification settings for a user + description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + responses: + '200': + description: Updated user notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + /user/{userId}/settings/permissions: + get: + summary: Get permission settings for a user + description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User permission settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + example: 2 + post: + summary: Update permission settings for a user + description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + required: + - permissions + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + example: 2 /search: get: summary: Search for movies, TV shows, or people @@ -2834,6 +3322,45 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/tv/upcoming: + get: + summary: Discover Upcoming TV shows + description: Returns a list of upcoming TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/trending: get: summary: Trending movies and TV @@ -2946,7 +3473,7 @@ paths: schema: type: string nullable: true - enum: [all, available, approved, pending, unavailable] + enum: [all, approved, available, pending, processing, unavailable] - in: query name: sort schema: @@ -3005,6 +3532,8 @@ paths: type: number rootFolder: type: string + languageProfileId: + type: number required: - mediaType - mediaId @@ -3036,6 +3565,12 @@ paths: approved: type: number example: 10 + processing: + type: number + example: 4 + available: + type: number + example: 6 required: - pending - approved @@ -3121,10 +3656,10 @@ paths: schema: $ref: '#/components/schemas/MediaRequest' /request/{requestId}/{status}: - get: - summary: Update a requests status + post: + summary: Update a request's status description: | - Updates a requests status to approved or declined. Also returns the request in a JSON object. + Updates a request's status to approved or declined. Also returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: @@ -3615,9 +4150,9 @@ paths: '204': description: Succesfully removed media item /media/{mediaId}/{status}: - get: + post: summary: Update media status - description: Updates a medias status and returns the media in JSON format + description: Updates a media item's status and returns the media in JSON format tags: - media parameters: @@ -3636,12 +4171,15 @@ paths: schema: type: string enum: [available, partial, processing, pending, unknown] - - in: query - name: is4k - description: 4K Status - example: false - schema: - type: boolean + requestBody: + content: + application/json: + schema: + type: object + properties: + is4k: + type: boolean + example: false responses: '200': description: Returned media @@ -3776,6 +4314,49 @@ paths: type: array items: $ref: '#/components/schemas/SonarrSeries' + /regions: + get: + summary: Regions supported by TMDb + description: Returns a list of regions in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America + /languages: + get: + summary: Languages supported by TMDb + description: Returns a list of languages in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English security: - cookieAuth: [] diff --git a/package.json b/package.json index db2f5287..0b078b80 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "yarn build:next && yarn build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"", "start": "NODE_ENV=production node dist/index.js", - "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'", + "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false \"./src/**/!(*.test).{ts,tsx}\"", "migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate", "migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create", "migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run", @@ -17,7 +17,7 @@ }, "license": "MIT", "dependencies": { - "@headlessui/react": "^0.2.0-da179ca", + "@headlessui/react": "^0.3.1", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", "ace-builds": "^1.4.12", @@ -27,19 +27,20 @@ "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", + "country-code-emoji": "^2.2.0", "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.10.11", + "express-openapi-validator": "^4.11.0", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", "intl": "^1.2.5", - "lodash": "^4.17.20", + "lodash": "^4.17.21", "next": "10.0.3", "node-cache": "^5.1.2", "node-schedule": "^2.0.0", - "nodemailer": "^6.4.17", + "nodemailer": "^6.4.18", "nookies": "^2.5.2", "plex-api": "^5.3.1", "pug": "^3.0.0", @@ -48,28 +49,28 @@ "react-animate-height": "^2.0.23", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.12.0", + "react-intl": "^5.12.5", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", - "react-toast-notifications": "^2.4.0", + "react-toast-notifications": "^2.4.3", "react-transition-group": "^4.4.1", "react-truncate-markup": "^5.1.0", "react-use-clipboard": "1.0.7", "reflect-metadata": "^0.1.13", "secure-random-password": "^0.2.2", - "sqlite3": "^5.0.0", + "sqlite3": "^5.0.2", "swagger-ui-express": "^4.1.6", - "swr": "^0.4.1", - "typeorm": "^0.2.30", + "swr": "^0.4.2", + "typeorm": "^0.2.31", "uuid": "^8.3.2", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.0", "xml2js": "^0.4.23", "yamljs": "^0.3.0", - "yup": "^0.32.8" + "yup": "^0.32.9" }, "devDependencies": { - "@babel/cli": "^7.12.13", + "@babel/cli": "^7.12.17", "@commitlint/cli": "^11.0.0", "@commitlint/config-conventional": "^11.0.0", "@semantic-release/changelog": "^5.0.1", @@ -83,50 +84,50 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/csurf": "^1.11.0", - "@types/email-templates": "^8.0.1", + "@types/email-templates": "^8.0.2", "@types/express": "^4.17.11", "@types/express-session": "^1.17.3", "@types/lodash": "^4.14.168", - "@types/node": "^14.14.24", + "@types/node": "^14.14.31", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", - "@types/react": "^17.0.1", - "@types/react-dom": "^17.0.0", + "@types/react": "^17.0.2", + "@types/react-dom": "^17.0.1", "@types/react-toast-notifications": "^2.4.0", - "@types/react-transition-group": "^4.4.0", + "@types/react-transition-group": "^4.4.1", "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.14.2", - "@typescript-eslint/parser": "^4.14.2", + "@typescript-eslint/eslint-plugin": "^4.15.1", + "@typescript-eslint/parser": "^4.15.1", "autoprefixer": "^10.2.4", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.19.0", + "eslint": "^7.20.0", "eslint-config-prettier": "^7.2.0", - "eslint-plugin-formatjs": "^2.12.0", + "eslint-plugin-formatjs": "^2.12.4", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "extract-react-intl-messages": "^4.1.1", - "husky": "^4.3.8", - "lint-staged": "^10.5.3", + "husky": "4.3.8", + "lint-staged": "^10.5.4", "nodemon": "^2.0.7", - "postcss": "^8.2.4", + "postcss": "^8.2.6", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.7", + "semantic-release": "^17.3.9", "semantic-release-docker": "^2.2.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", - "typescript": "^4.1.3" + "typescript": "^4.1.5" }, "resolutions": { "sqlite3/node-gyp": "^5.1.0" diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 0182c27c..5d93f956 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -212,7 +212,7 @@ class PlexTvAPI { return parsedXml; } - public async checkUserAccess(authUser: PlexUser): Promise { + public async checkUserAccess(userId: number): Promise { const settings = getSettings(); try { @@ -224,11 +224,11 @@ class PlexTvAPI { const users = friends.MediaContainer.User; - const user = users.find((u) => Number(u.$.id) === authUser.id); + const user = users.find((u) => Number(u.$.id) === userId); if (!user) { throw new Error( - 'This user does not exist on the main plex accounts shared list' + "This user does not exist on the main Plex account's shared list" ); } diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 8e8488d0..187a52ba 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -242,7 +242,7 @@ class RadarrAPI extends ExternalAPI { public getProfiles = async (): Promise => { try { const data = await this.getRolling( - `/profile`, + `/qualityProfile`, undefined, 3600 ); diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 681cb1f3..7369b0b6 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -112,6 +112,7 @@ interface AddSeriesOptions { tvdbid: number; title: string; profileId: number; + languageProfileId?: number; seasons: number[]; seasonFolder: boolean; rootFolderPath: string; @@ -120,6 +121,11 @@ interface AddSeriesOptions { searchNow?: boolean; } +export interface LanguageProfile { + id: number; + name: string; +} + class SonarrAPI extends ExternalAPI { static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ @@ -235,7 +241,8 @@ class SonarrAPI extends ExternalAPI { { tvdbId: options.tvdbid, title: options.title, - profileId: options.profileId, + qualityProfileId: options.profileId, + languageProfileId: options.languageProfileId, seasons: this.buildSeasonList( options.seasons, series.seasons.map((season) => ({ @@ -284,7 +291,7 @@ class SonarrAPI extends ExternalAPI { public async getProfiles(): Promise { try { const data = await this.getRolling( - '/profile', + '/qualityProfile', undefined, 3600 ); @@ -321,6 +328,28 @@ class SonarrAPI extends ExternalAPI { } } + public async getLanguageProfiles(): Promise { + try { + const data = await this.getRolling( + '/languageprofile', + undefined, + 3600 + ); + + return data; + } catch (e) { + logger.error( + 'Something went wrong while retrieving Sonarr language profiles.', + { + label: 'Sonarr API', + message: e.message, + } + ); + + throw new Error('Failed to get language profiles'); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index be1a629e..b7bfeb92 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,11 +1,14 @@ +import { sortBy } from 'lodash'; import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, TmdbExternalIdResponse, + TmdbLanguage, TmdbMovieDetails, TmdbPersonCombinedCredits, TmdbPersonDetail, + TmdbRegion, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, @@ -25,6 +28,8 @@ interface DiscoverMovieOptions { page?: number; includeAdult?: boolean; language?: string; + primaryReleaseDateGte?: string; + primaryReleaseDateLte?: string; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -45,6 +50,9 @@ interface DiscoverMovieOptions { interface DiscoverTvOptions { page?: number; language?: string; + firstAirDateGte?: string; + firstAirDateLte?: string; + includeEmptyReleaseDate?: boolean; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -57,7 +65,12 @@ interface DiscoverTvOptions { } class TheMovieDb extends ExternalAPI { - constructor() { + private region?: string; + private originalLanguage?: string; + constructor({ + region, + originalLanguage, + }: { region?: string; originalLanguage?: string } = {}) { super( 'https://api.themoviedb.org/3', { @@ -67,6 +80,8 @@ class TheMovieDb extends ExternalAPI { nodeCache: cacheManager.getCache('tmdb').data, } ); + this.region = region; + this.originalLanguage = originalLanguage; } public searchMulti = async ({ @@ -145,7 +160,7 @@ class TheMovieDb extends ExternalAPI { { params: { language, - append_to_response: 'credits,external_ids,videos', + append_to_response: 'credits,external_ids,videos,release_dates', }, }, 43200 @@ -171,7 +186,7 @@ class TheMovieDb extends ExternalAPI { params: { language, append_to_response: - 'aggregate_credits,credits,external_ids,keywords,videos', + 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings', }, }, 43200 @@ -343,6 +358,8 @@ class TheMovieDb extends ExternalAPI { page = 1, includeAdult = false, language = 'en', + primaryReleaseDateGte, + primaryReleaseDateLte, }: DiscoverMovieOptions = {}): Promise => { try { const data = await this.get('/discover/movie', { @@ -351,6 +368,11 @@ class TheMovieDb extends ExternalAPI { page, include_adult: includeAdult, language, + with_release_type: '3|2', + region: this.region, + with_original_language: this.originalLanguage, + 'primary_release_date.gte': primaryReleaseDateGte, + 'primary_release_date.lte': primaryReleaseDateLte, }, }); @@ -363,7 +385,10 @@ class TheMovieDb extends ExternalAPI { public getDiscoverTv = async ({ sortBy = 'popularity.desc', page = 1, - language = 'en', + language = 'en-US', + firstAirDateGte, + firstAirDateLte, + includeEmptyReleaseDate = false, }: DiscoverTvOptions = {}): Promise => { try { const data = await this.get('/discover/tv', { @@ -371,6 +396,11 @@ class TheMovieDb extends ExternalAPI { sort_by: sortBy, page, language, + region: this.region, + 'first_air_date.gte': firstAirDateGte, + 'first_air_date.lte': firstAirDateLte, + with_original_language: this.originalLanguage, + include_null_first_air_dates: includeEmptyReleaseDate, }, }); @@ -394,6 +424,8 @@ class TheMovieDb extends ExternalAPI { params: { page, language, + region: this.region, + originalLanguage: this.originalLanguage, }, } ); @@ -420,6 +452,7 @@ class TheMovieDb extends ExternalAPI { params: { page, language, + region: this.region, }, } ); @@ -594,6 +627,38 @@ class TheMovieDb extends ExternalAPI { throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } + + public async getRegions(): Promise { + try { + const data = await this.get( + '/configuration/countries', + {}, + 86400 // 24 hours + ); + + const regions = sortBy(data, 'english_name'); + + return regions; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); + } + } + + public async getLanguages(): Promise { + try { + const data = await this.get( + '/configuration/languages', + {}, + 86400 // 24 hours + ); + + const languages = sortBy(data, 'english_name'); + + return languages; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); + } + } } export default TheMovieDb; diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 63b0ba9a..1b0da07e 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -136,6 +136,7 @@ export interface TmdbMovieDetails { name: string; }[]; release_date: string; + release_dates: TmdbMovieReleaseResult; revenue: number; runtime?: number; spoken_languages: { @@ -205,6 +206,7 @@ export interface TmdbTvSeasonResult { export interface TmdbTvDetails { id: number; backdrop_path?: string; + content_ratings: TmdbTvRatingResult; created_by: { id: number; credit_id: string; @@ -272,6 +274,29 @@ export interface TmdbVideoResult { results: TmdbVideo[]; } +export interface TmdbTvRatingResult { + results: TmdbRating[]; +} + +export interface TmdbRating { + iso_3166_1: string; + rating: string; +} + +export interface TmdbMovieReleaseResult { + results: TmdbRelease[]; +} + +export interface TmdbRelease extends TmdbRating { + release_dates: { + certification: string; + iso_639_1?: string; + note?: string; + release_date: string; + type: number; + }[]; +} + export interface TmdbKeyword { id: number; name: string; @@ -316,6 +341,7 @@ export interface TmdbPersonCredit { adult: boolean; release_date: string; } + export interface TmdbPersonCreditCast extends TmdbPersonCredit { character: string; } @@ -344,3 +370,14 @@ export interface TmdbCollection { backdrop_path?: string; parts: TmdbMovieResult[]; } + +export interface TmdbRegion { + iso_3166_1: string; + english_name: string; +} + +export interface TmdbLanguage { + iso_639_1: string; + english_name: string; + name: string; +} diff --git a/server/constants/media.ts b/server/constants/media.ts index a15dd7ee..d9ef9e02 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -2,7 +2,6 @@ export enum MediaRequestStatus { PENDING = 1, APPROVED, DECLINED, - AVAILABLE, } export enum MediaType { diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 4337d014..658aee67 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -78,6 +78,9 @@ export class MediaRequest { @Column({ nullable: true }) public rootFolder: string; + @Column({ nullable: true }) + public languageProfileId: number; + constructor(init?: Partial) { Object.assign(this, init); } @@ -108,6 +111,7 @@ export class MediaRequest { image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, notifyUser: this.requestedBy, media, + request: this, }); } @@ -127,6 +131,7 @@ export class MediaRequest { .join(', '), }, ], + request: this, }); } } @@ -174,6 +179,7 @@ export class MediaRequest { image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, notifyUser: this.requestedBy, media, + request: this, } ); } else if (this.media.mediaType === MediaType.TV) { @@ -196,6 +202,7 @@ export class MediaRequest { .join(', '), }, ], + request: this, } ); } @@ -380,9 +387,7 @@ export class MediaRequest { const tmdb = new TheMovieDb(); const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); @@ -451,6 +456,7 @@ export class MediaRequest { notifyUser: admin, media, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + request: this, }); }); logger.info('Sent request to Radarr', { label: 'Media Request' }); @@ -527,15 +533,15 @@ export class MediaRequest { const tmdb = new TheMovieDb(); const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, - url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ - sonarrSettings.hostname - }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'), }); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { - this.handleRemoveParentUpdate(); + const requestRepository = getRepository(MediaRequest); + await mediaRepository.remove(media); + await requestRepository.remove(this); throw new Error('Series was missing tvdb id'); } @@ -559,6 +565,11 @@ export class MediaRequest { ? sonarrSettings.activeAnimeProfileId : sonarrSettings.activeProfileId; + let languageProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId + ? sonarrSettings.activeAnimeLanguageProfileId + : sonarrSettings.activeLanguageProfileId; + if ( this.rootFolder && this.rootFolder !== '' && @@ -577,10 +588,24 @@ export class MediaRequest { }); } + if ( + this.languageProfileId && + this.languageProfileId !== languageProfile + ) { + languageProfile = this.languageProfileId; + logger.info( + `Request has an override Language Profile: ${languageProfile}`, + { + label: 'Media Request', + } + ); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ profileId: qualityProfile, + languageProfileId: languageProfile, rootFolderPath: rootFolder, title: series.name, tvdbid: tvdbId, @@ -635,6 +660,7 @@ export class MediaRequest { .join(', '), }, ], + request: this, }); }); logger.info('Sent request to Sonarr', { label: 'Media Request' }); diff --git a/server/entity/User.ts b/server/entity/User.ts index fd0162dd..50ede81d 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -7,6 +7,7 @@ import { OneToMany, RelationCount, AfterLoad, + OneToOne, } from 'typeorm'; import { Permission, @@ -21,14 +22,19 @@ import logger from '../logger'; import { getSettings } from '../lib/settings'; import { default as generatePassword } from 'secure-random-password'; import { UserType } from '../constants/user'; +import { v4 as uuid } from 'uuid'; +import { UserSettings } from './UserSettings'; @Entity() export class User { - public static filterMany(users: User[]): Partial[] { - return users.map((u) => u.filter()); + public static filterMany( + users: User[], + showFiltered?: boolean + ): Partial[] { + return users.map((u) => u.filter(showFiltered)); } - static readonly filteredFields: string[] = ['plexToken', 'password']; + static readonly filteredFields: string[] = ['email']; public displayName: string; @@ -47,6 +53,12 @@ export class User { @Column({ nullable: true, select: false }) public password?: string; + @Column({ nullable: true, select: false }) + public resetPasswordGuid?: string; + + @Column({ type: 'date', nullable: true }) + public recoveryLinkExpirationDate?: Date | null; + @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; @@ -68,6 +80,13 @@ export class User { @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; + @OneToOne(() => UserSettings, (settings) => settings.user, { + cascade: true, + eager: true, + onDelete: 'CASCADE', + }) + public settings?: UserSettings; + @CreateDateColumn() public createdAt: Date; @@ -78,11 +97,11 @@ export class User { Object.assign(this, init); } - public filter(): Partial { + public filter(showFiltered?: boolean): Partial { const filtered: Partial = Object.assign( {}, ...(Object.keys(this) as (keyof User)[]) - .filter((k) => !User.filteredFields.includes(k)) + .filter((k) => showFiltered || !User.filteredFields.includes(k)) .map((k) => ({ [k]: this[k] })) ); @@ -97,11 +116,11 @@ export class User { } public passwordMatch(password: string): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { if (this.password) { resolve(bcrypt.compare(password, this.password)); } else { - return reject(false); + return resolve(false); } }); } @@ -111,29 +130,66 @@ export class User { this.password = hashedPassword; } - public async resetPassword(): Promise { + public async generatePassword(): Promise { const password = generatePassword.randomPassword({ length: 16 }); this.setPassword(password); - const applicationUrl = getSettings().main.applicationUrl; + const { applicationTitle, applicationUrl } = getSettings().main; try { - logger.info(`Sending password email for ${this.email}`, { - label: 'User creation', + logger.info(`Sending generated password email for ${this.email}`, { + label: 'User Management', }); const email = new PreparedEmail(); await email.send({ - template: path.join(__dirname, '../templates/email/password'), + template: path.join(__dirname, '../templates/email/generatedpassword'), message: { to: this.email, }, locals: { password: password, applicationUrl, + applicationTitle, }, }); } catch (e) { - logger.error('Failed to send out password email', { - label: 'User creation', + logger.error('Failed to send out generated password email', { + label: 'User Management', + message: e.message, + }); + } + } + + public async resetPassword(): Promise { + const guid = uuid(); + this.resetPasswordGuid = guid; + + // 24 hours into the future + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + 1); + this.recoveryLinkExpirationDate = targetDate; + + const { applicationTitle, applicationUrl } = getSettings().main; + const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`; + + try { + logger.info(`Sending reset password email for ${this.email}`, { + label: 'User Management', + }); + const email = new PreparedEmail(); + await email.send({ + template: path.join(__dirname, '../templates/email/resetpassword'), + message: { + to: this.email, + }, + locals: { + resetPasswordLink, + applicationUrl: resetPasswordLink, + applicationTitle, + }, + }); + } catch (e) { + logger.error('Failed to send out reset password email', { + label: 'User Management', message: e.message, }); } diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts new file mode 100644 index 00000000..163de134 --- /dev/null +++ b/server/entity/UserSettings.ts @@ -0,0 +1,34 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from './User'; + +@Entity() +export class UserSettings { + constructor(init?: Partial) { + Object.assign(this, init); + } + + @PrimaryGeneratedColumn() + public id: number; + + @OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' }) + @JoinColumn() + public user: User; + + @Column({ default: true }) + public enableNotifications: boolean; + + @Column({ nullable: true }) + public discordId?: string; + + @Column({ nullable: true }) + public region?: string; + + @Column({ nullable: true }) + public originalLanguage?: string; +} diff --git a/server/index.ts b/server/index.ts index 56481682..3cfd0dba 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,7 @@ import SlackAgent from './lib/notifications/agents/slack'; import PushoverAgent from './lib/notifications/agents/pushover'; import WebhookAgent from './lib/notifications/agents/webhook'; import { getClientIp } from '@supercharge/request-ip'; +import PushbulletAgent from './lib/notifications/agents/pushbullet'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -51,9 +52,10 @@ app notificationManager.registerAgents([ new DiscordAgent(), new EmailAgent(), + new PushbulletAgent(), + new PushoverAgent(), new SlackAgent(), new TelegramAgent(), - new PushoverAgent(), new WebhookAgent(), ]); diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index fb4b2cd5..3bfa289e 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,4 +1,5 @@ import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; +import { LanguageProfile } from '../../api/sonarr'; export interface ServiceCommonServer { id: number; @@ -7,12 +8,15 @@ export interface ServiceCommonServer { isDefault: boolean; activeProfileId: number; activeDirectory: string; + activeLanguageProfileId?: number; activeAnimeProfileId?: number; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; } export interface ServiceCommonServerWithDetails { server: ServiceCommonServer; profiles: RadarrProfile[]; rootFolders: Partial[]; + languageProfiles?: LanguageProfile[]; } diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index b731b979..5136f17d 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -12,6 +12,7 @@ export interface PublicSettingsResponse { localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; + region: string; } export interface CacheItem { diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts new file mode 100644 index 00000000..259455dc --- /dev/null +++ b/server/interfaces/api/userInterfaces.ts @@ -0,0 +1,11 @@ +import type { User } from '../../entity/User'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { PaginatedResponse } from './common'; + +export interface UserResultsResponse extends PaginatedResponse { + results: User[]; +} + +export interface UserRequestsResponse extends PaginatedResponse { + results: MediaRequest[]; +} diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts new file mode 100644 index 00000000..023b7631 --- /dev/null +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -0,0 +1,10 @@ +export interface UserSettingsGeneralResponse { + username?: string; + region?: string; + originalLanguage?: string; +} + +export interface UserSettingsNotificationsResponse { + enableNotifications: boolean; + discordId?: string; +} diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 3ed5870d..486fbf90 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -353,6 +353,25 @@ class JobPlexSync { } } + // movies with hama agent actually are tv shows with at least one episode in it + // try to get first episode of any season - cannot hardcode season or episode number + // because sometimes user can have it in other season/ep than s01e01 + private async processHamaMovie( + metadata: PlexMetadata, + tmdbMovie: TmdbMovieDetails | undefined, + tmdbMovieId: number + ) { + const season = metadata.Children?.Metadata[0]; + if (season) { + const episodes = await this.plexClient.getChildrenMetadata( + season.ratingKey + ); + if (episodes) { + await this.processMovieWithId(episodes[0], tmdbMovie, tmdbMovieId); + } + } + } + private async processShow(plexitem: PlexLibraryItem) { const mediaRepository = getRepository(Media); @@ -431,8 +450,8 @@ class JobPlexSync { // if lookup of tvshow above failed, then try movie with tmdbid/imdbid // note - some tv shows have imdbid set too, that's why this need to go second if (result?.tmdbId) { - return await this.processMovieWithId( - plexitem, + return await this.processHamaMovie( + metadata, undefined, result.tmdbId ); @@ -440,8 +459,8 @@ class JobPlexSync { const tmdbMovie = await this.tmdb.getMovieByImdbId({ imdbId: result.imdbId, }); - return await this.processMovieWithId( - plexitem, + return await this.processHamaMovie( + metadata, tmdbMovie, tmdbMovie.id ); @@ -524,15 +543,18 @@ class JobPlexSync { if (existingSeason) { // These ternary statements look super confusing, but they are simply // setting the status to AVAILABLE if all of a type is there, partially if some, - // and then not modifying the status if there are 0 items + // and then not modifying the status if there are 0 items. + // If the season was already available, we don't modify it as well. existingSeason.status = - totalStandard === season.episode_count + totalStandard === season.episode_count || + existingSeason.status === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : totalStandard > 0 ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status; existingSeason.status4k = - this.enable4kShow && total4k === season.episode_count + (this.enable4kShow && total4k === season.episode_count) || + existingSeason.status4k === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 98daf106..4db8966a 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,5 +1,6 @@ import { Notification } from '..'; import Media from '../../../entity/Media'; +import { MediaRequest } from '../../../entity/MediaRequest'; import { User } from '../../../entity/User'; import { NotificationAgentConfig } from '../../settings'; @@ -10,6 +11,7 @@ export interface NotificationPayload { image?: string; message?: string; extra?: { name: string; value: string }[]; + request?: MediaRequest; } export abstract class BaseAgent { @@ -22,6 +24,6 @@ export abstract class BaseAgent { } export interface NotificationAgent { - shouldSend(type: Notification): boolean; + shouldSend(type: Notification, payload: NotificationPayload): boolean; send(type: Notification, payload: NotificationPayload): Promise; } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 81558cdf..fc6e5bbb 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -74,6 +74,12 @@ interface DiscordWebhookPayload { username: string; avatar_url?: string; tts: boolean; + content?: string; + allowed_mentions?: { + parse?: ('users' | 'roles' | 'everyone')[]; + roles?: string[]; + users?: string[]; + }; } class DiscordAgent @@ -98,106 +104,64 @@ class DiscordAgent const fields: Field[] = []; + if (payload.request) { + fields.push({ + name: 'Requested By', + value: payload.notifyUser.displayName ?? '', + inline: true, + }); + } + switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; - fields.push( - { - name: 'Requested By', - value: payload.notifyUser.displayName ?? '', - inline: true, - }, - { - name: 'Status', - value: 'Pending Approval', - inline: true, - } - ); - - if (settings.main.applicationUrl) { - fields.push({ - name: 'View Media', - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + fields.push({ + name: 'Status', + value: 'Pending Approval', + inline: true, + }); break; case Notification.MEDIA_APPROVED: color = EmbedColors.PURPLE; - fields.push( - { - name: 'Requested By', - value: payload.notifyUser.displayName ?? '', - inline: true, - }, - { - name: 'Status', - value: 'Processing Request', - inline: true, - } - ); - - if (settings.main.applicationUrl) { - fields.push({ - name: 'View Media', - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + fields.push({ + name: 'Status', + value: 'Processing', + inline: true, + }); break; case Notification.MEDIA_AVAILABLE: color = EmbedColors.GREEN; - fields.push( - { - name: 'Requested By', - value: payload.notifyUser.displayName ?? '', - inline: true, - }, - { - name: 'Status', - value: 'Available', - inline: true, - } - ); - - if (settings.main.applicationUrl) { - fields.push({ - name: 'View Media', - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + fields.push({ + name: 'Status', + value: 'Available', + inline: true, + }); break; case Notification.MEDIA_DECLINED: color = EmbedColors.RED; - fields.push( - { - name: 'Requested By', - value: payload.notifyUser.displayName ?? '', - inline: true, - }, - { - name: 'Status', - value: 'Declined', - inline: true, - } - ); - - if (settings.main.applicationUrl) { - fields.push({ - name: 'View Media', - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + fields.push({ + name: 'Status', + value: 'Declined', + inline: true, + }); break; case Notification.MEDIA_FAILED: color = EmbedColors.RED; - if (settings.main.applicationUrl) { - fields.push({ - name: 'View Media', - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + fields.push({ + name: 'Status', + value: 'Failed', + inline: true, + }); break; } + if (settings.main.applicationUrl && payload.media) { + fields.push({ + name: `Open in ${settings.main.applicationTitle}`, + value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }); + } + return { title: payload.subject, description: payload.message, @@ -246,9 +210,24 @@ class DiscordAgent return false; } + const mentionedUsers: string[] = []; + let content = undefined; + + if ( + payload.notifyUser.settings?.enableNotifications && + payload.notifyUser.settings?.discordId + ) { + mentionedUsers.push(payload.notifyUser.settings.discordId); + content = `<@${payload.notifyUser.settings.discordId}>`; + } + await axios.post(webhookUrl, { username: settings.main.applicationTitle, embeds: [this.buildEmbed(type, payload)], + content, + allowed_mentions: { + users: mentionedUsers, + }, } as DiscordWebhookPayload); return true; @@ -256,6 +235,7 @@ class DiscordAgent logger.error('Error sending Discord notification', { label: 'Notifications', message: e.message, + response: e.response.data, }); return false; } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index c5c2fe83..750aaf68 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -21,12 +21,13 @@ class EmailAgent return settings.notifications.agents.email; } - public shouldSend(type: Notification): boolean { + public shouldSend(type: Notification, payload: NotificationPayload): boolean { const settings = this.getSettings(); if ( settings.enabled && - hasNotificationType(type, this.getSettings().types) + hasNotificationType(type, this.getSettings().types) && + (payload.notifyUser.settings?.enableNotifications ?? true) ) { return true; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts new file mode 100644 index 00000000..c7becfab --- /dev/null +++ b/server/lib/notifications/agents/pushbullet.ts @@ -0,0 +1,146 @@ +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentPushbullet } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface PushbulletPayload { + title: string; + body: string; +} + +class PushbulletAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentPushbullet { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.pushbullet; + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + this.getSettings().options.accessToken && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + private constructMessageDetails( + type: Notification, + payload: NotificationPayload + ): { + title: string; + body: string; + } { + let messageTitle = ''; + let message = ''; + + const title = payload.subject; + const plot = payload.message; + const username = payload.notifyUser.displayName; + + switch (type) { + case Notification.MEDIA_PENDING: + messageTitle = 'New Request'; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Pending Approval`; + break; + case Notification.MEDIA_APPROVED: + messageTitle = 'Request Approved'; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Processing`; + break; + case Notification.MEDIA_AVAILABLE: + messageTitle = 'Now Available'; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Available`; + break; + case Notification.MEDIA_DECLINED: + messageTitle = 'Request Declined'; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Declined`; + break; + case Notification.MEDIA_FAILED: + messageTitle = 'Failed Request'; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Failed`; + break; + case Notification.TEST_NOTIFICATION: + messageTitle = 'Test Notification'; + message += `${plot}`; + break; + } + + return { + title: messageTitle, + body: message, + }; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending Pushbullet notification', { label: 'Notifications' }); + 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, + { + type: 'note', + title: title, + body: body, + } as PushbulletPayload, + { + headers: { + 'Access-Token': accessToken, + }, + } + ); + + return true; + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } +} + +export default PushbulletAgent; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 52f538aa..19c6d6d9 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -9,6 +9,9 @@ interface PushoverPayload { user: string; title: string; message: string; + url: string; + url_title: string; + priority: number; html: number; } @@ -41,10 +44,19 @@ class PushoverAgent private constructMessageDetails( type: Notification, payload: NotificationPayload - ): { title: string; message: string } { + ): { + title: string; + message: string; + url: string | undefined; + url_title: string | undefined; + priority: number; + } { const settings = getSettings(); let messageTitle = ''; let message = ''; + let url: string | undefined; + let url_title: string | undefined; + let priority = 0; const title = payload.subject; const plot = payload.message; @@ -53,45 +65,69 @@ class PushoverAgent switch (type) { case Notification.MEDIA_PENDING: messageTitle = 'New Request'; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `Requested By\n${username}\n\n`; - message += `Status\nPending Approval\n`; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nPending Approval`; break; case Notification.MEDIA_APPROVED: messageTitle = 'Request Approved'; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `Requested By\n${username}\n\n`; - message += `Status\nProcessing Request\n`; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nProcessing`; break; case Notification.MEDIA_AVAILABLE: messageTitle = 'Now Available'; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `Requested By\n${username}\n\n`; - message += `Status\nAvailable\n`; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nAvailable`; break; case Notification.MEDIA_DECLINED: messageTitle = 'Request Declined'; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `Requested By\n${username}\n\n`; - message += `Status\nDeclined\n`; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nDeclined`; + priority = 1; + break; + case Notification.MEDIA_FAILED: + messageTitle = 'Failed Request'; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nFailed`; + priority = 1; break; case Notification.TEST_NOTIFICATION: messageTitle = 'Test Notification'; - message += `${plot}\n\n`; - message += `Requested By\n${username}\n`; + message += `${plot}`; break; } if (settings.main.applicationUrl && payload.media) { - const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `Open in ${settings.main.applicationTitle}`; + url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; + url_title = `Open in ${settings.main.applicationTitle}`; } - return { title: messageTitle, message }; + return { + title: messageTitle, + message, + url, + url_title, + priority, + }; } public async send( @@ -104,13 +140,22 @@ class PushoverAgent const { accessToken, userToken } = this.getSettings().options; - const { title, message } = this.constructMessageDetails(type, payload); + const { + title, + message, + url, + url_title, + priority, + } = this.constructMessageDetails(type, payload); await axios.post(endpoint, { token: accessToken, user: userToken, title: title, message: message, + url: url, + url_title: url_title, + priority: priority, html: 1, } as PushoverPayload); diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 318bbfeb..70a527f1 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -58,77 +58,61 @@ class SlackAgent payload: NotificationPayload ): SlackBlockEmbed { const settings = getSettings(); - let header = settings.main.applicationTitle; + let header = ''; let actionUrl: string | undefined; const fields: EmbedField[] = []; + if (payload.request) { + fields.push({ + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, + }); + } + switch (type) { case Notification.MEDIA_PENDING: header = 'New Request'; - fields.push( - { - type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, - }, - { - type: 'mrkdwn', - text: '*Status*\nPending Approval', - } - ); - if (settings.main.applicationUrl) { - actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; - } + fields.push({ + type: 'mrkdwn', + text: '*Status*\nPending Approval', + }); break; case Notification.MEDIA_APPROVED: header = 'Request Approved'; - fields.push( - { - type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, - }, - { - type: 'mrkdwn', - text: '*Status*\nProcessing Request', - } - ); - if (settings.main.applicationUrl) { - actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; - } + fields.push({ + type: 'mrkdwn', + text: '*Status*\nProcessing', + }); + break; + case Notification.MEDIA_AVAILABLE: + header = 'Now Available'; + fields.push({ + type: 'mrkdwn', + text: '*Status*\nAvailable', + }); break; case Notification.MEDIA_DECLINED: header = 'Request Declined'; - fields.push( - { - type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, - }, - { - type: 'mrkdwn', - text: '*Status*\nDeclined', - } - ); - if (settings.main.applicationUrl) { - actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; - } + fields.push({ + type: 'mrkdwn', + text: '*Status*\nDeclined', + }); break; - case Notification.MEDIA_AVAILABLE: - header = 'Now available!'; - fields.push( - { - type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, - }, - { - type: 'mrkdwn', - text: '*Status*\nAvailable', - } - ); + case Notification.MEDIA_FAILED: + header = 'Failed Request'; + fields.push({ + type: 'mrkdwn', + text: '*Status*\nFailed', + }); + break; + case Notification.TEST_NOTIFICATION: + header = 'Test Notification'; + break; + } - if (settings.main.applicationUrl) { - actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; - } - break; + if (settings.main.applicationUrl && payload.media) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; } const blocks: EmbedBlock[] = [ @@ -139,14 +123,17 @@ class SlackAgent text: header, }, }, - { + ]; + + if (type !== Notification.TEST_NOTIFICATION) { + blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*${payload.subject}*`, }, - }, - ]; + }); + } if (payload.message) { blocks.push({ @@ -191,7 +178,7 @@ class SlackAgent value: 'open_overseerr', text: { type: 'plain_text', - text: `Open ${settings.main.applicationTitle}`, + text: `Open in ${settings.main.applicationTitle}`, }, }, ], diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 2e08cbdf..9913d35e 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -8,6 +8,7 @@ interface TelegramPayload { text: string; parse_mode: string; chat_id: string; + disable_notification: boolean; } class TelegramAgent @@ -56,49 +57,59 @@ class TelegramAgent /* eslint-disable no-useless-escape */ switch (type) { case Notification.MEDIA_PENDING: - message += `\*New Request\*\n`; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `\*Requested By\*\n${user}\n\n`; - message += `\*Status\*\nPending Approval\n`; - + message += `\*New Request\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nPending Approval`; break; case Notification.MEDIA_APPROVED: - message += `\*Request Approved\*\n`; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `\*Requested By\*\n${user}\n\n`; - message += `\*Status\*\nProcessing Request\n`; - - break; - case Notification.MEDIA_DECLINED: - message += `\*Request Declined\*\n`; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `\*Requested By\*\n${user}\n\n`; - message += `\*Status\*\nDeclined\n`; - + message += `\*Request Approved\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nProcessing`; break; case Notification.MEDIA_AVAILABLE: - message += `\*Now available\\!\*\n`; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `\*Requested By\*\n${user}\n\n`; - message += `\*Status\*\nAvailable\n`; - + message += `\*Now Available\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nAvailable`; + break; + case Notification.MEDIA_DECLINED: + message += `\*Request Declined\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nDeclined`; + break; + case Notification.MEDIA_FAILED: + message += `\*Failed Request\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nFailed`; break; case Notification.TEST_NOTIFICATION: - message += `\*Test Notification\*\n`; - message += `${title}\n\n`; - message += `${plot}\n\n`; - message += `\*Requested By\*\n${user}\n`; - + message += `\*Test Notification\*`; + message += `\n\n${plot}`; break; } if (settings.main.applicationUrl && payload.media) { const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`; + message += `\n\n\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`; } /* eslint-enable */ @@ -119,6 +130,7 @@ class TelegramAgent text: this.buildMessage(type, payload), parse_mode: 'MarkdownV2', chat_id: `${this.getSettings().options.chatId}`, + disable_notification: this.getSettings().options.sendSilently, } as TelegramPayload); return true; diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 796593da..6186be49 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -19,6 +19,7 @@ const KeyMap: Record = { notifyuser_username: 'notifyUser.displayName', notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', + notifyuser_settings_discordId: 'notifyUser.settings.discordId', media_tmdbid: 'media.tmdbId', media_imdbid: 'media.imdbId', media_tvdbid: 'media.tvdbId', @@ -27,6 +28,7 @@ const KeyMap: Record = { payload.media?.status ? MediaStatus[payload.media?.status] : '', media_status4k: (payload) => payload.media?.status ? MediaStatus[payload.media?.status4k] : '', + request_id: 'request.id', }; class WebhookAgent @@ -60,6 +62,14 @@ class WebhookAgent } delete finalPayload[key]; key = 'media'; + } else if (key === '{{request}}') { + if (payload.request) { + finalPayload.request = finalPayload[key]; + } else { + finalPayload.request = null; + } + delete finalPayload[key]; + key = 'request'; } if (typeof finalPayload[key] === 'string') { diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index d4341217..a50a2932 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -49,7 +49,7 @@ class NotificationManager { label: 'Notifications', }); this.activeAgents.forEach((agent) => { - if (settings.enabled && agent.shouldSend(type)) { + if (settings.enabled && agent.shouldSend(type, payload)) { agent.send(type, payload); } }); diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index f0d45acd..5006a004 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -14,6 +14,9 @@ export enum Permission { REQUEST_4K_TV = 4096, REQUEST_ADVANCED = 8192, REQUEST_VIEW = 16384, + AUTO_APPROVE_4K = 32768, + AUTO_APPROVE_4K_MOVIE = 65536, + AUTO_APPROVE_4K_TV = 131072, } export interface PermissionCheckOptions { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index f5ac5e8e..02320e00 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -10,6 +10,17 @@ export interface Library { enabled: boolean; } +export interface Region { + iso_3166_1: string; + english_name: string; +} + +export interface Language { + iso_639_1: string; + english_name: string; + name: string; +} + export interface PlexSettings { name: string; machineId?: string; @@ -45,6 +56,8 @@ export interface SonarrSettings extends DVRSettings { activeAnimeProfileId?: number; activeAnimeProfileName?: string; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; + activeLanguageProfileId?: number; enableSeasonFolders: boolean; } @@ -56,6 +69,8 @@ export interface MainSettings { defaultPermissions: number; hideAvailable: boolean; localLogin: boolean; + region: string; + originalLanguage: string; trustProxy: boolean; } @@ -69,6 +84,7 @@ interface FullPublicSettings extends PublicSettings { localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; + region: string; } export interface NotificationAgentConfig { @@ -105,6 +121,13 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { botAPI: string; chatId: string; + sendSilently: boolean; + }; +} + +export interface NotificationAgentPushbullet extends NotificationAgentConfig { + options: { + accessToken: string; }; } @@ -113,7 +136,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig { accessToken: string; userToken: string; priority: number; - sound: string; }; } @@ -126,11 +148,12 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig { } interface NotificationAgents { - email: NotificationAgentEmail; discord: NotificationAgentDiscord; + email: NotificationAgentEmail; + pushbullet: NotificationAgentPushbullet; + pushover: NotificationAgentPushover; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; - pushover: NotificationAgentPushover; webhook: NotificationAgentWebhook; } @@ -168,6 +191,8 @@ class Settings { defaultPermissions: Permission.REQUEST, hideAvailable: false, localLogin: true, + region: '', + originalLanguage: '', trustProxy: false, }, plex: { @@ -218,6 +243,14 @@ class Settings { options: { botAPI: '', chatId: '', + sendSilently: false, + }, + }, + pushbullet: { + enabled: false, + types: 0, + options: { + accessToken: '', }, }, pushover: { @@ -227,7 +260,6 @@ class Settings { accessToken: '', userToken: '', priority: 0, - sound: '', }, }, webhook: { @@ -304,6 +336,7 @@ class Settings { series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), + region: this.data.main.region, }; } diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts new file mode 100644 index 00000000..01278c01 --- /dev/null +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddResetPasswordGuidAndExpiryDate1612482778137 + implements MigrationInterface { + name = 'AddResetPasswordGuidAndExpiryDate1612482778137'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts new file mode 100644 index 00000000..fa89d81b --- /dev/null +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLanguageProfileId1612571545781 implements MigrationInterface { + name = 'AddLanguageProfileId1612571545781'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/migration/1613615266968-CreateUserSettings.ts b/server/migration/1613615266968-CreateUserSettings.ts new file mode 100644 index 00000000..4d4a973e --- /dev/null +++ b/server/migration/1613615266968-CreateUserSettings.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserSettings1613615266968 implements MigrationInterface { + name = 'CreateUserSettings1613615266968'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query(`DROP TABLE "user_settings"`); + } +} diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts new file mode 100644 index 00000000..17c25ec2 --- /dev/null +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateUserSettingsRegions1613955393450 + implements MigrationInterface { + name = 'UpdateUserSettingsRegions1613955393450'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/models/Movie.ts b/server/models/Movie.ts index bfeb95ac..be4828ec 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,4 +1,7 @@ -import type { TmdbMovieDetails } from '../api/themoviedb/interfaces'; +import type { + TmdbMovieDetails, + TmdbMovieReleaseResult, +} from '../api/themoviedb/interfaces'; import { ProductionCompany, Genre, @@ -48,6 +51,7 @@ export interface MovieDetails { name: string; }[]; releaseDate: string; + releases: TmdbMovieReleaseResult; revenue: number; runtime?: number; spokenLanguages: { @@ -95,6 +99,7 @@ export const mapMovieDetails = ( })), productionCountries: movie.production_countries, releaseDate: movie.release_date, + releases: movie.release_dates, revenue: movie.revenue, spokenLanguages: movie.spoken_languages, status: movie.status, diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 420dca28..3631573e 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -15,6 +15,7 @@ import type { TmdbTvSeasonResult, TmdbTvDetails, TmdbSeasonWithEpisodes, + TmdbTvRatingResult, } from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; @@ -58,6 +59,7 @@ export interface TvDetails { id: number; backdropPath?: string; posterPath?: string; + contentRatings: TmdbTvRatingResult; createdBy: { id: number; name: string; @@ -174,6 +176,7 @@ export const mapTvDetails = ( originCountry: company.origin_country, logoPath: company.logo_path, })), + contentRatings: show.content_ratings, spokenLanguages: show.spoken_languages.map((language) => ({ englishName: language.english_name, iso_639_1: language.iso_639_1, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 1fd21dac..3437f76a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -23,7 +23,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { where: { id: req.user.id }, }); - return res.status(200).json(user.filter()); + return res.status(200).json(user); }); authRoutes.post('/login', async (req, res, next) => { @@ -87,7 +87,7 @@ authRoutes.post('/login', async (req, res, next) => { }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - if (await mainPlexTv.checkUserAccess(account)) { + if (await mainPlexTv.checkUserAccess(account.id)) { user = new User({ email: account.email, plexUsername: account.username, @@ -176,7 +176,10 @@ authRoutes.post('/local', async (req, res, next) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { - logger.error(e.message, { label: 'Auth' }); + logger.error('Something went wrong when trying to authenticate', { + label: 'Auth', + error: e.message, + }); return next({ status: 500, message: 'Something went wrong.', @@ -184,7 +187,7 @@ authRoutes.post('/local', async (req, res, next) => { } }); -authRoutes.get('/logout', (req, res, next) => { +authRoutes.post('/logout', (req, res, next) => { req.session?.destroy((err) => { if (err) { return next({ @@ -197,4 +200,80 @@ authRoutes.get('/logout', (req, res, next) => { }); }); +authRoutes.post('/reset-password', async (req, res) => { + const userRepository = getRepository(User); + const body = req.body as { email?: string }; + + if (!body.email) { + return res.status(500).json({ error: 'You must provide an email' }); + } + + const user = await userRepository.findOne({ + where: { email: body.email }, + }); + + if (user) { + await user.resetPassword(); + userRepository.save(user); + logger.info('Successful request made for recovery link', { + label: 'User Management', + context: { ip: req.ip, email: body.email }, + }); + } else { + logger.info('Failed request made to reset a password', { + label: 'User Management', + context: { ip: req.ip, email: body.email }, + }); + } + + return res.status(200).json({ status: 'ok' }); +}); + +authRoutes.post('/reset-password/:guid', async (req, res, next) => { + const userRepository = getRepository(User); + + try { + if (!req.body.password || req.body.password?.length < 8) { + const message = + 'Failed to reset password. Password must be atleast 8 characters long.'; + logger.info(message, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid }, + }); + return next({ status: 500, message: message }); + } + + const user = await userRepository.findOne({ + where: { resetPasswordGuid: req.params.guid }, + }); + + if (!user) { + throw new Error('Guid invalid.'); + } + + if ( + !user.recoveryLinkExpirationDate || + user.recoveryLinkExpirationDate <= new Date() + ) { + throw new Error('Recovery link expired.'); + } + + await user.setPassword(req.body.password); + user.recoveryLinkExpirationDate = null; + userRepository.save(user); + logger.info(`Successfully reset password`, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid, email: user.email }, + }); + + return res.status(200).json({ status: 'ok' }); + } catch (e) { + logger.info(`Failed to reset password. ${e.message}`, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid }, + }); + return res.status(200).json({ status: 'ok' }); + } +}); + export default authRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ec2116aa..5b9d1afc 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -4,11 +4,17 @@ import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search'; import Media from '../entity/Media'; import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; +import { getSettings } from '../lib/settings'; const discoverRoutes = Router(); discoverRoutes.get('/movies', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), @@ -35,11 +41,23 @@ discoverRoutes.get('/movies', async (req, res) => { }); discoverRoutes.get('/movies/upcoming', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); - const data = await tmdb.getUpcomingMovies({ + const now = new Date(); + const offset = now.getTimezoneOffset(); + const date = new Date(now.getTime() - offset * 60 * 1000) + .toISOString() + .split('T')[0]; + + const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.query.language as string, + primaryReleaseDateGte: date, }); const media = await Media.getRelatedMedia( @@ -62,7 +80,12 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => { }); discoverRoutes.get('/tv', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), @@ -88,8 +111,52 @@ discoverRoutes.get('/tv', async (req, res) => { }); }); +discoverRoutes.get('/tv/upcoming', async (req, res) => { + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); + + const now = new Date(); + const offset = now.getTimezoneOffset(); + const date = new Date(now.getTime() - offset * 60 * 1000) + .toISOString() + .split('T')[0]; + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + firstAirDateGte: date, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); +}); + discoverRoutes.get('/trending', async (req, res) => { - const tmdb = new TheMovieDb(); + const settings = getSettings(); + const tmdb = new TheMovieDb({ + region: req.user?.settings?.region ?? settings.main.region, + originalLanguage: + req.user?.settings?.originalLanguage ?? settings.main.originalLanguage, + }); const data = await tmdb.getAllTrending({ page: Number(req.query.page), diff --git a/server/routes/index.ts b/server/routes/index.ts index 78324025..7527c030 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -16,6 +16,7 @@ import collectionRoutes from './collection'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; import { appDataStatus, appDataPath } from '../utils/appDataVolume'; +import TheMovieDb from '../api/themoviedb'; const router = Router(); @@ -35,7 +36,7 @@ router.get('/status/appdata', (_req, res) => { }); }); -router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); +router.use('/user', isAuthenticated(), user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); @@ -57,6 +58,22 @@ router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/auth', authRoutes); +router.get('/regions', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const regions = await tmdb.getRegions(); + + return res.status(200).json(regions); +}); + +router.get('/languages', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const languages = await tmdb.getLanguages(); + + return res.status(200).json(languages); +}); + router.get('/', (_req, res) => { return res.status(200).json({ api: 'Overseerr API', diff --git a/server/routes/media.ts b/server/routes/media.ts index f6c6a505..c77f7708 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -82,7 +82,7 @@ mediaRoutes.get('/', async (req, res, next) => { } }); -mediaRoutes.get< +mediaRoutes.post< { id: string; status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown'; @@ -102,7 +102,7 @@ mediaRoutes.get< return next({ status: 404, message: 'Media does not exist.' }); } - const is4k = Boolean(req.query.is4k); + const is4k = Boolean(req.body.is4k); switch (req.params.status) { case 'available': diff --git a/server/routes/request.ts b/server/routes/request.ts index 25ba9c0e..ec89a983 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; -import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm'; +import { getRepository } from 'typeorm'; import { MediaRequest } from '../entity/MediaRequest'; import TheMovieDb from '../api/themoviedb'; import Media from '../entity/Media'; @@ -14,66 +14,102 @@ import { User } from '../entity/User'; const requestRoutes = Router(); requestRoutes.get('/', async (req, res, next) => { - const requestRepository = getRepository(MediaRequest); try { - const pageSize = req.query.take ? Number(req.query.take) : 20; + const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter: - | MediaRequestStatus - | FindOperator - | undefined = undefined; + let statusFilter: MediaRequestStatus[]; + + switch (req.query.filter) { + case 'approved': + case 'processing': + case 'available': + statusFilter = [MediaRequestStatus.APPROVED]; + break; + case 'pending': + statusFilter = [MediaRequestStatus.PENDING]; + break; + case 'unavailable': + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + ]; + break; + default: + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + MediaRequestStatus.DECLINED, + ]; + } + + let mediaStatusFilter: MediaStatus[]; switch (req.query.filter) { case 'available': - statusFilter = MediaRequestStatus.AVAILABLE; - break; - case 'approved': - statusFilter = MediaRequestStatus.APPROVED; - break; - case 'pending': - statusFilter = MediaRequestStatus.PENDING; + mediaStatusFilter = [MediaStatus.AVAILABLE]; break; + case 'processing': case 'unavailable': - statusFilter = In([ - MediaRequestStatus.PENDING, - MediaRequestStatus.APPROVED, - ]); + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + ]; break; default: - statusFilter = In(Object.values(MediaRequestStatus)); + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + ]; } - let sortFilter: FindOneOptions['order'] = { - id: 'DESC', - }; + let sortFilter: string; switch (req.query.sort) { case 'modified': - sortFilter = { - updatedAt: 'DESC', - }; + sortFilter = 'request.updatedAt'; break; + default: + sortFilter = 'request.id'; } - const [requests, requestCount] = req.user?.hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], - { type: 'or' } - ) - ? await requestRepository.findAndCount({ - order: sortFilter, - relations: ['media', 'modifiedBy'], - where: { status: statusFilter }, - take: Number(req.query.take) ?? 20, - skip, - }) - : await requestRepository.findAndCount({ - where: { requestedBy: { id: req.user?.id }, status: statusFilter }, - relations: ['media', 'modifiedBy'], - order: sortFilter, - take: Number(req.query.limit) ?? 20, - skip, - }); + let query = getRepository(MediaRequest) + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media') + .leftJoinAndSelect('request.seasons', 'seasons') + .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') + .leftJoinAndSelect('request.requestedBy', 'requestedBy') + .where('request.status IN (:...requestStatus)', { + requestStatus: statusFilter, + }) + .andWhere( + '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', + { + mediaStatus: mediaStatusFilter, + } + ); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + query = query.andWhere('requestedBy.id = :id', { + id: req.user?.id, + }); + } + + const [requests, requestCount] = await query + .orderBy(sortFilter, 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); return res.status(200).json({ pageInfo: { @@ -176,13 +212,29 @@ requestRoutes.post( requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: - req.user?.hasPermission(Permission.AUTO_APPROVE) || - req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE + ) || + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE + ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, modifiedBy: - req.user?.hasPermission(Permission.AUTO_APPROVE) || - req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE + ) || + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE + ) ? req.user : undefined, is4k: req.body.is4k, @@ -237,26 +289,51 @@ requestRoutes.post( requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: - req.user?.hasPermission(Permission.AUTO_APPROVE) || - req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE + ) || + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV + ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, modifiedBy: - req.user?.hasPermission(Permission.AUTO_APPROVE) || - req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE + ) || + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV + ) ? req.user : undefined, is4k: req.body.is4k, serverId: req.body.serverId, profileId: req.body.profileId, rootFolder: req.body.rootFolder, + languageProfileId: req.body.languageProfileId, seasons: finalSeasons.map( (sn) => new SeasonRequest({ seasonNumber: sn, status: - req.user?.hasPermission(Permission.AUTO_APPROVE) || - req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE + ) || + req.user?.hasPermission( + req.body.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV + ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, }) @@ -278,16 +355,51 @@ requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); try { - const pendingCount = await requestRepository.count({ - status: MediaRequestStatus.PENDING, - }); - const approvedCount = await requestRepository.count({ - status: MediaRequestStatus.APPROVED, - }); + const query = requestRepository + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media'); + + const pendingCount = await query + .where('request.status = :requestStatus', { + requestStatus: MediaRequestStatus.PENDING, + }) + .getCount(); + + const approvedCount = await query + .where('request.status = :requestStatus', { + requestStatus: MediaRequestStatus.APPROVED, + }) + .getCount(); + + const processingCount = await query + .where('request.status = :requestStatus', { + requestStatus: MediaRequestStatus.APPROVED, + }) + .andWhere( + '(request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus)', + { + availableStatus: MediaStatus.AVAILABLE, + } + ) + .getCount(); + + const availableCount = await query + .where('request.status = :requestStatus', { + requestStatus: MediaRequestStatus.APPROVED, + }) + .andWhere( + '(request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus)', + { + availableStatus: MediaStatus.AVAILABLE, + } + ) + .getCount(); return res.status(200).json({ pending: pendingCount, approved: approvedCount, + processing: processingCount, + available: availableCount, }); } catch (e) { next({ status: 500, message: e.message }); @@ -488,7 +600,7 @@ requestRoutes.post<{ } ); -requestRoutes.get<{ +requestRoutes.post<{ requestId: string; status: 'pending' | 'approve' | 'decline'; }>( diff --git a/server/routes/service.ts b/server/routes/service.ts index 94b2bc72..5e6dccc8 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -46,9 +46,7 @@ serviceRoutes.get<{ radarrId: string }>( const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); @@ -90,6 +88,8 @@ serviceRoutes.get('/sonarr', async (req, res) => { activeProfileId: sonarr.activeProfileId, activeAnimeProfileId: sonarr.activeAnimeProfileId, activeAnimeDirectory: sonarr.activeAnimeDirectory, + activeLanguageProfileId: sonarr.activeLanguageProfileId, + activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId, }) ); @@ -114,36 +114,43 @@ serviceRoutes.get<{ sonarrId: string }>( const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, - url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ - sonarrSettings.hostname - }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'), }); - const profiles = await sonarr.getProfiles(); - const rootFolders = await sonarr.getRootFolders(); + try { + const profiles = await sonarr.getProfiles(); + const rootFolders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); - return res.status(200).json({ - server: { - id: sonarrSettings.id, - name: sonarrSettings.name, - is4k: sonarrSettings.is4k, - isDefault: sonarrSettings.isDefault, - activeDirectory: sonarrSettings.activeDirectory, - activeProfileId: sonarrSettings.activeProfileId, - activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, - activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, - }, - profiles: profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - })), - rootFolders: rootFolders.map((folder) => ({ - id: folder.id, - freeSpace: folder.freeSpace, - path: folder.path, - totalSpace: folder.totalSpace, - })), - } as ServiceCommonServerWithDetails); + return res.status(200).json({ + server: { + id: sonarrSettings.id, + name: sonarrSettings.name, + is4k: sonarrSettings.is4k, + isDefault: sonarrSettings.isDefault, + activeDirectory: sonarrSettings.activeDirectory, + activeProfileId: sonarrSettings.activeProfileId, + activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, + activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, + activeLanguageProfileId: sonarrSettings.activeLanguageProfileId, + activeAnimeLanguageProfileId: + sonarrSettings.activeAnimeLanguageProfileId, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + languageProfiles: languageProfiles, + } as ServiceCommonServerWithDetails); + } catch (e) { + next({ status: 500, message: e.message }); + } } ); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 61dabe21..0099d28c 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -54,7 +54,7 @@ settingsRoutes.post('/main', (req, res) => { return res.status(200).json(settings.main); }); -settingsRoutes.get('/main/regenerate', (req, res, next) => { +settingsRoutes.post('/main/regenerate', (req, res, next) => { const settings = getSettings(); const main = settings.regenerateApiKey(); @@ -210,10 +210,14 @@ settingsRoutes.get('/plex/library', async (req, res) => { return res.status(200).json(settings.plex.libraries); }); -settingsRoutes.get('/plex/sync', (req, res) => { - if (req.query.cancel) { +settingsRoutes.get('/plex/sync', (_req, res) => { + return res.status(200).json(jobPlexFullSync.status()); +}); + +settingsRoutes.post('/plex/sync', (req, res) => { + if (req.body.cancel) { jobPlexFullSync.cancel(); - } else if (req.query.start) { + } else if (req.body.start) { jobPlexFullSync.run(); } return res.status(200).json(jobPlexFullSync.status()); @@ -231,7 +235,7 @@ settingsRoutes.get('/jobs', (_req, res) => { ); }); -settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { +settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId); if (!scheduledJob) { @@ -249,7 +253,7 @@ settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { }); }); -settingsRoutes.get<{ jobId: string }>( +settingsRoutes.post<{ jobId: string }>( '/jobs/:jobId/cancel', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -286,7 +290,7 @@ settingsRoutes.get('/cache', (req, res) => { ); }); -settingsRoutes.get<{ cacheId: AvailableCacheIds }>( +settingsRoutes.post<{ cacheId: AvailableCacheIds }>( '/cache/:cacheId/flush', (req, res, next) => { const cache = cacheManager.getCache(req.params.cacheId); @@ -300,7 +304,7 @@ settingsRoutes.get<{ cacheId: AvailableCacheIds }>( } ); -settingsRoutes.get( +settingsRoutes.post( '/initialize', isAuthenticated(Permission.ADMIN), (_req, res) => { diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 7f52e7db..58be3a4f 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -7,6 +7,7 @@ import SlackAgent from '../../lib/notifications/agents/slack'; import TelegramAgent from '../../lib/notifications/agents/telegram'; import PushoverAgent from '../../lib/notifications/agents/pushover'; import WebhookAgent from '../../lib/notifications/agents/webhook'; +import PushbulletAgent from '../../lib/notifications/agents/pushbullet'; const notificationRoutes = Router(); @@ -135,6 +136,40 @@ notificationRoutes.post('/telegram/test', (req, res, next) => { return res.status(204).send(); }); +notificationRoutes.get('/pushbullet', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.pushbullet); +}); + +notificationRoutes.post('/pushbullet', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.pushbullet = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.pushbullet); +}); + +notificationRoutes.post('/pushbullet/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const pushbulletAgent = new PushbulletAgent(req.body); + pushbulletAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + notificationRoutes.get('/pushover', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index 1bbcf208..1e17a475 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -39,9 +39,7 @@ radarrRoutes.post('/test', async (req, res, next) => { try { const radarr = new RadarrAPI({ apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, + url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'), }); const profiles = await radarr.getProfiles(); @@ -112,9 +110,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => { const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 409530f7..d9bbe3c2 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -39,13 +39,12 @@ sonarrRoutes.post('/test', async (req, res, next) => { try { const sonarr = new SonarrAPI({ apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, + url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'), }); const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); return res.status(200).json({ profiles, @@ -53,6 +52,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { id: folder.id, path: folder.path, })), + languageProfiles, }); } catch (e) { logger.error('Failed to test Sonarr', { diff --git a/server/routes/user.ts b/server/routes/user.ts deleted file mode 100644 index 896278ef..00000000 --- a/server/routes/user.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { Router } from 'express'; -import { getRepository, Not } from 'typeorm'; -import PlexTvAPI from '../api/plextv'; -import { MediaRequest } from '../entity/MediaRequest'; -import { User } from '../entity/User'; -import { hasPermission, Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import gravatarUrl from 'gravatar-url'; -import { UserType } from '../constants/user'; - -const router = Router(); - -router.get('/', async (_req, res) => { - const userRepository = getRepository(User); - - const users = await userRepository.find(); - - return res.status(200).json(User.filterMany(users)); -}); - -router.post('/', async (req, res, next) => { - try { - const settings = getSettings(); - - const body = req.body; - const userRepository = getRepository(User); - - const passedExplicitPassword = body.password && body.password.length > 0; - const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); - - if (!passedExplicitPassword && !settings.notifications.agents.email) { - throw new Error('Email notifications must be enabled'); - } - - const user = new User({ - avatar: body.avatar ?? avatar, - username: body.username ?? body.email, - email: body.email, - password: body.password, - permissions: settings.main.defaultPermissions, - plexToken: '', - userType: UserType.LOCAL, - }); - - if (passedExplicitPassword) { - await user?.setPassword(body.password); - } else { - await user?.resetPassword(); - } - - await userRepository.save(user); - return res.status(201).json(user.filter()); - } catch (e) { - next({ status: 500, message: e.message }); - } -}); - -router.get<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOneOrFail({ - where: { id: Number(req.params.id) }, - }); - - return res.status(200).json(user.filter()); - } catch (e) { - next({ status: 404, message: 'User not found' }); - } -}); - -const canMakePermissionsChange = (permissions: number, user?: User) => - // Only let the owner grant admin privileges - !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || - // Only let users with the manage settings permission, grant the same permission - !( - hasPermission(Permission.MANAGE_SETTINGS, permissions) && - !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) - ); - -router.put< - Record, - Partial[], - { ids: string[]; permissions: number } ->('/', async (req, res, next) => { - try { - const isOwner = req.user?.id === 1; - - if (!canMakePermissionsChange(req.body.permissions, req.user)) { - return next({ - status: 403, - message: 'You do not have permission to grant this level of access', - }); - } - - const userRepository = getRepository(User); - - const users = await userRepository.findByIds(req.body.ids, { - ...(!isOwner ? { id: Not(1) } : {}), - }); - - const updatedUsers = await Promise.all( - users.map(async (user) => { - return userRepository.save({ - ...user, - ...{ permissions: req.body.permissions }, - }); - }) - ); - - return res.status(200).json(updatedUsers); - } catch (e) { - next({ status: 500, message: e.message }); - } -}); - -router.put<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOneOrFail({ - where: { id: Number(req.params.id) }, - }); - - // Only let the owner user modify themselves - if (user.id === 1 && req.user?.id !== 1) { - return next({ - status: 403, - message: 'You do not have permission to modify this user', - }); - } - - if (!canMakePermissionsChange(req.body.permissions, req.user)) { - return next({ - status: 403, - message: 'You do not have permission to grant this level of access', - }); - } - - Object.assign(user, { - username: req.body.username, - permissions: req.body.permissions, - }); - - await userRepository.save(user); - - return res.status(200).json(user.filter()); - } catch (e) { - next({ status: 404, message: 'User not found' }); - } -}); - -router.delete<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOne({ - where: { id: Number(req.params.id) }, - relations: ['requests'], - }); - - if (!user) { - return next({ status: 404, message: 'User not found' }); - } - - if (user.id === 1) { - return next({ status: 405, message: 'This account cannot be deleted.' }); - } - - if (user.hasPermission(Permission.ADMIN)) { - return next({ - status: 405, - message: 'You cannot delete users with administrative privileges.', - }); - } - - const requestRepository = getRepository(MediaRequest); - - /** - * Requests are usually deleted through a cascade constraint. Those however, do - * not trigger the removal event so listeners to not run and the parent Media - * will not be updated back to unknown for titles that were still pending. So - * we manually remove all requests from the user here so the parent media's - * properly reflect the change. - */ - await requestRepository.remove(user.requests); - - await userRepository.delete(user.id); - return res.status(200).json(user.filter()); - } catch (e) { - logger.error('Something went wrong deleting a user', { - label: 'API', - userId: req.params.id, - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Something went wrong deleting the user', - }); - } -}); - -router.post('/import-from-plex', async (req, res, next) => { - try { - const settings = getSettings(); - const userRepository = getRepository(User); - - // taken from auth.ts - const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - - const plexUsersResponse = await mainPlexTv.getUsers(); - const createdUsers: User[] = []; - for (const rawUser of plexUsersResponse.MediaContainer.User) { - const account = rawUser.$; - - const user = await userRepository.findOne({ - where: [{ plexId: account.id }, { email: account.email }], - }); - - if (user) { - // Update the users avatar with their plex thumbnail (incase it changed) - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; - - // in-case the user was previously a local account - if (user.userType === UserType.LOCAL) { - user.userType = UserType.PLEX; - user.plexId = parseInt(account.id); - - if (user.username === account.username) { - user.username = ''; - } - } - await userRepository.save(user); - } else { - // Check to make sure it's a real account - if (account.email && account.username) { - 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 }); - } -}); - -export default router; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts new file mode 100644 index 00000000..803aed7c --- /dev/null +++ b/server/routes/user/index.ts @@ -0,0 +1,380 @@ +import { Router } from 'express'; +import { getRepository, Not } from 'typeorm'; +import PlexTvAPI from '../../api/plextv'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { User } from '../../entity/User'; +import { hasPermission, Permission } from '../../lib/permissions'; +import { getSettings } from '../../lib/settings'; +import logger from '../../logger'; +import gravatarUrl from 'gravatar-url'; +import { UserType } from '../../constants/user'; +import { isAuthenticated } from '../../middleware/auth'; +import { UserResultsResponse } from '../../interfaces/api/userInterfaces'; +import { UserRequestsResponse } from '../../interfaces/api/userInterfaces'; +import userSettingsRoutes from './usersettings'; + +const router = Router(); + +router.get('/', async (req, res, next) => { + try { + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + let query = getRepository(User).createQueryBuilder('user'); + + switch (req.query.sort) { + case 'updated': + query = query.orderBy('user.updatedAt', 'DESC'); + break; + case 'displayname': + query = query.orderBy( + '(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)', + 'ASC' + ); + break; + case 'requests': + query = query + .addSelect((subQuery) => { + return subQuery + .select('COUNT(request.id)', 'requestCount') + .from(MediaRequest, 'request') + .where('request.requestedBy.id = user.id'); + }, 'requestCount') + .orderBy('requestCount', 'DESC'); + break; + default: + query = query.orderBy('user.id', 'ASC'); + break; + } + + const [users, userCount] = await query + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(userCount / pageSize), + pageSize, + results: userCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: User.filterMany( + users, + req.user?.hasPermission(Permission.MANAGE_USERS) + ), + } as UserResultsResponse); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +router.post( + '/', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const settings = getSettings(); + + const body = req.body; + const userRepository = getRepository(User); + + const passedExplicitPassword = body.password && body.password.length > 0; + const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); + + if (!passedExplicitPassword && !settings.notifications.agents.email) { + throw new Error('Email notifications must be enabled'); + } + + const user = new User({ + avatar: body.avatar ?? avatar, + username: body.username ?? body.email, + email: body.email, + password: body.password, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + + if (passedExplicitPassword) { + await user?.setPassword(body.password); + } else { + await user?.generatePassword(); + } + + await userRepository.save(user); + return res.status(201).json(user.filter()); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +router.get<{ id: string }>('/:id', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + return res + .status(200) + .json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS))); + } catch (e) { + next({ status: 404, message: 'User not found.' }); + } +}); + +router.use('/:id/settings', userSettingsRoutes); + +router.get<{ id: string }, UserRequestsResponse>( + '/:id/requests', + async (req, res, next) => { + const userRepository = getRepository(User); + const requestRepository = getRepository(MediaRequest); + + const pageSize = req.query.take ? Number(req.query.take) : 20; + const skip = req.query.skip ? Number(req.query.skip) : 0; + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + const [requests, requestCount] = await requestRepository.findAndCount({ + where: { requestedBy: user }, + order: { id: 'DESC' }, + take: pageSize, + skip, + }); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(requestCount / pageSize), + pageSize, + results: requestCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: requests, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +const canMakePermissionsChange = (permissions: number, user?: User) => + // Only let the owner grant admin privileges + !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || + // Only let users with the manage settings permission, grant the same permission + !( + hasPermission(Permission.MANAGE_SETTINGS, permissions) && + !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) + ); + +router.put< + Record, + Partial[], + { ids: string[]; permissions: number } +>('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { + try { + const isOwner = req.user?.id === 1; + + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + const userRepository = getRepository(User); + + const users = await userRepository.findByIds(req.body.ids, { + ...(!isOwner ? { id: Not(1) } : {}), + }); + + const updatedUsers = await Promise.all( + users.map(async (user) => { + return userRepository.save({ + ...user, + ...{ permissions: req.body.permissions }, + }); + }) + ); + + return res.status(200).json(updatedUsers); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +router.put<{ id: string }>( + '/:id', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + // Only let the owner user modify themselves + if (user.id === 1 && req.user?.id !== 1) { + return next({ + status: 403, + message: 'You do not have permission to modify this user', + }); + } + + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + Object.assign(user, { + username: req.body.username, + permissions: req.body.permissions, + }); + + await userRepository.save(user); + + return res.status(200).json(user.filter()); + } catch (e) { + next({ status: 404, message: 'User not found.' }); + } + } +); + +router.delete<{ id: string }>( + '/:id', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + relations: ['requests'], + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + if (user.id === 1) { + return next({ + status: 405, + message: 'This account cannot be deleted.', + }); + } + + if (user.hasPermission(Permission.ADMIN)) { + return next({ + status: 405, + message: 'You cannot delete users with administrative privileges.', + }); + } + + const requestRepository = getRepository(MediaRequest); + + /** + * Requests are usually deleted through a cascade constraint. Those however, do + * not trigger the removal event so listeners to not run and the parent Media + * will not be updated back to unknown for titles that were still pending. So + * we manually remove all requests from the user here so the parent media's + * properly reflect the change. + */ + await requestRepository.remove(user.requests); + + await userRepository.delete(user.id); + return res.status(200).json(user.filter()); + } catch (e) { + logger.error('Something went wrong deleting a user', { + label: 'API', + userId: req.params.id, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Something went wrong deleting the user', + }); + } + } +); + +router.post( + '/import-from-plex', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const settings = getSettings(); + const userRepository = getRepository(User); + + // taken from auth.ts + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + + const plexUsersResponse = await mainPlexTv.getUsers(); + const createdUsers: User[] = []; + for (const rawUser of plexUsersResponse.MediaContainer.User) { + const account = rawUser.$; + + const user = await userRepository.findOne({ + where: [{ plexId: account.id }, { email: account.email }], + }); + + if (user) { + // Update the users avatar with their plex thumbnail (incase it changed) + user.avatar = account.thumb; + user.email = account.email; + user.plexUsername = account.username; + + // in-case the user was previously a local account + if (user.userType === UserType.LOCAL) { + user.userType = UserType.PLEX; + user.plexId = parseInt(account.id); + + if (user.username === account.username) { + user.username = ''; + } + } + 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 }); + } + } +); + +export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts new file mode 100644 index 00000000..c2e07511 --- /dev/null +++ b/server/routes/user/usersettings.ts @@ -0,0 +1,304 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import { User } from '../../entity/User'; +import { UserSettings } from '../../entity/UserSettings'; +import { + UserSettingsGeneralResponse, + UserSettingsNotificationsResponse, +} from '../../interfaces/api/userSettingsInterfaces'; +import { Permission } from '../../lib/permissions'; +import logger from '../../logger'; +import { isAuthenticated } from '../../middleware/auth'; + +const isOwnProfileOrAdmin = (): Middleware => { + const authMiddleware: Middleware = (req, res, next) => { + if ( + !req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== Number(req.params.id) + ) { + return next({ + status: 403, + message: "You do not have permission to view this user's settings.", + }); + } + next(); + }; + return authMiddleware; +}; + +const userSettingsRoutes = Router({ mergeParams: true }); + +userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( + '/main', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ + username: user.username, + region: user.settings?.region, + originalLanguage: user.settings?.originalLanguage, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + UserSettingsGeneralResponse, + UserSettingsGeneralResponse +>('/main', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + user.username = req.body.username; + if (!user.settings) { + user.settings = new UserSettings({ + user: req.user, + region: req.body.region, + originalLanguage: req.body.originalLanguage, + }); + } else { + user.settings.region = req.body.region; + user.settings.originalLanguage = req.body.originalLanguage; + } + + await userRepository.save(user); + + return res.status(200).json({ username: user.username }); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, { hasPassword: boolean }>( + '/password', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + select: ['id', 'password'], + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ hasPassword: !!user.password }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + null, + { currentPassword?: string; newPassword: string } +>('/password', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + const userWithPassword = await userRepository.findOne({ + select: ['id', 'password'], + where: { id: Number(req.params.id) }, + }); + + if (!user || !userWithPassword) { + return next({ status: 404, message: 'User not found.' }); + } + + if (req.body.newPassword.length < 8) { + return next({ + status: 400, + message: 'Password must be at least 8 characters', + }); + } + + // If the user has the permission to manage users and they are not + // editing themselves, we will just set the new password + if ( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== user.id + ) { + await user.setPassword(req.body.newPassword); + await userRepository.save(user); + logger.debug('Password overriden by user.', { + label: 'User Settings', + userEmail: user.email, + changingUser: req.user.email, + }); + return res.status(204).send(); + } + + // If the user has a password, we need to check the currentPassword is correct + if ( + user.password && + (!req.body.currentPassword || + !(await userWithPassword.passwordMatch(req.body.currentPassword))) + ) { + logger.debug( + 'Attempt to change password for user failed. Invalid current password provided.', + { label: 'User Settings', userEmail: user.email } + ); + return next({ status: 403, message: 'Current password is invalid.' }); + } + + await user.setPassword(req.body.newPassword); + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( + '/notifications', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ + enableNotifications: user.settings?.enableNotifications ?? true, + discordId: user.settings?.discordId, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + UserSettingsNotificationsResponse, + UserSettingsNotificationsResponse +>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + if (!user.settings) { + user.settings = new UserSettings({ + user: req.user, + enableNotifications: req.body.enableNotifications, + discordId: req.body.discordId, + }); + } else { + user.settings.enableNotifications = req.body.enableNotifications; + user.settings.discordId = req.body.discordId; + } + + userRepository.save(user); + + return res.status(200).json({ + enableNotifications: user.settings.enableNotifications, + discordId: user.settings.discordId, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, { permissions?: number }>( + '/permissions', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ permissions: user.permissions }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + { permissions?: number }, + { permissions: number } +>( + '/permissions', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + if (user.id === 1) { + return next({ + status: 500, + message: 'Permissions for user with ID 1 cannot be modified', + }); + } + + user.permissions = req.body.permissions; + + await userRepository.save(user); + + return res.status(200).json({ permissions: user.permissions }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +export default userSettingsRoutes; diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 8414d9a9..b434f6c0 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -35,6 +35,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { message: movie.overview, media: entity, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + request: request, }); }); } @@ -96,6 +97,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { .join(', '), }, ], + request: request, }); } } diff --git a/server/templates/email/password/html.pug b/server/templates/email/generatedpassword/html.pug similarity index 100% rename from server/templates/email/password/html.pug rename to server/templates/email/generatedpassword/html.pug diff --git a/server/templates/email/generatedpassword/subject.pug b/server/templates/email/generatedpassword/subject.pug new file mode 100644 index 00000000..2768f12b --- /dev/null +++ b/server/templates/email/generatedpassword/subject.pug @@ -0,0 +1 @@ +!= `Account Information [${applicationTitle}]` diff --git a/server/templates/email/media-request/subject.pug b/server/templates/email/media-request/subject.pug index e1c43065..a0f50fba 100644 --- a/server/templates/email/media-request/subject.pug +++ b/server/templates/email/media-request/subject.pug @@ -1 +1 @@ -= `${requestType}: ${mediaName} - ${applicationTitle}` +!= `${requestType} - ${mediaName} [${applicationTitle}]` diff --git a/server/templates/email/password/subject.pug b/server/templates/email/password/subject.pug deleted file mode 100644 index e9135b7e..00000000 --- a/server/templates/email/password/subject.pug +++ /dev/null @@ -1 +0,0 @@ -= `Password Reset - ${applicationTitle}` diff --git a/server/templates/email/resetpassword/html.pug b/server/templates/email/resetpassword/html.pug new file mode 100644 index 00000000..f7c8bb08 --- /dev/null +++ b/server/templates/email/resetpassword/html.pug @@ -0,0 +1,100 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen') + //if mso + xml + o:officedocumentsettings + o:pixelsperinch 96 + style. + td, + th, + div, + p, + a, + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Segoe UI', sans-serif; + mso-line-height-rule: exactly; + } + style. + @media (max-width: 600px) { + .sm-w-full { + width: 100% !important; + } + } +div(role='article' aria-roledescription='email' aria-label='' lang='en') + table(style="\ + background-color: #f2f4f6;\ + font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\ + width: 100%;\ + " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center') + table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='\ + font-size: 16px;\ + padding-top: 25px;\ + padding-bottom: 25px;\ + text-align: center;\ + ') + a(href=applicationUrl style='\ + text-shadow: 0 1px 0 #ffffff;\ + font-weight: 700;\ + font-size: 16px;\ + color: #a8aaaf;\ + text-decoration: none;\ + ') + | #{applicationTitle} + tr + td(style='width: 100%' width='100%') + table.sm-w-full(align='center' style='\ + background-color: #ffffff;\ + margin-left: auto;\ + margin-right: auto;\ + width: 570px;\ + ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation') + tr + td(style='padding: 45px') + div(style='font-size: 16px; text-align: center; padding-bottom: 14px;') + | A request to reset the password was made. Click + a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here + | to set a new password. + div(style='font-size: 16px; text-align: center; padding-bottom: 14px;') + | If you did not request this recovery link you can safely ignore this email. + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle} +tr + td + table.sm-w-full(align='center' style='\ + margin-left: auto;\ + margin-right: auto;\ + text-align: center;\ + width: 570px;\ + ' width='570' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='font-size: 16px; padding: 45px') + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + text-align: center;\ + color: #a8aaaf;\ + ') + | #{applicationTitle}. diff --git a/server/templates/email/resetpassword/subject.pug b/server/templates/email/resetpassword/subject.pug new file mode 100644 index 00000000..0751d745 --- /dev/null +++ b/server/templates/email/resetpassword/subject.pug @@ -0,0 +1 @@ +!= `Password Reset [${applicationTitle}]` diff --git a/server/templates/email/test-email/subject.pug b/server/templates/email/test-email/subject.pug index c138fe15..64ce0add 100644 --- a/server/templates/email/test-email/subject.pug +++ b/server/templates/email/test-email/subject.pug @@ -1 +1 @@ -= `Test Notification - ${applicationTitle}` +!= `Test Notification [${applicationTitle}]` diff --git a/src/assets/extlogos/discord_white.svg b/src/assets/extlogos/discord.svg similarity index 100% rename from src/assets/extlogos/discord_white.svg rename to src/assets/extlogos/discord.svg diff --git a/src/assets/extlogos/pushbullet.svg b/src/assets/extlogos/pushbullet.svg new file mode 100644 index 00000000..c241c5d4 --- /dev/null +++ b/src/assets/extlogos/pushbullet.svg @@ -0,0 +1 @@ +image/svg+xml diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index b6bec5e2..5148a309 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -8,16 +8,17 @@ import { MediaStatus } from '../../../server/constants/media'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; import { LanguageContext } from '../../context/LanguageContext'; -import globalMessages from '../../i18n/globalMessages'; import Error from '../../pages/_error'; -import Badge from '../Common/Badge'; -import Button from '../Common/Button'; +import StatusBadge from '../StatusBadge'; +import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import LoadingSpinner from '../Common/LoadingSpinner'; import Modal from '../Common/Modal'; import Slider from '../Slider'; import TitleCard from '../TitleCard'; import Transition from '../Transition'; import PageTitle from '../Common/PageTitle'; +import { useUser, Permission } from '../../hooks/useUser'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ overviewunavailable: 'Overview unavailable.', @@ -29,6 +30,10 @@ const messages = defineMessages({ requestcollection: 'Request Collection', requestswillbecreated: 'The following titles will have requests created for them:', + request4k: 'Request 4K', + requestcollection4k: 'Request Collection in 4K', + requestswillbecreated4k: + 'The following titles will have 4K requests created for them:', requestSuccess: '{title} successfully requested!', }); @@ -41,10 +46,14 @@ const CollectionDetails: React.FC = ({ }) => { const intl = useIntl(); const router = useRouter(); + const settings = useSettings(); const { addToast } = useToasts(); const { locale } = useContext(LanguageContext); + const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [isRequesting, setRequesting] = useState(false); + const [is4k, setIs4k] = useState(false); + const { data, error, revalidate } = useSWR( `/api/v1/collection/${router.query.collectionId}?language=${locale}`, { @@ -61,8 +70,45 @@ const CollectionDetails: React.FC = ({ return ; } + let collectionStatus = MediaStatus.UNKNOWN; + let collectionStatus4k = MediaStatus.UNKNOWN; + + if ( + data.parts.every( + (part) => + part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE + ) + ) { + collectionStatus = MediaStatus.AVAILABLE; + } else if ( + data.parts.some( + (part) => + part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE + ) + ) { + collectionStatus = MediaStatus.PARTIALLY_AVAILABLE; + } + + if ( + data.parts.every( + (part) => + part.mediaInfo && part.mediaInfo.status4k === MediaStatus.AVAILABLE + ) + ) { + collectionStatus4k = MediaStatus.AVAILABLE; + } else if ( + data.parts.some( + (part) => + part.mediaInfo && part.mediaInfo.status4k === MediaStatus.AVAILABLE + ) + ) { + collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE; + } + const requestableParts = data.parts.filter( - (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN + (part) => + !part.mediaInfo || + part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN ); const requestBundle = async () => { @@ -73,6 +119,7 @@ const CollectionDetails: React.FC = ({ await axios.post('/api/v1/request', { mediaId: part.id, mediaType: 'movie', + is4k, }); }) ); @@ -102,7 +149,7 @@ const CollectionDetails: React.FC = ({ return (
= ({ okText={ isRequesting ? intl.formatMessage(messages.requesting) - : intl.formatMessage(messages.request) + : intl.formatMessage(is4k ? messages.request4k : messages.request) } okDisabled={isRequesting} okButtonType="primary" onCancel={() => setRequestModal(false)} - title={intl.formatMessage(messages.requestcollection)} + title={intl.formatMessage( + is4k ? messages.requestcollection4k : messages.requestcollection + )} iconSvg={ = ({ } > -

{intl.formatMessage(messages.requestswillbecreated)}

+

+ {intl.formatMessage( + is4k + ? messages.requestswillbecreated4k + : messages.requestswillbecreated + )} +

    {data.parts .filter( (part) => !part.mediaInfo || - part.mediaInfo?.status === MediaStatus.UNKNOWN + part.mediaInfo[is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN ) .map((part) => (
  • {part.title}
  • @@ -160,64 +216,128 @@ const CollectionDetails: React.FC = ({
-
-
+
+
-
-
- {data.parts.every( - (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE - ) && ( - - {intl.formatMessage(globalMessages.available)} - - )} - {!data.parts.every( - (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE - ) && - data.parts.some( - (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE +
+
+ + (part.mediaInfo?.downloadStatus ?? []).length > 0 + )} + /> + + {settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { + type: 'or', + } ) && ( - - {intl.formatMessage(globalMessages.partiallyavailable)} - + + + (part.mediaInfo?.downloadStatus4k ?? []).length > 0 + )} + /> + )}

{data.name}

- + {intl.formatMessage(messages.numberofmovies, { count: data.parts.length, })}
-
- {data.parts.some( - (part) => - !part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN - ) && ( - - )} +
+ {hasPermission(Permission.REQUEST) && + (collectionStatus !== MediaStatus.AVAILABLE || + (settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { type: 'or' } + ) && + collectionStatus4k !== MediaStatus.AVAILABLE)) && ( +
+ { + setRequestModal(true); + setIs4k(collectionStatus === MediaStatus.AVAILABLE); + }} + text={ + <> + + + + + {intl.formatMessage( + collectionStatus === MediaStatus.AVAILABLE + ? messages.requestcollection4k + : messages.requestcollection + )} + + + } + > + {settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { type: 'or' } + ) && + collectionStatus !== MediaStatus.AVAILABLE && + collectionStatus4k !== MediaStatus.AVAILABLE && ( + { + setRequestModal(true); + setIs4k(true); + }} + > + + + + + {intl.formatMessage(messages.requestcollection4k)} + + + )} + +
+ )}
diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 1f7672bc..bd5e467b 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,4 +1,4 @@ -import React, { ButtonHTMLAttributes } from 'react'; +import React, { ForwardedRef } from 'react'; export type ButtonType = | 'default' @@ -8,20 +8,44 @@ export type ButtonType = | 'success' | 'ghost'; -interface ButtonProps extends ButtonHTMLAttributes { +// Helper type to override types (overrides onClick) +type MergeElementProps< + T extends React.ElementType, + P extends Record +> = Omit, keyof P> & P; + +type ElementTypes = 'button' | 'a'; + +type Element

= P extends 'a' + ? HTMLAnchorElement + : HTMLButtonElement; + +type BaseProps

= { buttonType?: ButtonType; buttonSize?: 'default' | 'lg' | 'md' | 'sm'; -} + // Had to do declare this manually as typescript would assume e was of type any otherwise + onClick?: ( + e: React.MouseEvent

+ ) => void; +}; -const Button: React.FC = ({ - buttonType = 'default', - buttonSize = 'default', - children, - className, - ...props -}) => { +type ButtonProps

= { + as?: P; +} & MergeElementProps>; + +function Button

( + { + buttonType = 'default', + buttonSize = 'default', + as, + children, + className, + ...props + }: ButtonProps

, + ref?: React.Ref> +): JSX.Element { const buttonStyle = [ - 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150', + 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer', ]; switch (buttonType) { case 'primary': @@ -68,14 +92,30 @@ const Button: React.FC = ({ default: buttonStyle.push('px-4 py-2 text-sm'); } - if (className) { - buttonStyle.push(className); - } - return ( - - ); -}; -export default Button; + buttonStyle.push(className ?? ''); + + if (as === 'a') { + return ( + )} + ref={ref as ForwardedRef} + > + {children} + + ); + } else { + return ( + + ); + } +} + +export default React.forwardRef(Button) as typeof Button; diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index fd9b6c79..5b3b0bc3 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -37,7 +37,7 @@ const ListView: React.FC = ({ {intl.formatMessage(messages.noresults)}

)} -
    +
      {items?.map((title) => { let titleCard: React.ReactNode; @@ -90,22 +90,12 @@ const ListView: React.FC = ({ break; } - return ( -
    • - {titleCard} -
    • - ); + return
    • {titleCard}
    • ; })} {isLoading && !isReachingEnd && [...Array(20)].map((_item, i) => ( -
    • +
    • ))} diff --git a/src/components/Common/PlayButton/index.tsx b/src/components/Common/PlayButton/index.tsx new file mode 100644 index 00000000..5a513d74 --- /dev/null +++ b/src/components/Common/PlayButton/index.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import ButtonWithDropdown from '../ButtonWithDropdown'; + +interface PlayButtonProps { + links: PlayButtonLink[]; +} + +export interface PlayButtonLink { + text: string; + url: string; +} + +const PlayButton: React.FC = ({ links }) => { + if (!links || !links.length) { + return null; + } + + return ( + + + + + + {links[0].text} + + } + onClick={() => { + window.open(links[0].url, '_blank'); + }} + > + {links.length > 1 && + links.slice(1).map((link, i) => { + return ( + { + window.open(link.url, '_blank'); + }} + buttonType="ghost" + > + {link.text} + + ); + })} + + ); +}; + +export default PlayButton; diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx new file mode 100644 index 00000000..6e08c29d --- /dev/null +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -0,0 +1,93 @@ +import React, { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import type { TvResult } from '../../../server/models/Search'; +import ListView from '../Common/ListView'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { LanguageContext } from '../../context/LanguageContext'; +import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; +import PageTitle from '../Common/PageTitle'; + +const messages = defineMessages({ + upcomingtv: 'Upcoming Series', +}); + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: TvResult[]; +} + +const DiscoverTvUpcoming: React.FC = () => { + const intl = useIntl(); + const settings = useSettings(); + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/discover/tv/upcoming?page=${ + pageIndex + 1 + }&language=${locale}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + if (error) { + return
      {error}
      ; + } + + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as TvResult[] + ); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + + const isEmpty = !isLoadingInitialData && titles?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.results.length < 20); + + return ( + <> + +
      +
      + +
      +
      + 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvUpcoming; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index e883d4ae..b53e9d3d 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -15,6 +15,7 @@ const messages = defineMessages({ recentrequests: 'Recent Requests', popularmovies: 'Popular Movies', populartv: 'Popular Series', + upcomingtv: 'Upcoming Series', recentlyAdded: 'Recently Added', nopending: 'No Pending Requests', upcoming: 'Upcoming Movies', @@ -97,12 +98,6 @@ const Discover: React.FC = () => { placeholder={} emptyMessage={intl.formatMessage(messages.nopending)} /> - { url="/api/v1/discover/movies" linkUrl="/discover/movies" /> + + ); }; diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index 50b2caf8..8bb1be32 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -89,7 +89,7 @@ const LanguagePicker: React.FC = () => {
      { leaveTo="transform opacity-0 scale-95" > diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 1078d288..10d146fa 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import SearchInput from './SearchInput'; import UserDropdown from './UserDropdown'; import Sidebar from './Sidebar'; @@ -14,17 +14,45 @@ const messages = defineMessages({ const Layout: React.FC = ({ children }) => { const [isSidebarOpen, setSidebarOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); const { hasPermission } = useUser(); const router = useRouter(); + useEffect(() => { + const updateScrolled = () => { + if (window.pageYOffset > 60) { + setIsScrolled(true); + } else { + setIsScrolled(false); + } + }; + + window.addEventListener('scroll', updateScrolled, { passive: true }); + + return () => { + window.removeEventListener('scroll', updateScrolled); + }; + }, []); + return (
      +
      +
      +
      setSidebarOpen(false)} />
      -
      +
      -
      +
      -
      +
      @@ -52,7 +80,7 @@ const Layout: React.FC = ({ children }) => {
      -
      +
      {router.pathname === '/' && hasPermission(Permission.ADMIN) && (
      diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index ccd7c354..c870acbf 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -4,6 +4,7 @@ import Button from '../Common/Button'; import { Field, Form, Formik } from 'formik'; import * as Yup from 'yup'; import axios from 'axios'; +import Link from 'next/link'; const messages = defineMessages({ email: 'Email Address', @@ -12,7 +13,8 @@ const messages = defineMessages({ validationpasswordrequired: 'Password required', loginerror: 'Something went wrong while trying to sign in.', signingin: 'Signing in…', - signin: 'Sign in', + signin: 'Sign In', + forgotpassword: 'Forgot Password?', }); interface LocalLoginProps { @@ -95,9 +97,16 @@ const LocalLogin: React.FC = ({ revalidate }) => {
      )}
      -
      -
      - +
      +
      + + + + + +
      -
        +
          {data?.credits.cast.map((person, index) => { return ( -
        • +
        • { {intl.formatMessage(messages.fullcrew)}
      -
        +
          {data?.credits.crew.map((person, index) => { return ( -
        • +
        • = ({ movie }) => { const settings = useSettings(); - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); const [showManager, setShowManager] = useState(false); + const { data, error, revalidate } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}`, { initialData: movie, } ); + const { data: ratingData } = useSWR( `/api/v1/movie/${router.query.movieId}/ratings` ); @@ -110,11 +111,39 @@ const MovieDetails: React.FC = ({ movie }) => { return ; } + const mediaLinks: PlayButtonLink[] = []; + + if (data.mediaInfo?.plexUrl) { + mediaLinks.push({ + text: intl.formatMessage(messages.playonplex), + url: data.mediaInfo?.plexUrl, + }); + } + + if ( + data.mediaInfo?.plexUrl4k && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { + type: 'or', + }) + ) { + mediaLinks.push({ + text: intl.formatMessage(messages.play4konplex), + url: data.mediaInfo?.plexUrl4k, + }); + } + const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) .pop()?.url; + if (trailerUrl) { + mediaLinks.push({ + text: intl.formatMessage(messages.watchtrailer), + url: trailerUrl, + }); + } + const deleteMedia = async () => { if (data?.mediaInfo?.id) { await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); @@ -123,17 +152,47 @@ const MovieDetails: React.FC = ({ movie }) => { }; const markAvailable = async (is4k = false) => { - await axios.get(`/api/v1/media/${data?.mediaInfo?.id}/available`, { - params: { - is4k, - }, + await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, { + is4k, }); revalidate(); }; + const region = user?.settings?.region + ? user.settings.region + : settings.currentSettings.region + ? settings.currentSettings.region + : 'US'; + const movieAttributes: React.ReactNode[] = []; + + if ( + data.releases.results.length && + (data.releases.results.find((r) => r.iso_3166_1 === region) + ?.release_dates[0].certification || + data.releases.results[0].release_dates[0].certification) + ) { + movieAttributes.push( + + {data.releases.results.find((r) => r.iso_3166_1 === region) + ?.release_dates[0].certification || + data.releases.results[0].release_dates[0].certification} + + ); + } + + if (data.runtime) { + movieAttributes.push( + intl.formatMessage(messages.runtime, { minutes: data.runtime }) + ); + } + + if (data.genres.length) { + movieAttributes.push(data.genres.map((g) => g.name).join(', ')); + } + return (
          = ({ movie }) => { />
          -
          - {data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( - - 0} - plexUrl={data.mediaInfo?.plexUrl} - plexUrl4k={data.mediaInfo?.plexUrl4k} - /> - - )} - +
          + 0} + status={data.mediaInfo?.status} + inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} plexUrl={data.mediaInfo?.plexUrl} - plexUrl4k={ - data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_MOVIE)) - ? data.mediaInfo.plexUrl4k - : undefined - } /> + {settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { + type: 'or', + } + ) && ( + + 0 + } + plexUrl4k={data.mediaInfo?.plexUrl4k} + /> + + )}

          {data.title}{' '} - ({data.releaseDate.slice(0, 4)}) + {data.releaseDate && ( + ({data.releaseDate.slice(0, 4)}) + )}

          - {(data.runtime ?? 0) > 0 && ( - <> - {' '} - |{' '} - - )} - {data.genres.map((g) => g.name).join(', ')} + {movieAttributes.length > 0 && + movieAttributes + .map((t, k) => {t}) + .reduce((prev, curr) => ( + <> + {prev} | {curr} + + ))}
          - {(trailerUrl || - data.mediaInfo?.plexUrl || - data.mediaInfo?.plexUrl4k) && ( - - - - - - - {data.mediaInfo?.plexUrl - ? intl.formatMessage(messages.playonplex) - : data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_MOVIE)) - ? intl.formatMessage(messages.playonplex) - : intl.formatMessage(messages.watchtrailer)} - - - } - onClick={() => { - if (data.mediaInfo?.plexUrl) { - window.open(data.mediaInfo?.plexUrl, '_blank'); - } else if (data.mediaInfo?.plexUrl4k) { - window.open(data.mediaInfo?.plexUrl4k, '_blank'); - } else if (trailerUrl) { - window.open(trailerUrl, '_blank'); - } - }} - > - {( - trailerUrl - ? data.mediaInfo?.plexUrl || - (data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_MOVIE))) - : data.mediaInfo?.plexUrl && - data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_MOVIE)) - ) ? ( - <> - {data.mediaInfo?.plexUrl && - data.mediaInfo?.plexUrl4k && - (hasPermission(Permission.REQUEST_4K) || - hasPermission(Permission.REQUEST_4K_MOVIE)) && ( - { - window.open(data.mediaInfo?.plexUrl4k, '_blank'); - }} - buttonType="ghost" - > - {intl.formatMessage(messages.play4konplex)} - - )} - {trailerUrl && ( - { - window.open(trailerUrl, '_blank'); - }} - buttonType="ghost" - > - {intl.formatMessage(messages.watchtrailer)} - - )} - - ) : null} - - )} +
          + +
          = ({ movie }) => {

          - + {intl.formatMessage(messages.overview)}

          {data.overview @@ -568,39 +543,39 @@ const MovieDetails: React.FC = ({ movie }) => {

          )}
          - {(data.voteCount > 0 || ratingData) && ( + {(!!data.voteCount || + (ratingData?.criticsRating && !!ratingData?.criticsScore) || + (ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
          - {ratingData?.criticsRating && - (ratingData?.criticsScore ?? 0) > 0 && ( - <> - - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - - - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && - (ratingData?.audienceScore ?? 0) > 0 && ( - <> - - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - - - {ratingData.audienceScore}% - - - )} - {data.voteCount > 0 && ( + {ratingData?.criticsRating && !!ratingData?.criticsScore && ( + <> + + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + + + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && !!ratingData?.audienceScore && ( + <> + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + + + {ratingData.audienceScore}% + + + )} + {!!data.voteCount && ( <> @@ -612,22 +587,24 @@ const MovieDetails: React.FC = ({ movie }) => { )}
          )} + {data.releaseDate && ( +
          + + {intl.formatMessage(messages.releasedate)} + + + + +
          + )}
          - - - - - -
          -
          - - + {intl.formatMessage(messages.status)} {data.status} @@ -636,7 +613,7 @@ const MovieDetails: React.FC = ({ movie }) => { {data.revenue > 0 && (
          - + {intl.formatMessage(messages.revenue)} = ({ movie }) => { {data.budget > 0 && (
          - + {intl.formatMessage(messages.budget)} = ({ movie }) => { ) && (
          - + {intl.formatMessage(messages.originallanguage)} { @@ -680,7 +657,7 @@ const MovieDetails: React.FC = ({ movie }) => { {data.productionCompanies[0] && (
          - + {intl.formatMessage(messages.studio)} {data.productionCompanies[0]?.name} @@ -700,45 +677,47 @@ const MovieDetails: React.FC = ({ movie }) => {
          -
          - -
          - ( - 0 && ( + <> + + ( + + ))} /> - ))} - /> + + )} void; } export const PermissionEdit: React.FC = ({ + actingUser, + currentUser, currentPermission, onUpdate, - user, }) => { const intl = useIntl(); @@ -106,18 +117,21 @@ export const PermissionEdit: React.FC = ({ name: intl.formatMessage(messages.request4k), description: intl.formatMessage(messages.request4kDescription), permission: Permission.REQUEST_4K, + requires: [{ permissions: [Permission.REQUEST] }], children: [ { id: 'request4k-movies', name: intl.formatMessage(messages.request4kMovies), description: intl.formatMessage(messages.request4kMoviesDescription), permission: Permission.REQUEST_4K_MOVIE, + requires: [{ permissions: [Permission.REQUEST] }], }, { id: 'request4k-tv', name: intl.formatMessage(messages.request4kTv), description: intl.formatMessage(messages.request4kTvDescription), permission: Permission.REQUEST_4K_TV, + requires: [{ permissions: [Permission.REQUEST] }], }, ], }, @@ -126,6 +140,7 @@ export const PermissionEdit: React.FC = ({ name: intl.formatMessage(messages.autoapprove), description: intl.formatMessage(messages.autoapproveDescription), permission: Permission.AUTO_APPROVE, + requires: [{ permissions: [Permission.REQUEST] }], children: [ { id: 'autoapprovemovies', @@ -134,6 +149,7 @@ export const PermissionEdit: React.FC = ({ messages.autoapproveMoviesDescription ), permission: Permission.AUTO_APPROVE_MOVIE, + requires: [{ permissions: [Permission.REQUEST] }], }, { id: 'autoapprovetv', @@ -142,6 +158,55 @@ export const PermissionEdit: React.FC = ({ messages.autoapproveSeriesDescription ), permission: Permission.AUTO_APPROVE_TV, + requires: [{ permissions: [Permission.REQUEST] }], + }, + ], + }, + { + id: 'autoapprove4k', + name: intl.formatMessage(messages.autoapprove4k), + description: intl.formatMessage(messages.autoapprove4kDescription), + permission: Permission.AUTO_APPROVE_4K, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_4K], + type: 'and', + }, + ], + children: [ + { + id: 'autoapprove4k-movies', + name: intl.formatMessage(messages.autoapprove4kMovies), + description: intl.formatMessage( + messages.autoapprove4kMoviesDescription + ), + permission: Permission.AUTO_APPROVE_4K_MOVIE, + requires: [ + { + permissions: [Permission.REQUEST], + }, + { + permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + type: 'or', + }, + ], + }, + { + id: 'autoapprove4k-tv', + name: intl.formatMessage(messages.autoapprove4kSeries), + description: intl.formatMessage( + messages.autoapprove4kSeriesDescription + ), + permission: Permission.AUTO_APPROVE_4K_TV, + requires: [ + { + permissions: [Permission.REQUEST], + }, + { + permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV], + type: 'or', + }, + ], }, ], }, @@ -153,7 +218,8 @@ export const PermissionEdit: React.FC = ({ onUpdate(newPermission)} /> diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index b6de4e39..37c807e8 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -8,35 +8,66 @@ export interface PermissionItem { description: string; permission: Permission; children?: PermissionItem[]; + requires?: PermissionRequirement[]; +} + +interface PermissionRequirement { + permissions: Permission[]; + type?: 'and' | 'or'; } interface PermissionOptionProps { option: PermissionItem; + actingUser?: User; + currentUser?: User; currentPermission: number; - user?: User; parent?: PermissionItem; onUpdate: (newPermissions: number) => void; } const PermissionOption: React.FC = ({ option, + actingUser, + currentUser, currentPermission, onUpdate, - user, parent, }) => { + const autoApprovePermissions = [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MOVIE, + Permission.AUTO_APPROVE_TV, + Permission.AUTO_APPROVE_4K, + Permission.AUTO_APPROVE_4K_MOVIE, + Permission.AUTO_APPROVE_4K_TV, + ]; + return ( <>
          + hasPermission(requirement.permissions, currentPermission, { + type: requirement.type ?? 'and', + }) + )) ? 'opacity-50' : '' }`} @@ -47,16 +78,28 @@ const PermissionOption: React.FC = ({ name="permissions" type="checkbox" disabled={ + (currentUser && currentUser.id === 1) || (option.permission !== Permission.ADMIN && hasPermission(Permission.ADMIN, currentPermission)) || + (autoApprovePermissions.includes(option.permission) && + hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || (!!parent?.permission && hasPermission(parent.permission, currentPermission)) || - (user && - user.id !== 1 && + (actingUser && + !hasPermission(Permission.ADMIN, actingUser.permissions) && option.permission === Permission.ADMIN) || - (user && - !hasPermission(Permission.MANAGE_SETTINGS, user.permissions) && - option.permission === Permission.MANAGE_SETTINGS) + (actingUser && + !hasPermission( + Permission.MANAGE_SETTINGS, + actingUser.permissions + ) && + option.permission === Permission.MANAGE_SETTINGS) || + (option.requires && + !option.requires.every((requirement) => + hasPermission(requirement.permissions, currentPermission, { + type: requirement.type ?? 'and', + }) + )) } onChange={() => { onUpdate( @@ -66,9 +109,20 @@ const PermissionOption: React.FC = ({ ); }} checked={ - hasPermission(option.permission, currentPermission) || - (!!parent?.permission && - hasPermission(parent.permission, currentPermission)) + (hasPermission(option.permission, currentPermission) || + (!!parent?.permission && + hasPermission(parent.permission, currentPermission)) || + (autoApprovePermissions.includes(option.permission) && + hasPermission( + Permission.MANAGE_REQUESTS, + currentPermission + ))) && + (!option.requires || + option.requires.every((requirement) => + hasPermission(requirement.permissions, currentPermission, { + type: requirement.type ?? 'and', + }) + )) } />
          diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 6b090bcf..2d365ed0 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -92,13 +92,10 @@ const PersonDetails: React.FC = () => {
          -
            +
              {sortedCast?.map((media, index) => { return ( -
            • +
            • {
          -
            +
              {sortedCrew?.map((media, index) => { return ( -
            • +
            • { <> {(sortedCrew || sortedCast) && ( -
              +
              { />
              )} -
              +
              {data.profilePath && (
              void; +} + +const RegionSelector: React.FC = ({ + name, + value, + onChange, +}) => { + const intl = useIntl(); + const { data: regions } = useSWR('/api/v1/regions'); + const [selectedRegion, setSelectedRegion] = useState(null); + + useEffect(() => { + if (regions && value) { + const matchedRegion = regions.find( + (region) => region.iso_3166_1 === value + ); + setSelectedRegion(matchedRegion ?? null); + } + }, [value, regions]); + + useEffect(() => { + if (onChange && regions) { + onChange(name, selectedRegion?.iso_3166_1 ?? ''); + } + }, [onChange, selectedRegion, name, regions]); + + return ( +
              +
              + + {({ open }) => ( +
              + + + {selectedRegion && ( + + {countryCodeEmoji(selectedRegion.iso_3166_1)} + + )} + + {selectedRegion + ? intl.formatDisplayName(selectedRegion.iso_3166_1, { + type: 'region', + }) + : intl.formatMessage(messages.regionDefault)} + + + + + + + + + + + + + {({ selected, active }) => ( +
              + + {intl.formatMessage(messages.regionDefault)} + + {selected && ( + + + + + + )} +
              + )} +
              + {regions?.map((region) => ( + + {({ selected, active }) => ( +
              + + {countryCodeEmoji(region.iso_3166_1)} + + + {intl.formatDisplayName(region.iso_3166_1, { + type: 'region', + fallback: 'none', + }) ?? region.english_name} + + {selected && ( + + + + + + )} +
              + )} +
              + ))} +
              +
              +
              + )} +
              +
              +
              + ); +}; + +export default RegionSelector; diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index aa925aef..28966d2f 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -30,7 +30,7 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { const updateRequest = async (type: 'approve' | 'decline'): Promise => { setIsUpdating(true); - await axios.get(`/api/v1/request/${request.id}/${type}`); + await axios.post(`/api/v1/request/${request.id}/${type}`); if (onUpdate) { onUpdate(); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 0f3ad6da..ce386878 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -83,7 +83,7 @@ const RequestButton: React.FC = ({ request: MediaRequest, type: 'approve' | 'decline' ) => { - const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + const response = await axios.post(`/api/v1/request/${request.id}/${type}`); if (response) { onUpdate(); @@ -100,7 +100,7 @@ const RequestButton: React.FC = ({ await Promise.all( requests.map(async (request) => { - return axios.get(`/api/v1/request/${request.id}/${type}`); + return axios.post(`/api/v1/request/${request.id}/${type}`); }) ); diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 858e8e46..b65df670 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useInView } from 'react-intersection-observer'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { TvDetails } from '../../../server/models/Tv'; @@ -37,9 +37,10 @@ const RequestCardPlaceholder: React.FC = () => { interface RequestCardProps { request: MediaRequest; + onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; } -const RequestCard: React.FC = ({ request }) => { +const RequestCard: React.FC = ({ request, onTitleData }) => { const { ref, inView } = useInView({ triggerOnce: true, }); @@ -62,13 +63,19 @@ const RequestCard: React.FC = ({ request }) => { }); const modifyRequest = async (type: 'approve' | 'decline') => { - const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + const response = await axios.post(`/api/v1/request/${request.id}/${type}`); if (response) { revalidate(); } }; + useEffect(() => { + if (title && onTitleData) { + onTitleData(request.id, title); + } + }, [title, onTitleData, request]); + if (!title && !error) { return (
              @@ -105,16 +112,18 @@ const RequestCard: React.FC = ({ request }) => { {isMovie(title) ? title.title : title.name} -
              - - - {requestData.requestedBy.displayName} - -
              + + + + + {requestData.requestedBy.displayName} + + + {requestData.media.status && (
              = ({ const [isRetrying, setRetrying] = useState(false); const modifyRequest = async (type: 'approve' | 'decline') => { - const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + const response = await axios.post(`/api/v1/request/${request.id}/${type}`); if (response) { revalidate(); @@ -162,16 +162,18 @@ const RequestItem: React.FC = ({ {isMovie(title) ? title.title : title.name} -
              - - - {requestData.requestedBy.displayName} - -
              + + + + + {requestData.requestedBy.displayName} + + + {requestData.seasons.length > 0 && (
              @@ -188,7 +190,8 @@ const RequestItem: React.FC = ({
              - {requestData.media.status === MediaStatus.UNKNOWN || + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN || requestData.status === MediaRequestStatus.DECLINED ? ( {requestData.status === MediaRequestStatus.DECLINED @@ -245,7 +248,8 @@ const RequestItem: React.FC = ({
              - {requestData.media.status === MediaStatus.UNKNOWN && + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN && requestData.status !== MediaRequestStatus.DECLINED && hasPermission(Permission.MANAGE_REQUESTS) && ( +
              + + {intl.formatMessage(messages.resultsperpage, { + pageSize: ( + + ), + })} +
              +
              + + + + + ) : ( + { + const response = await axios.post( + `/api/v1/auth/reset-password`, + { + email: values.email, + } + ); + + if (response.status === 200) { + setSubmitted(true); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( +
              +
              + +
              +
              + +
              + {errors.email && touched.email && ( +
              {errors.email}
              + )} +
              +
              +
              +
              + + + +
              +
              +
              + ); + }} +
              + )} +
              +
              +
              +
              + ); +}; + +export default ResetPassword; diff --git a/src/components/ResetPassword/index.tsx b/src/components/ResetPassword/index.tsx new file mode 100644 index 00000000..6eaeef43 --- /dev/null +++ b/src/components/ResetPassword/index.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import ImageFader from '../Common/ImageFader'; +import { defineMessages, useIntl } from 'react-intl'; +import LanguagePicker from '../Layout/LanguagePicker'; +import Button from '../Common/Button'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import axios from 'axios'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; + +const messages = defineMessages({ + resetpassword: 'Reset Password', + password: 'Password', + confirmpassword: 'Confirm Password', + validationpasswordrequired: 'You must provide a password', + validationpasswordmatch: 'Password must match', + validationpasswordminchars: + 'Password is too short; should be a minimum of 8 characters', + gobacklogin: 'Go Back to Sign-In Page', + resetpasswordsuccessmessage: + 'If the link is valid and is connected to a user then the password has been reset.', +}); + +const ResetPassword: React.FC = () => { + const intl = useIntl(); + const router = useRouter(); + const [hasSubmitted, setSubmitted] = useState(false); + + const guid = router.query.guid; + + const ResetSchema = Yup.object().shape({ + password: Yup.string() + .required(intl.formatMessage(messages.validationpasswordrequired)) + .min(8, intl.formatMessage(messages.validationpasswordminchars)), + confirmPassword: Yup.string() + .required(intl.formatMessage(messages.validationpasswordmatch)) + .test( + 'passwords-match', + intl.formatMessage(messages.validationpasswordmatch), + function (value) { + return this.parent.password === value; + } + ), + }); + + return ( +
              + +
              + +
              +
              + Overseerr Logo +

              + {intl.formatMessage(messages.resetpassword)} +

              +
              +
              +
              +
              + {hasSubmitted ? ( + <> +

              + {intl.formatMessage(messages.resetpasswordsuccessmessage)} +

              + + + + + + + ) : ( + { + const response = await axios.post( + `/api/v1/auth/reset-password/${guid}`, + { + password: values.password, + } + ); + + if (response.status === 200) { + setSubmitted(true); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( +
              +
              + +
              +
              + +
              + {errors.password && touched.password && ( +
              {errors.password}
              + )} +
              + +
              +
              + +
              + {errors.confirmPassword && + touched.confirmPassword && ( +
              + {errors.confirmPassword} +
              + )} +
              +
              +
              +
              + + + +
              +
              +
              + ); + }} +
              + )} +
              +
              +
              +
              + ); +}; + +export default ResetPassword; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d4cea972..151fc922 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -11,6 +11,7 @@ import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; import PageTitle from '../Common/PageTitle'; +import Error from '../../pages/_error'; const messages = defineMessages({ search: 'Search', @@ -53,7 +54,7 @@ const Search: React.FC = () => { }; if (error) { - return
              {error}
              ; + return ; } const titles = data?.reduce( diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index 8d879acf..fcd67943 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -15,7 +15,7 @@ const messages = defineMessages({ agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks', - discordsettingssaved: 'Discord notification settings saved!', + discordsettingssaved: 'Discord notification settings saved successfully!', discordsettingsfailed: 'Discord notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', @@ -117,7 +117,11 @@ const NotificationsDiscord: React.FC = () => { )}
              -
              +
              {intl.formatMessage(messages.notificationtypes)} diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index 36926c55..ad9313f1 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -8,6 +8,7 @@ import axios from 'axios'; import * as Yup from 'yup'; import { useToasts } from 'react-toast-notifications'; import NotificationTypeSelector from '../../NotificationTypeSelector'; +import Alert from '../../Common/Alert'; const messages = defineMessages({ save: 'Save Changes', @@ -21,7 +22,7 @@ const messages = defineMessages({ enableSsl: 'Enable SSL', authUser: 'SMTP Username', authPass: 'SMTP Password', - emailsettingssaved: 'Email notification settings saved!', + emailsettingssaved: 'Email notification settings saved successfully!', emailsettingsfailed: 'Email notification settings failed to save.', test: 'Test', testsent: 'Test notification sent!', @@ -31,6 +32,10 @@ const messages = defineMessages({ senderName: 'Sender Name', notificationtypes: 'Notification Types', validationEmail: 'You must provide a valid email address', + emailNotificationTypesAlert: 'Notification Email Recipients', + emailNotificationTypesAlertDescription: + 'For the "Media Requested" and "Media Failed" notification types,\ + notifications will only be sent to users with the "Manage Requests" permission.', }); const NotificationsEmail: React.FC = () => { @@ -124,178 +129,193 @@ const NotificationsEmail: React.FC = () => { }; return ( -
              -
              - -
              - -
              -
              -
              - -
              -
              - -
              - {errors.emailFrom && touched.emailFrom && ( -
              {errors.emailFrom}
              - )} -
              -
              -
              - -
              -
              - -
              -
              -
              -
              - -
              -
              - -
              - {errors.smtpHost && touched.smtpHost && ( -
              {errors.smtpHost}
              - )} -
              -
              -
              - -
              -
              - -
              - {errors.smtpPort && touched.smtpPort && ( -
              {errors.smtpPort}
              - )} -
              -
              -
              - -
              - -
              -
              -
              - -
              - -
              -
              -
              - -
              -
              - -
              -
              -
              -
              - -
              -
              - -
              -
              -
              -
              + <> + + {intl.formatMessage( + messages.emailNotificationTypesAlertDescription + )} + +
              - - {intl.formatMessage(messages.notificationtypes)} - +
              -
              - setFieldValue('types', newTypes)} + +
              +
              +
              + +
              +
              + +
              + {errors.emailFrom && touched.emailFrom && ( +
              {errors.emailFrom}
              + )} +
              +
              +
              + +
              +
              +
              -
              -
              -
              - - - - - - +
              + +
              +
              + +
              + {errors.smtpHost && touched.smtpHost && ( +
              {errors.smtpHost}
              + )} +
              -
              - +
              + +
              +
              + +
              + {errors.smtpPort && touched.smtpPort && ( +
              {errors.smtpPort}
              + )} +
              +
              +
              + +
              + +
              +
              +
              + +
              + +
              +
              +
              + +
              +
              + +
              +
              +
              +
              + +
              +
              + +
              +
              +
              +
              +
              + + {intl.formatMessage(messages.notificationtypes)} + +
              +
              + + setFieldValue('types', newTypes) + } + /> +
              +
              +
              +
              +
              +
              + + + + + + +
              +
              + + ); }} diff --git a/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx new file mode 100644 index 00000000..5fbc053e --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsPushbullet/index.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { Field, Form, Formik } from 'formik'; +import useSWR from 'swr'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import Button from '../../../Common/Button'; +import { defineMessages, useIntl } from 'react-intl'; +import axios from 'axios'; +import * as Yup from 'yup'; +import { useToasts } from 'react-toast-notifications'; +import Alert from '../../../Common/Alert'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving…', + agentEnabled: 'Enable Agent', + accessToken: 'Access Token', + validationAccessTokenRequired: 'You must provide an access token', + pushbulletSettingsSaved: + 'Pushbullet notification settings saved successfully!', + pushbulletSettingsFailed: 'Pushbullet notification settings failed to save.', + testSent: 'Test notification sent!', + test: 'Test', + settingUpPushbullet: 'Setting Up Pushbullet Notifications', + settingUpPushbulletDescription: + 'To configure Pushbullet notifications, you will need to create an access token and enter it below.', + notificationTypes: 'Notification Types', +}); + +const NotificationsPushbullet: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/pushbullet' + ); + + const NotificationsPushbulletSchema = Yup.object().shape({ + accessToken: Yup.string().required( + intl.formatMessage(messages.validationAccessTokenRequired) + ), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/pushbullet', { + enabled: values.enabled, + types: values.types, + options: { + accessToken: values.accessToken, + }, + }); + addToast(intl.formatMessage(messages.pushbulletSettingsSaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.pushbulletSettingsFailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/pushbullet/test', { + enabled: true, + types: values.types, + options: { + accessToken: values.accessToken, + }, + }); + + addToast(intl.formatMessage(messages.testSent), { + appearance: 'info', + autoDismiss: true, + }); + }; + + return ( + <> + + {intl.formatMessage(messages.settingUpPushbulletDescription, { + CreateAccessTokenLink: function CreateAccessTokenLink(msg) { + return ( + + {msg} + + ); + }, + })} + +
              +
              + +
              + +
              +
              +
              + +
              +
              + +
              + {errors.accessToken && touched.accessToken && ( +
              {errors.accessToken}
              + )} +
              +
              +
              +
              + + {intl.formatMessage(messages.notificationTypes)} + +
              +
              + + setFieldValue('types', newTypes) + } + /> +
              +
              +
              +
              +
              +
              + + + + + + +
              +
              +
              + + ); + }} +
              + ); +}; + +export default NotificationsPushbullet; diff --git a/src/components/Settings/Notifications/NotificationsPushover/index.tsx b/src/components/Settings/Notifications/NotificationsPushover/index.tsx index 3456bff2..f86746d0 100644 --- a/src/components/Settings/Notifications/NotificationsPushover/index.tsx +++ b/src/components/Settings/Notifications/NotificationsPushover/index.tsx @@ -14,19 +14,19 @@ const messages = defineMessages({ save: 'Save Changes', saving: 'Saving…', agentenabled: 'Enable Agent', - accessToken: 'Access Token', - userToken: 'User Token', - validationAccessTokenRequired: 'You must provide an access token.', - validationUserTokenRequired: 'You must provide a user token.', - pushoversettingssaved: 'Pushover notification settings saved!', + accessToken: 'Application/API Token', + userToken: 'User Key', + validationAccessTokenRequired: 'You must provide a valid application token', + validationUserTokenRequired: 'You must provide a valid user key', + pushoversettingssaved: 'Pushover notification settings saved successfully!', pushoversettingsfailed: 'Pushover notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', settinguppushover: 'Setting Up Pushover Notifications', settinguppushoverDescription: - 'To setup Pushover you need to register an application and get the access token.\ - When setting up the application you can use one of the icons in the public folder on github.\ - You also need the pushover user token which can be found on the start page when you log in.', + 'To configure Pushover notifications, you will need to register an application and enter the API key below.\ + (You can use one of our official icons on GitHub.)\ + You will need also need your user key.', notificationtypes: 'Notification Types', }); @@ -38,12 +38,18 @@ const NotificationsPushover: React.FC = () => { ); const NotificationsPushoverSchema = Yup.object().shape({ - accessToken: Yup.string().required( - intl.formatMessage(messages.validationAccessTokenRequired) - ), - userToken: Yup.string().required( - intl.formatMessage(messages.validationUserTokenRequired) - ), + accessToken: Yup.string() + .required(intl.formatMessage(messages.validationAccessTokenRequired)) + .matches( + /^a[A-Za-z0-9]{29}$/, + intl.formatMessage(messages.validationAccessTokenRequired) + ), + userToken: Yup.string() + .required(intl.formatMessage(messages.validationUserTokenRequired)) + .matches( + /^[ug][A-Za-z0-9]{29}$/, + intl.formatMessage(messages.validationUserTokenRequired) + ), }); if (!data && !error) { @@ -178,7 +184,11 @@ const NotificationsPushover: React.FC = () => { )}
              -
              +
              {intl.formatMessage(messages.notificationtypes)} diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx index b4297bd4..98b66adb 100644 --- a/src/components/Settings/Notifications/NotificationsSlack/index.tsx +++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx @@ -15,14 +15,13 @@ const messages = defineMessages({ saving: 'Saving…', agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', - webhookUrlPlaceholder: 'Webhook URL', - slacksettingssaved: 'Slack notification settings saved!', + slacksettingssaved: 'Slack notification settings saved successfully!', slacksettingsfailed: 'Slack notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', settingupslack: 'Setting Up Slack Notifications', settingupslackDescription: - 'To use Slack notifications, you will need to create an Incoming Webhook integration and use the provided webhook URL below.', + 'To configure Slack notifications, you will need to create an Incoming Webhook integration and enter the webhook URL below.', notificationtypes: 'Notification Types', validationWebhookUrl: 'You must provide a valid URL', }); @@ -131,21 +130,18 @@ const NotificationsSlack: React.FC = () => {
              - +
              {errors.webhookUrl && touched.webhookUrl && (
              {errors.webhookUrl}
              )}
              -
              +
              {intl.formatMessage(messages.notificationtypes)} diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index 40c34b6b..bc626fd5 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -14,20 +14,22 @@ const messages = defineMessages({ save: 'Save Changes', saving: 'Saving…', agentenabled: 'Enable Agent', - botAPI: 'Bot API', + botAPI: 'Bot Authentication Token', chatId: 'Chat ID', - validationBotAPIRequired: 'You must provide a Bot API key.', - validationChatIdRequired: 'You must provide a Chat ID.', - telegramsettingssaved: 'Telegram notification settings saved!', + validationBotAPIRequired: 'You must provide a bot authentication token', + validationChatIdRequired: 'You must provide a valid chat ID', + telegramsettingssaved: 'Telegram notification settings saved successfully!', telegramsettingsfailed: 'Telegram notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', settinguptelegram: 'Setting Up Telegram Notifications', settinguptelegramDescription: - 'To setup Telegram you need to create a bot and get the bot API key.\ - Additionally, you need the chat ID for the chat you want the bot to send notifications to.\ - You can do this by adding @get_id_bot to the chat or group chat.', + 'To configure Telegram notifications, you will need to create a bot and get the bot API key.\ + Additionally, you will need the chat ID for the chat to which you would like to send notifications.\ + You can get this by adding @get_id_bot to the chat.', notificationtypes: 'Notification Types', + sendSilently: 'Send Silently', + sendSilentlyTip: 'Send notifications with no sound', }); const NotificationsTelegram: React.FC = () => { @@ -41,9 +43,9 @@ const NotificationsTelegram: React.FC = () => { botAPI: Yup.string().required( intl.formatMessage(messages.validationBotAPIRequired) ), - chatId: Yup.string().required( - intl.formatMessage(messages.validationChatIdRequired) - ), + chatId: Yup.string() + .required(intl.formatMessage(messages.validationChatIdRequired)) + .matches(/^\d+$/, intl.formatMessage(messages.validationChatIdRequired)), }); if (!data && !error) { @@ -57,6 +59,7 @@ const NotificationsTelegram: React.FC = () => { types: data?.types, botAPI: data?.options.botAPI, chatId: data?.options.chatId, + sendSilently: data?.options.sendSilently, }} validationSchema={NotificationsTelegramSchema} onSubmit={async (values) => { @@ -67,6 +70,7 @@ const NotificationsTelegram: React.FC = () => { options: { botAPI: values.botAPI, chatId: values.chatId, + sendSilently: values.sendSilently, }, }); addToast(intl.formatMessage(messages.telegramsettingssaved), { @@ -91,6 +95,7 @@ const NotificationsTelegram: React.FC = () => { options: { botAPI: values.botAPI, chatId: values.chatId, + sendSilently: values.sendSilently, }, }); @@ -178,7 +183,26 @@ const NotificationsTelegram: React.FC = () => { )}
              -
              +
              + +
              + +
              +
              +
              {intl.formatMessage(messages.notificationtypes)} diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index a0f89679..e1a63fca 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -29,6 +29,9 @@ const defaultPayload = { status4k: '{{media_status4k}}', }, '{{extra}}': [], + '{{request}}': { + request_id: '{{request_id}}', + }, }; const messages = defineMessages({ @@ -37,16 +40,15 @@ const messages = defineMessages({ agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', authheader: 'Authorization Header', - validationJsonPayloadRequired: 'You must provide a JSON Payload', - webhookUrlPlaceholder: 'Remote webhook URL', - webhooksettingssaved: 'Webhook notification settings saved!', + validationJsonPayloadRequired: 'You must provide a valid JSON payload', + webhooksettingssaved: 'Webhook notification settings saved successfully!', webhooksettingsfailed: 'Webhook notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', notificationtypes: 'Notification Types', - resetPayload: 'Reset to Default JSON Payload', - resetPayloadSuccess: 'JSON reset to default payload.', - customJson: 'Custom JSON Payload', + resetPayload: 'Reset to Default', + resetPayloadSuccess: 'JSON payload reset successfully!', + customJson: 'JSON Payload', templatevariablehelp: 'Template Variable Help', validationWebhookUrl: 'You must provide a valid URL', }); @@ -68,14 +70,18 @@ const NotificationsWebhook: React.FC = () => { ), jsonPayload: Yup.string() .required(intl.formatMessage(messages.validationJsonPayloadRequired)) - .test('validate-json', 'Invalid JSON', (value) => { - try { - JSON.parse(value ?? ''); - return true; - } catch (e) { - return false; + .test( + 'validate-json', + intl.formatMessage(messages.validationJsonPayloadRequired), + (value) => { + try { + JSON.parse(value ?? ''); + return true; + } catch (e) { + return false; + } } - }), + ), }); if (!data && !error) { @@ -170,14 +176,7 @@ const NotificationsWebhook: React.FC = () => {
              - +
              {errors.webhookUrl && touched.webhookUrl && (
              {errors.webhookUrl}
              @@ -257,7 +256,11 @@ const NotificationsWebhook: React.FC = () => {
              -
              +
              diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index eba9d198..967bb7ee 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -342,7 +342,7 @@ const RadarrModal: React.FC = ({
              @@ -367,7 +367,7 @@ const RadarrModal: React.FC = ({
              @@ -394,7 +394,7 @@ const RadarrModal: React.FC = ({
              = ({ name="port" type="text" placeholder="7878" - className="port" + className="short" onChange={(e: React.ChangeEvent) => { setIsValidated(false); setFieldValue('port', e.target.value); @@ -432,7 +432,7 @@ const RadarrModal: React.FC = ({
              @@ -481,7 +481,7 @@ const RadarrModal: React.FC = ({
              @@ -519,7 +519,7 @@ const RadarrModal: React.FC = ({
              @@ -555,7 +555,7 @@ const RadarrModal: React.FC = ({
              diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 0a3c87d9..de725b88 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -21,7 +21,7 @@ const messages = defineMessages({ runnow: 'Run Now', canceljob: 'Cancel Job', jobstarted: '{jobname} started.', - jobcancelled: '{jobname} cancelled.', + jobcancelled: '{jobname} canceled.', process: 'Process', command: 'Command', cache: 'Cache', @@ -63,7 +63,7 @@ const SettingsJobs: React.FC = () => { } const runJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/run`); + await axios.post(`/api/v1/settings/jobs/${job.id}/run`); addToast( intl.formatMessage(messages.jobstarted, { jobname: job.name, @@ -77,7 +77,7 @@ const SettingsJobs: React.FC = () => { }; const cancelJob = async (job: Job) => { - await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); + await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`); addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { appearance: 'error', autoDismiss: true, @@ -86,7 +86,7 @@ const SettingsJobs: React.FC = () => { }; const flushCache = async (cache: CacheItem) => { - await axios.get(`/api/v1/settings/cache/${cache.id}/flush`); + await axios.post(`/api/v1/settings/cache/${cache.id}/flush`); addToast( intl.formatMessage(messages.cacheflushed, { cachename: cache.name }), { diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 3a49889d..ce551774 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -122,20 +122,18 @@ const SettingsLayout: React.FC = ({ children }) => { ))}
              -
              -
              - -
              +
              +
              {children}
              diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index 3d51ef2e..83008652 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -1,7 +1,7 @@ import React from 'react'; import useSWR from 'swr'; import LoadingSpinner from '../Common/LoadingSpinner'; -import type { MainSettings } from '../../../server/lib/settings'; +import type { MainSettings, Language } from '../../../server/lib/settings'; import CopyButton from './CopyButton'; import { Form, Formik, Field } from 'formik'; import axios from 'axios'; @@ -13,6 +13,7 @@ import Badge from '../Common/Badge'; import globalMessages from '../../i18n/globalMessages'; import PermissionEdit from '../PermissionEdit'; import * as Yup from 'yup'; +import RegionSelector from '../RegionSelector'; const messages = defineMessages({ generalsettings: 'General Settings', @@ -23,6 +24,12 @@ const messages = defineMessages({ apikey: 'API Key', applicationTitle: 'Application Title', applicationurl: 'Application URL', + region: 'Discover Region', + regionTip: + 'Filter content by region (only applies to the "Popular" and "Upcoming" categories)', + originallanguage: 'Discover Language', + originallanguageTip: + 'Filter content by original language (only applies to the "Popular" and "Upcoming" categories)', toastApiKeySuccess: 'New API key generated!', toastApiKeyFailure: 'Something went wrong while generating a new API key.', toastSettingsSuccess: 'Settings successfully saved!', @@ -41,6 +48,7 @@ const messages = defineMessages({ validationApplicationTitle: 'You must provide an application title', validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', + originalLanguageDefault: 'All Languages', }); const SettingsMain: React.FC = () => { @@ -50,6 +58,9 @@ const SettingsMain: React.FC = () => { const { data, error, revalidate } = useSWR( '/api/v1/settings/main' ); + const { data: languages, error: languagesError } = useSWR( + '/api/v1/languages' + ); const MainSettingsSchema = Yup.object().shape({ applicationTitle: Yup.string().required( intl.formatMessage(messages.validationApplicationTitle) @@ -70,7 +81,7 @@ const SettingsMain: React.FC = () => { const regenerate = async () => { try { - await axios.get('/api/v1/settings/main/regenerate'); + await axios.post('/api/v1/settings/main/regenerate'); revalidate(); addToast(intl.formatMessage(messages.toastApiKeySuccess), { @@ -85,7 +96,7 @@ const SettingsMain: React.FC = () => { } }; - if (!data && !error) { + if (!data && !error && !languages && !languagesError) { return ; } @@ -108,6 +119,8 @@ const SettingsMain: React.FC = () => { defaultPermissions: data?.defaultPermissions ?? 0, hideAvailable: data?.hideAvailable, localLogin: data?.localLogin, + region: data?.region, + originalLanguage: data?.originalLanguage, trustProxy: data?.trustProxy, }} enableReinitialize @@ -121,6 +134,8 @@ const SettingsMain: React.FC = () => { defaultPermissions: values.defaultPermissions, hideAvailable: values.hideAvailable, localLogin: values.localLogin, + region: values.region, + originalLanguage: values.originalLanguage, trustProxy: values.trustProxy, }); @@ -263,6 +278,53 @@ const SettingsMain: React.FC = () => { />
              +
              + +
              + +
              +
              +
              + +
              +
              + + + {languages?.map((language) => ( + + ))} + +
              +
              +