diff --git a/.all-contributorsrc b/.all-contributorsrc index e2d49912..d19b5861 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -245,6 +245,25 @@ "contributions": [ "doc" ] + }, + { + "login": "hirenshah", + "name": "hirenshah", + "avatar_url": "https://avatars2.githubusercontent.com/u/418112?v=4", + "profile": "https://github.com/hirenshah", + "contributions": [ + "doc" + ] + }, + { + "login": "TheCatLady", + "name": "TheCatLady", + "avatar_url": "https://avatars0.githubusercontent.com/u/52870424?v=4", + "profile": "https://github.com/TheCatLady", + "contributions": [ + "code", + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f96b3b3..328044dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,8 @@ on: jobs: test: - runs-on: ubuntu-18.04 + name: Lint & Test Build + runs-on: ubuntu-20.04 container: node:12.18-alpine steps: - name: checkout @@ -24,10 +25,10 @@ jobs: - name: build run: yarn build build_and_push: - name: Build and push Docker image to Docker Hub + name: Build & Publish to Docker Hub needs: test - if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci') - runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-20.04 steps: - name: Checkout uses: actions/checkout@v2 @@ -59,3 +60,28 @@ jobs: sctx/overseerr:${{ github.sha }} ghcr.io/sct/overseerr:develop ghcr.io/sct/overseerr:${{ github.sha }} + discord: + name: Send Discord Notification + needs: build_and_push + runs-on: ubuntu-20.04 + steps: + - name: Get Build Job Status + uses: technote-space/workflow-conclusion-action@v1 + + - name: Combine Job Status + id: status + run: | + failures=(neutral, skipped, timed_out, action_required) + if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then + echo ::set-output name=status::failure + else + echo ::set-output name=status::$WORKFLOW_CONCLUSION + fi + + - name: Post Status to Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ steps.status.outputs.status }} + title: ${{ github.workflow }} + nofail: true diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml index 647cdeca..f8be80fe 100644 --- a/.github/workflows/invalid_template.yml +++ b/.github/workflows/invalid_template.yml @@ -6,7 +6,7 @@ on: jobs: support: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - uses: dessant/support-requests@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff66667..08ed8225 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 container: node:12.18-alpine steps: - name: checkout @@ -23,7 +23,7 @@ jobs: semantic-release: name: Tag and release latest version needs: test - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout uses: actions/checkout@v2 @@ -41,3 +41,27 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: npx semantic-release + discord: + name: Send Discord Notification + runs-on: ubuntu-20.04 + steps: + - name: Get Build Job Status + uses: technote-space/workflow-conclusion-action@v1 + + - name: Combine Job Status + id: status + run: | + failures=(neutral, skipped, timed_out, action_required) + if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then + echo ::set-output name=status::failure + else + echo ::set-output name=status::$WORKFLOW_CONCLUSION + fi + + - name: Post Status to Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ steps.status.outputs.status }} + title: ${{ github.workflow }} + nofail: true diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml new file mode 100644 index 00000000..4d415ae3 --- /dev/null +++ b/.github/workflows/snap.yaml @@ -0,0 +1,102 @@ +name: Publish Snap + +on: + push: + branches: [develop] + tags: [v*] + pull_request: ~ + +jobs: + test: + name: Lint & Test Build + runs-on: ubuntu-20.04 + if: "!contains(github.event.head_commit.message, '[skip ci]')" + container: node:12.18-alpine + steps: + - name: checkout + uses: actions/checkout@v2 + - name: install dependencies + env: + HUSKY_SKIP_INSTALL: 1 + run: yarn + - name: lint + run: yarn lint + - name: build + run: yarn build + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: test + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@v2 + + - name: Prepare + id: prepare + run: | + git fetch --prune --unshallow --tags + if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=RELEASE::stable + else + echo ::set-output name=RELEASE::edge + fi + + - name: Set Up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Build Snap Package + uses: diddlesnaps/snapcraft-multiarch-action@v1 + id: build + with: + architecture: ${{ matrix.architecture }} + + - name: Upload Snap Package + uses: actions/upload-artifact@v2 + with: + name: overseerr-snap-package-${{ matrix.architecture }} + path: ${{ steps.build.outputs.snap }} + + - name: Review Snap Package + uses: diddlesnaps/snapcraft-review-tools-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + + - name: Publish Snap Package + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_LOGIN }} + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.prepare.outputs.RELEASE }} + + discord: + name: Send Discord Notification + needs: build-snap + runs-on: ubuntu-20.04 + steps: + - name: Get Build Job Status + uses: technote-space/workflow-conclusion-action@v1 + + - name: Combine Job Status + id: status + run: | + failures=(neutral, skipped, timed_out, action_required) + if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then + echo ::set-output name=status::failure + else + echo ::set-output name=status::$WORKFLOW_CONCLUSION + fi + + - name: Post Status to Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ steps.status.outputs.status }} + title: ${{ github.workflow }} + nofail: true diff --git a/.github/workflows/support.yml b/.github/workflows/support.yml index 16566885..4e9311ec 100644 --- a/.github/workflows/support.yml +++ b/.github/workflows/support.yml @@ -6,7 +6,7 @@ on: jobs: support: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - uses: dessant/support-requests@v2 with: diff --git a/.gitignore b/.gitignore index 379afe40..968e5492 100644 --- a/.gitignore +++ b/.gitignore @@ -32,13 +32,16 @@ yarn-error.log* .vercel # database -config/db/db.sqlite3 +config/db/*.sqlite3 config/settings.json # logs config/logs/*.log* config/logs/*.json +# anidb mapping file +config/anime-list.xml + # dist files dist diff --git a/README.md b/README.md index 179a3124..5b757499 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -36,14 +36,13 @@ - User profiles. - User settings page (to give users the ability to modify their Overseerr experience to their liking). -- 4K requests (Includes multi-radarr/sonarr management for media) +- Local user system (for those who don't use Plex). ## Planned Features - More notification types. - Issues system. This will allow users to report issues with content on your media server. -- Local user system (for those who don't use Plex). -- Compatibility APIs (to work with existing tools in your system). +- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested. ## Getting Started @@ -71,7 +70,7 @@ After running Overseerr for the first time, configure it by visiting the web UI ## Preview - + ## Support @@ -105,41 +104,44 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - + + + + + + +

sct

💻 🎨 🤔

Alex Zoitos

💻

Brandon Cohen

💻 📖

Ahreluth

🌍

KovalevArtem

🌍

GiyomuWeb

🌍

Angry Cuban

📖

sct

💻 🎨 🤔

Alex Zoitos

💻

Brandon Cohen

💻 📖

Ahreluth

🌍

KovalevArtem

🌍

GiyomuWeb

🌍

Angry Cuban

📖

jvennik

🌍

darknessgp

💻

salty

🚇

Shutruk

🌍

Krystian Charubin

🎨

Kieron Boswell

💻

samwiseg0

💬 🚇

jvennik

🌍

darknessgp

💻

salty

🚇

Shutruk

🌍

Krystian Charubin

🎨

Kieron Boswell

💻

samwiseg0

💬 🚇

ecelebi29

💻 📖

Mārtiņš Možeiko

💻

mazzetta86

🌍

Paul Hagedorn

🌍

Shagon94

🌍

sebstrgg

🌍

Danshil Mungur

💻 📖

ecelebi29

💻 📖

Mārtiņš Možeiko

💻

mazzetta86

🌍

Paul Hagedorn

🌍

Shagon94

🌍

sebstrgg

🌍

Danshil Mungur

💻 📖

doob187

🚇

johnpyp

💻

Jakob Ankarhem

📖 💻

Jayesh

💻

flying-sausages

📖

doob187

🚇

johnpyp

💻

Jakob Ankarhem

📖 💻

Jayesh

💻

flying-sausages

📖

hirenshah

📖

TheCatLady

💻 🌍
- + + diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 6b98b1a5..1685fc9b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,17 +1,21 @@ # Table of contents -* [Introduction](README.md) +- [Introduction](README.md) ## Getting Started -* [Installation](getting-started/installation.md) +- [Installation](getting-started/installation.md) + +## Using Overseerr + +- [Notifications](using-overseerr/notifications/README.md) + - [Custom Webhooks](using-overseerr/notifications/webhooks.md) ## Support -* [Frequently Asked Questions](support/faq.md) -* [Asking for Support](support/asking-for-support.md) +- [Frequently Asked Questions](support/faq.md) +- [Asking for Support](support/asking-for-support.md) ## Extending Overseerr -* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) - +- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md index b69e9504..160000fb 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy-examples.md @@ -8,11 +8,13 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomai ### Subdomain -Place in the `proxy-confs` folder as `overseerr.subdomain.conf` +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: -```text +```nginx server { listen 443 ssl http2; listen [::]:443 ssl http2; @@ -112,8 +114,8 @@ server { add_header Referrer-Policy "no-referrer"; # HTTP Strict Transport Security add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; - # Reduce XSS risks (Content-Security-Policy) - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always; + # Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary + # add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always; # Prevent some categories of XSS attacks (X-XSS-Protection) add_header X-XSS-Protection "1; mode=block" always; # Provide clickjacking protection (X-Frame-Options) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6c8c38c7..723afd03 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -12,6 +12,7 @@ After running Overseerr for the first time, configure it by visiting the web UI {% tabs %} {% tab title="Basic" %} + ```bash docker run -d \ -e LOG_LEVEL=info \ @@ -21,9 +22,11 @@ docker run -d \ --restart unless-stopped \ sctx/overseerr ``` + {% endtab %} {% tab title="UID/GID" %} + ```text docker run -d \ --user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \ @@ -34,9 +37,11 @@ docker run -d \ --restart unless-stopped \ sctx/overseerr ``` + {% endtab %} {% tab title="Manual Update" %} + ```text # Stop the Overseerr container docker stop overseerr @@ -50,6 +55,7 @@ docker pull sctx/overseerr # Run the Overseerr container with the same parameters as before docker run -d ... ``` + {% endtab %} {% endtabs %} @@ -70,7 +76,7 @@ Use a 3rd party updating mechanism such as [Watchtower](https://github.com/conta Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation. {% hint style="danger" %} -**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed! Details below.** +**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed!** {% endhint %} ```bash @@ -81,116 +87,74 @@ docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/her Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases. {% endhint %} -## Linux \(Unsupported\) +## Linux + +{% hint style="info" %} +The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd). +{% endhint %} + +**To install:** + +``` +sudo snap install overseerr +``` + +**Updating:** +Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command. + +``` +sudo snap refresh +``` + +**To install the development build:** + +``` +sudo snap install overseerr --edge +``` + +{% hint style="danger" %} +This version can break any moment. Be prepared to troubleshoot any issues that arise! +{% endhint %} + +## Third Party + {% tabs %} -{% tab title="Ubuntu 16.04+/Debian" %} -{% hint style="danger" %} -This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker. -{% endhint %} - -```bash -# Install nodejs -sudo apt-get install -y curl git gnupg2 -curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - -sudo apt-get install -y nodejs -# Install yarn -curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - -echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list -sudo apt-get update && sudo apt-get install yarn -# Install Overseerr -cd ~ && git clone https://github.com/sct/overseerr.git -cd overseerr -yarn install -yarn build -yarn start -``` - -**Updating** - -In order to update, you will need to re-build overseer. -```bash -cd ~/.overseerr -git pull -yarn install -yarn build -yarn start -``` -{% endtab %} - -{% tab title="Ubuntu ARM" %} -{% hint style="danger" %} -This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker. -{% endhint %} - -```bash -# Install nodejs -sudo apt-get install -y curl git gnupg2 build-essential -curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - -sudo apt-get install -y nodejs -# Install yarn -curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - -echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list -sudo apt-get update && sudo apt-get install yarn -# Install Overseerr -cd ~ && git clone https://github.com/sct/overseerr.git -cd overseerr -npm config set python "$(which python3)" -yarn install -yarn build -yarn start -``` - -**Updating** - -In order to update, you will need to re-build overseer. -```bash -cd ~/.overseerr -git pull -yarn install -yarn build -yarn start -``` -{% endtab %} - -{% tab title="ArchLinux \(3rd Party\)" %} -Built from tag \(master\): [https://aur.archlinux.org/packages/overseerr/](https://aur.archlinux.org/packages/overseerr/) -Built from latest \(develop\): [aur.archlinux.org/packages/overseerr-git](https://aur.archlinux.org/packages/overseerr-git/) -**To install these just use your favorite AUR package manager:** - -```bash -yay -S overseer -``` -{% endtab %} - -{% tab title="Gentoo \(3rd Party\)" %} +{% tab title="Gentoo" %} Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay) Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed. To enable using eselect repository, run: + ```bash eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git ``` Once complete, you can just run: + ```bash emerge www-apps/overseerr ``` + {% endtab %} -{% endtabs %} - -## Swizzin \(Third party\) +{% tab title="Swizzin" %} The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used. Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information. To install, run the following: + ```bash box install overseerr ``` To upgrade, run the following: + ```bash box upgrade overseerr ``` + +{% endtab %} + +{% endtabs %} diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md new file mode 100644 index 00000000..61323635 --- /dev/null +++ b/docs/using-overseerr/notifications/README.md @@ -0,0 +1,28 @@ +# Notifications + +Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it. + +## Currently Supported Notification Agents + +- Email +- Discord +- 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! + +Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page. + +{% 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. +{% endhint %} + +## Requesting new 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! diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md new file mode 100644 index 00000000..9f563b4c --- /dev/null +++ b/docs/using-overseerr/notifications/webhooks.md @@ -0,0 +1,56 @@ +# Webhooks + +Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes. + +## Configuration + +The following configuration options are available: + +### Webhook URL (Required) + +The URL you would like to post notifications to. Your JSON will be sent as the body of the request. + +### Authorization Header + +Custom authorization header. Anything entered for this will be sent as an `Authorization` header. + +### Custom 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. + +## Template Variables + +### Main + +- `{{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 + +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. + +### Media + +These variables are only included in media related notifications, such as requests. + +- `{{media_type}}` Media type. Either `movie` or `tv`. +- `{{media_tmdbid}}` Media's TMDB ID. +- `{{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`) + +### Special Key Variables + +These variables must be used as a key in the JSON Payload. (Ex, `"{{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. diff --git a/ormconfig.js b/ormconfig.js index 2cc4533b..070e0598 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -1,6 +1,8 @@ const devConfig = { type: 'sqlite', - database: 'config/db/db.sqlite3', + database: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` + : 'config/db/db.sqlite3', synchronize: true, migrationsRun: false, logging: false, @@ -15,7 +17,9 @@ const devConfig = { const prodConfig = { type: 'sqlite', - database: 'config/db/db.sqlite3', + database: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` + : 'config/db/db.sqlite3', synchronize: false, logging: false, entities: ['dist/entity/**/*.js'], diff --git a/overseerr-api.yml b/overseerr-api.yml index 3dd8d31e..d77b596b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -20,11 +20,16 @@ components: plexToken: type: string readOnly: true + userType: + type: integer + example: 1 + readOnly: true permissions: type: number example: 0 avatar: type: string + readOnly: true createdAt: type: string example: '2020-09-02T05:02:23.000Z' @@ -45,7 +50,6 @@ components: required: - id - email - - permissions - createdAt - updatedAt MainSettings: @@ -701,6 +705,15 @@ components: - $ref: '#/components/schemas/User' - type: string nullable: true + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - id - status @@ -855,6 +868,22 @@ components: properties: webhookUrl: type: string + WebhookSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + jsonPayload: + type: string TelegramSettings: type: object properties: @@ -1832,6 +1861,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/webhook: + get: + summary: Return current webhook notification settings + description: Returns current webhook notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned webhook settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + post: + summary: Update webhook notification settings + description: Update current webhook notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + /settings/notifications/webhook/test: + post: + summary: Test the provided slack settings + description: Sends a test notification to the slack agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats @@ -1898,6 +1973,34 @@ paths: type: string required: - authToken + /auth/local: + post: + summary: Login using a local account + description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + required: + - email + - password /auth/logout: get: summary: Logout and clear session cookie @@ -2364,6 +2467,15 @@ paths: type: array items: type: number + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string required: - mediaType - mediaId @@ -2374,6 +2486,30 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaRequest' + /request/count: + get: + summary: Returns request counts + description: | + Returns the number of pending and approved requests. + tags: + - request + responses: + '200': + description: Request counts returned + content: + application/json: + schema: + type: object + properties: + pending: + type: number + example: 0 + approved: + type: number + example: 10 + required: + - pending + - approved /request/{requestId}: get: summary: Requests a specific MediaRequest @@ -2395,6 +2531,26 @@ paths: application/json: schema: $ref: '#/components/schemas/MediaRequest' + put: + summary: Update a specific MediaRequest + description: Updats a specific media request and returns the request in JSON format. Requires the `MANAGE_REQUESTS` permission. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: 1 + schema: + type: string + responses: + '200': + description: Succesfully updated request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' delete: summary: Delete a request description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed. @@ -2896,7 +3052,7 @@ paths: name: sort schema: type: string - enum: [added, modified] + enum: [added, modified, mediaAdded] default: added responses: '200': @@ -2954,6 +3110,86 @@ paths: application/json: schema: $ref: '#/components/schemas/Collection' + /service/radarr: + get: + summary: Returns non-sensitive radarr server list + description: Returns a list of radarr servers, both ID and name in JSON format + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + /service/radarr/{radarrId}: + get: + summary: Returns radarr server quality profiles and root folders + description: Returns a radarr server quality profile and root folder details in JSON format + tags: + - service + parameters: + - in: path + name: radarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/RadarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr: + get: + summary: Returns non-sensitive sonarr server list + description: Returns a list of sonarr servers, both ID and name in JSON format + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + /service/sonarr/{sonarrId}: + get: + summary: Returns sonarr server quality profiles and root folders + description: Returns a sonarr server quality profile and root folder details in JSON format + tags: + - service + parameters: + - in: path + name: sonarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/SonarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' security: - cookieAuth: [] diff --git a/package.json b/package.json index a3ea375d..e8db7fa7 100644 --- a/package.json +++ b/package.json @@ -18,39 +18,44 @@ "license": "MIT", "dependencies": { "@svgr/webpack": "^5.5.0", + "ace-builds": "^1.4.12", "axios": "^0.21.1", + "bcrypt": "^5.0.0", "body-parser": "^1.19.0", "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", - "email-templates": "^8.0.2", + "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.10.2", + "express-openapi-validator": "^4.10.8", "express-session": "^1.17.1", "formik": "^2.2.6", + "gravatar-url": "^3.1.0", "intl": "^1.2.5", "lodash": "^4.17.20", - "next": "^10.0.4", + "next": "10.0.3", "node-schedule": "^1.3.2", "nodemailer": "^6.4.17", - "nookies": "^2.5.0", + "nookies": "^2.5.1", "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", + "react-ace": "^9.2.1", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.10.11", + "react-intl": "^5.10.16", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.0", "react-transition-group": "^4.4.1", - "react-truncate-markup": "^5.0.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", "swagger-ui-express": "^4.1.6", - "swr": "^0.3.11", - "typeorm": "^0.2.29", + "swr": "^0.4.0", + "typeorm": "^0.2.30", "uuid": "^8.3.2", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.0", @@ -68,48 +73,50 @@ "@semantic-release/git": "^9.0.0", "@tailwindcss/aspect-ratio": "^0.2.0", "@tailwindcss/forms": "^0.2.1", - "@tailwindcss/typography": "^0.3.1", + "@tailwindcss/typography": "^0.4.0", + "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/email-templates": "^8.0.0", - "@types/express": "^4.17.9", + "@types/express": "^4.17.11", "@types/express-session": "^1.17.0", "@types/lodash": "^4.14.167", - "@types/node": "^14.14.20", + "@types/node": "^14.14.21", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-toast-notifications": "^2.4.0", "@types/react-transition-group": "^4.4.0", + "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", "@types/xml2js": "^0.4.7", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.12.0", - "@typescript-eslint/parser": "^4.12.0", + "@typescript-eslint/eslint-plugin": "^4.13.0", + "@typescript-eslint/parser": "^4.13.0", "autoprefixer": "^9", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", - "commitizen": "^4.2.2", + "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.17.0", + "eslint": "^7.18.0", "eslint-config-prettier": "^7.1.0", - "eslint-plugin-formatjs": "^2.10.2", + "eslint-plugin-formatjs": "^2.10.3", "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.6", + "husky": "^4.3.8", "lint-staged": "^10.5.3", - "nodemon": "^2.0.6", + "nodemon": "^2.0.7", "postcss": "^7", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.1", + "semantic-release": "^17.3.3", "semantic-release-docker": "^2.2.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", diff --git a/public/preview.jpg b/public/preview.jpg new file mode 100644 index 00000000..bbf562c6 Binary files /dev/null and b/public/preview.jpg differ diff --git a/public/preview.png b/public/preview.png deleted file mode 100644 index 7d90433e..00000000 Binary files a/public/preview.png and /dev/null differ diff --git a/server/api/animelist.ts b/server/api/animelist.ts new file mode 100644 index 00000000..428684bc --- /dev/null +++ b/server/api/animelist.ts @@ -0,0 +1,223 @@ +import axios from 'axios'; +import xml2js from 'xml2js'; +import fs, { promises as fsp } from 'fs'; +import path from 'path'; +import logger from '../logger'; + +const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds +// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml +const MAPPING_URL = + 'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml'; +const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml'); + +const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); + +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs +// https://github.com/Anime-Lists/anime-lists/ + +interface AnimeMapping { + $: { + anidbseason: string; + tvdbseason: string; + }; + _: string; +} + +interface Anime { + $: { + anidbid: number; + tvdbid?: string; + defaulttvdbseason?: string; + tmdbid?: number; + imdbid?: string; + }; + 'mapping-list'?: { + mapping: AnimeMapping[]; + }[]; +} + +interface AnimeList { + 'anime-list': { + anime: Anime[]; + }; +} + +export interface AnidbItem { + tvdbId?: number; + tmdbId?: number; + imdbId?: string; +} + +class AnimeListMapping { + private syncing = false; + + private mapping: { [anidbId: number]: AnidbItem } = {}; + + // mapping file modification date when it was loaded + private mappingModified: Date | null = null; + + // each episode in season 0 from TVDB can map to movie + private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {}; + + public isLoaded = () => Object.keys(this.mapping).length !== 0; + + private loadFromFile = async () => { + logger.info('Loading mapping file', { label: 'Anime-List Sync' }); + try { + const mappingStat = await fsp.stat(LOCAL_PATH); + const file = await fsp.readFile(LOCAL_PATH); + const xml = (await xml2js.parseStringPromise(file)) as AnimeList; + + this.mapping = {}; + this.specials = {}; + for (const anime of xml['anime-list'].anime) { + // tvdbId can be nonnumber, like 'movie' string + let tvdbId: number | undefined; + if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) { + tvdbId = Number(anime.$.tvdbid); + } else { + tvdbId = undefined; + } + + let imdbIds: (string | undefined)[]; + if (anime.$.imdbid) { + // if there are multiple imdb entries, then they map to different movies + imdbIds = anime.$.imdbid.split(','); + } else { + // in case there is no imdbid, that's ok as there will be tmdbid + imdbIds = [undefined]; + } + + const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined; + const anidbId = Number(anime.$.anidbid); + this.mapping[anidbId] = { + // for season 0 ignore tvdbid, because this must be movie/OVA + tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId, + tmdbId: tmdbId, + imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping + }; + + if (tvdbId) { + const mappingList = anime['mapping-list']; + if (mappingList && mappingList.length != 0) { + let imdbIndex = 0; + for (const mapping of mappingList[0].mapping) { + const text = mapping._; + if (text && mapping.$.tvdbseason === '0') { + let matches; + while ((matches = mappingRegexp.exec(text)) !== null) { + const episode = Number(matches[1]); + if (!this.specials[tvdbId]) { + this.specials[tvdbId] = {}; + } + // map next available imdbid to episode in s0 + const imdbId = + imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex]; + if (tmdbId || imdbId) { + this.specials[tvdbId][episode] = { + tmdbId: tmdbId, + imdbId: imdbId, + }; + imdbIndex++; + } + } + } + } + } else { + // some movies do not have mapping-list, so map episode 1,2,3,..to movies + // movies must have imdbid or tmdbid + const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined; + if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') { + if (!this.specials[tvdbId]) { + this.specials[tvdbId] = {}; + } + // map each imdbid to episode in s0, episode index starts with 1 + for (let idx = 0; idx < imdbIds.length; idx++) { + this.specials[tvdbId][idx + 1] = { + tmdbId: tmdbId, + imdbId: imdbIds[idx], + }; + } + } + } + } + } + this.mappingModified = mappingStat.mtime; + logger.info( + `Loaded ${ + Object.keys(this.mapping).length + } AniDB items from mapping file`, + { label: 'Anime-List Sync' } + ); + } catch (e) { + throw new Error(`Failed to load Anime-List mappings: ${e.message}`); + } + }; + + private downloadFile = async () => { + logger.info('Downloading latest mapping file', { + label: 'Anime-List Sync', + }); + try { + const response = await axios.get(MAPPING_URL, { + responseType: 'stream', + }); + await new Promise((resolve) => { + const writer = fs.createWriteStream(LOCAL_PATH); + writer.on('finish', resolve); + response.data.pipe(writer); + }); + } catch (e) { + throw new Error(`Failed to download Anime-List mapping: ${e.message}`); + } + }; + + public sync = async () => { + // make sure only one sync runs at a time + if (this.syncing) { + return; + } + + this.syncing = true; + try { + // check if local file is not "expired" yet + if (fs.existsSync(LOCAL_PATH)) { + const now = new Date(); + const stat = await fsp.stat(LOCAL_PATH); + if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) { + if (!this.isLoaded()) { + // no need to download, but make sure file is loaded + await this.loadFromFile(); + } else if ( + this.mappingModified && + stat.mtime.getTime() > this.mappingModified.getTime() + ) { + // if file has been modified externally since last load, reload it + await this.loadFromFile(); + } + return; + } + } + await this.downloadFile(); + await this.loadFromFile(); + } finally { + this.syncing = false; + } + }; + + public getFromAnidbId = (anidbId: number): AnidbItem | undefined => { + return this.mapping[anidbId]; + }; + + public getSpecialEpisode = ( + tvdbId: number, + episode: number + ): AnidbItem | undefined => { + const episodes = this.specials[tvdbId]; + return episodes ? episodes[episode] : undefined; + }; +} + +const animeList = new AnimeListMapping(); + +export default animeList; diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index cc71b07e..43487c21 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -9,6 +9,8 @@ export interface PlexLibraryItem { guid: string; parentGuid?: string; grandparentGuid?: string; + addedAt: number; + updatedAt: number; type: 'movie' | 'show' | 'season' | 'episode'; } @@ -48,6 +50,25 @@ export interface PlexMetadata { parentIndex?: number; leafCount: number; viewedLeafCount: number; + addedAt: number; + updatedAt: number; + Media: Media[]; +} + +interface Media { + id: number; + duration: number; + bitrate: number; + width: number; + height: number; + aspectRatio: number; + audioChannels: number; + audioCodec: string; + videoCodec: string; + videoResolution: string; + container: string; + videoFrameRate: string; + videoProfile: string; } interface PlexMetadataResponse { @@ -123,6 +144,14 @@ class PlexAPI { return response.MediaContainer.Metadata[0]; } + public async getChildrenMetadata(key: string): Promise { + const response = await this.plexClient.query( + `/library/metadata/${key}/children` + ); + + return response.MediaContainer.Metadata; + } + public async getRecentlyAdded(id: string): Promise { const response = await this.plexClient.query( `/library/sections/${id}/recentlyAdded` diff --git a/server/api/radarr.ts b/server/api/radarr.ts index f3aaedc4..53d8c7ed 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -29,7 +29,7 @@ interface RadarrMovie { hasFile: boolean; } -interface RadarrRootFolder { +export interface RadarrRootFolder { id: number; path: string; freeSpace: number; @@ -40,7 +40,7 @@ interface RadarrRootFolder { }[]; } -interface RadarrProfile { +export interface RadarrProfile { id: number; name: string; } diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index 7c1c5985..cc3a562a 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -92,7 +92,27 @@ class RottenTomatoes { } ); - const movie = response.data.movies.find((movie) => movie.year === year); + // First, attempt to match exact name and year + let movie = response.data.movies.find( + (movie) => movie.year === year && movie.title === name + ); + + // If we don't find a movie, try to match partial name and year + if (!movie) { + movie = response.data.movies.find( + (movie) => movie.year === year && movie.title.includes(name) + ); + } + + // If we still dont find a movie, try to match just on year + if (!movie) { + movie = response.data.movies.find((movie) => movie.year === year); + } + + // One last try, try exact name match only + if (!movie) { + movie = response.data.movies.find((movie) => movie.title === name); + } if (!movie) { return null; diff --git a/server/constants/user.ts b/server/constants/user.ts new file mode 100644 index 00000000..38aa50a4 --- /dev/null +++ b/server/constants/user.ts @@ -0,0 +1,4 @@ +export enum UserType { + PLEX = 1, + LOCAL = 2, +} diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 92a74774..dc269f5a 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -80,6 +80,9 @@ class Media { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) public requests: MediaRequest[]; @@ -98,6 +101,9 @@ class Media { @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public lastSeasonChange: Date; + @Column({ type: 'datetime', nullable: true }) + public mediaAddedAt: Date; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ebf0b1c9..cf0903e8 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -65,6 +65,18 @@ export class MediaRequest { }) public seasons: SeasonRequest[]; + @Column({ default: false }) + public is4k: boolean; + + @Column({ nullable: true }) + public serverId: number; + + @Column({ nullable: true }) + public profileId: number; + + @Column({ nullable: true }) + public rootFolder: string; + constructor(init?: Partial) { Object.assign(this, init); } @@ -72,11 +84,11 @@ export class MediaRequest { @AfterUpdate() @AfterInsert() public async sendMedia(): Promise { - await Promise.all([this._sendToRadarr(), this._sendToSonarr()]); + await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); } @AfterInsert() - private async _notifyNewRequest() { + public async notifyNewRequest(): Promise { if (this.status === MediaRequestStatus.PENDING) { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ @@ -126,8 +138,11 @@ export class MediaRequest { * auto approved content */ @AfterUpdate() - private async _notifyApproved() { - if (this.status === MediaRequestStatus.APPROVED) { + public async notifyApprovedOrDeclined(): Promise { + if ( + this.status === MediaRequestStatus.APPROVED || + this.status === MediaRequestStatus.DECLINED + ) { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, @@ -139,30 +154,40 @@ export class MediaRequest { const tmdb = new TheMovieDb(); if (this.media.mediaType === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_APPROVED, { - subject: movie.title, - message: movie.overview, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - notifyUser: this.requestedBy, - media, - }); + notificationManager.sendNotification( + this.status === MediaRequestStatus.APPROVED + ? Notification.MEDIA_APPROVED + : Notification.MEDIA_DECLINED, + { + subject: movie.title, + message: movie.overview, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + notifyUser: this.requestedBy, + media, + } + ); } else if (this.media.mediaType === MediaType.TV) { const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_APPROVED, { - subject: tv.name, - message: tv.overview, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, - notifyUser: this.requestedBy, - media, - extra: [ - { - name: 'Seasons', - value: this.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - }); + notificationManager.sendNotification( + this.status === MediaRequestStatus.APPROVED + ? Notification.MEDIA_APPROVED + : Notification.MEDIA_DECLINED, + { + subject: tv.name, + message: tv.overview, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + notifyUser: this.requestedBy, + media, + extra: [ + { + name: 'Seasons', + value: this.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + } + ); } } } @@ -181,15 +206,23 @@ export class MediaRequest { } const seasonRequestRepository = getRepository(SeasonRequest); if (this.status === MediaRequestStatus.APPROVED) { - media.status = MediaStatus.PROCESSING; + if (this.is4k) { + media.status4k = MediaStatus.PROCESSING; + } else { + media.status = MediaStatus.PROCESSING; + } mediaRepository.save(media); } if ( - this.media.mediaType === MediaType.MOVIE && + media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { - media.status = MediaStatus.UNKNOWN; + if (this.is4k) { + media.status4k = MediaStatus.UNKNOWN; + } else { + media.status = MediaStatus.UNKNOWN; + } mediaRepository.save(media); } @@ -224,18 +257,31 @@ export class MediaRequest { } @AfterRemove() - private async _handleRemoveParentUpdate() { + public async handleRemoveParentUpdate(): Promise { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, + relations: ['requests'], }); - if (!fullMedia.requests || fullMedia.requests.length === 0) { + + if ( + !fullMedia.requests.some((request) => !request.is4k) && + fullMedia.status !== MediaStatus.AVAILABLE + ) { fullMedia.status = MediaStatus.UNKNOWN; - mediaRepository.save(fullMedia); } + + if ( + !fullMedia.requests.some((request) => request.is4k) && + fullMedia.status4k !== MediaStatus.AVAILABLE + ) { + fullMedia.status4k = MediaStatus.UNKNOWN; + } + + mediaRepository.save(fullMedia); } - private async _sendToRadarr() { + public async sendToRadarr(): Promise { if ( this.status === MediaRequestStatus.APPROVED && this.type === MediaType.MOVIE @@ -251,18 +297,58 @@ export class MediaRequest { return; } - const radarrSettings = settings.radarr.find( - (radarr) => radarr.isDefault && !radarr.is4k + let radarrSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === this.is4k ); + if ( + this.serverId !== null && + this.serverId >= 0 && + radarrSettings?.id !== this.serverId + ) { + radarrSettings = settings.radarr.find( + (radarr) => radarr.id === this.serverId + ); + logger.info( + `Request has an override server: ${radarrSettings?.name}`, + { label: 'Media Request' } + ); + } + if (!radarrSettings) { logger.info( - 'There is no default radarr configured. Did you set any of your Radarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }radarr configured. Did you set any of your Radarr servers as default?`, { label: 'Media Request' } ); return; } + let rootFolder = radarrSettings.activeDirectory; + let qualityProfile = radarrSettings.activeProfileId; + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== radarrSettings.activeDirectory + ) { + rootFolder = this.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + }); + } + + if ( + this.profileId && + this.profileId !== radarrSettings.activeProfileId + ) { + qualityProfile = this.profileId; + logger.info(`Request has an override profile id: ${qualityProfile}`, { + label: 'Media Request', + }); + } + const tmdb = new TheMovieDb(); const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, @@ -275,9 +361,9 @@ export class MediaRequest { // Run this asynchronously so we don't wait for it on the UI side radarr .addMovie({ - profileId: radarrSettings.activeProfileId, - qualityProfileId: radarrSettings.activeProfileId, - rootFolderPath: radarrSettings.activeDirectory, + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, minimumAvailability: radarrSettings.minimumAvailability, title: movie.title, tmdbId: movie.id, @@ -325,7 +411,7 @@ export class MediaRequest { } } - private async _sendToSonarr() { + public async sendToSonarr(): Promise { if ( this.status === MediaRequestStatus.APPROVED && this.type === MediaType.TV @@ -341,13 +427,29 @@ export class MediaRequest { return; } - const sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && !sonarr.is4k + let sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k ); + if ( + this.serverId !== null && + this.serverId >= 0 && + sonarrSettings?.id !== this.serverId + ) { + sonarrSettings = settings.sonarr.find( + (sonarr) => sonarr.id === this.serverId + ); + logger.info( + `Request has an override server: ${sonarrSettings?.name}`, + { label: 'Media Request' } + ); + } + if (!sonarrSettings) { logger.info( - 'There is no default sonarr configured. Did you set any of your Sonarr servers as default?', + `There is no default ${ + this.is4k ? '4K ' : '' + }sonarr configured. Did you set any of your Sonarr servers as default?`, { label: 'Media Request' } ); return; @@ -386,17 +488,38 @@ export class MediaRequest { seriesType = 'anime'; } + let rootFolder = + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory; + let qualityProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId; + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== rootFolder + ) { + rootFolder = this.rootFolder; + logger.info(`Request has an override root folder: ${rootFolder}`, { + label: 'Media Request', + }); + } + + if (this.profileId && this.profileId !== qualityProfile) { + qualityProfile = this.profileId; + logger.info(`Request has an override profile id: ${qualityProfile}`, { + label: 'Media Request', + }); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ - profileId: - seriesType === 'anime' && sonarrSettings.activeAnimeProfileId - ? sonarrSettings.activeAnimeProfileId - : sonarrSettings.activeProfileId, - rootFolderPath: - seriesType === 'anime' && sonarrSettings.activeAnimeDirectory - ? sonarrSettings.activeAnimeDirectory - : sonarrSettings.activeDirectory, + profileId: qualityProfile, + rootFolderPath: rootFolder, title: series.name, tvdbid: series.external_ids.tvdb_id, seasons: this.seasons.map((season) => season.seasonNumber), diff --git a/server/entity/Season.ts b/server/entity/Season.ts index d66805cb..77f9c760 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -20,6 +20,9 @@ class Season { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) public media: Promise; diff --git a/server/entity/User.ts b/server/entity/User.ts index 35ad36e6..0b05efb2 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -9,6 +9,13 @@ import { } from 'typeorm'; import { Permission, hasPermission } from '../lib/permissions'; import { MediaRequest } from './MediaRequest'; +import bcrypt from 'bcrypt'; +import path from 'path'; +import PreparedEmail from '../lib/email'; +import logger from '../logger'; +import { getSettings } from '../lib/settings'; +import { default as generatePassword } from 'secure-random-password'; +import { UserType } from '../constants/user'; @Entity() export class User { @@ -16,7 +23,7 @@ export class User { return users.map((u) => u.filter()); } - static readonly filteredFields: string[] = ['plexToken']; + static readonly filteredFields: string[] = ['plexToken', 'password']; @PrimaryGeneratedColumn() public id: number; @@ -27,8 +34,14 @@ export class User { @Column() public username: string; - @Column({ select: false }) - public plexId: number; + @Column({ nullable: true, select: false }) + public password?: string; + + @Column({ type: 'integer', default: UserType.PLEX }) + public userType: UserType; + + @Column({ nullable: true, select: false }) + public plexId?: number; @Column({ nullable: true, select: false }) public plexToken?: string; @@ -69,4 +82,47 @@ export class User { public hasPermission(permissions: Permission | Permission[]): boolean { return !!hasPermission(permissions, this.permissions); } + + public passwordMatch(password: string): Promise { + return new Promise((resolve, reject) => { + if (this.password) { + resolve(bcrypt.compare(password, this.password)); + } else { + return reject(false); + } + }); + } + + public async setPassword(password: string): Promise { + const hashedPassword = await bcrypt.hash(password, 12); + this.password = hashedPassword; + } + + public async resetPassword(): Promise { + const password = generatePassword.randomPassword({ length: 16 }); + this.setPassword(password); + + const applicationUrl = getSettings().main.applicationUrl; + try { + logger.info(`Sending password email for ${this.email}`, { + label: 'User creation', + }); + const email = new PreparedEmail(); + await email.send({ + template: path.join(__dirname, '../templates/email/password'), + message: { + to: this.email, + }, + locals: { + password: password, + applicationUrl, + }, + }); + } catch (e) { + logger.error('Failed to send out password email', { + label: 'User creation', + message: e.message, + }); + } + } } diff --git a/server/index.ts b/server/index.ts index 32b14447..6733af87 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ import TelegramAgent from './lib/notifications/agents/telegram'; import { getAppVersion } from './utils/appVersion'; import SlackAgent from './lib/notifications/agents/slack'; import PushoverAgent from './lib/notifications/agents/pushover'; +import WebhookAgent from './lib/notifications/agents/webhook'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -51,6 +52,7 @@ app new SlackAgent(), new TelegramAgent(), new PushoverAgent(), + new WebhookAgent(), ]); // Start Jobs diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts new file mode 100644 index 00000000..fb4b2cd5 --- /dev/null +++ b/server/interfaces/api/serviceInterfaces.ts @@ -0,0 +1,18 @@ +import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; + +export interface ServiceCommonServer { + id: number; + name: string; + is4k: boolean; + isDefault: boolean; + activeProfileId: number; + activeDirectory: string; + activeAnimeProfileId?: number; + activeAnimeDirectory?: string; +} + +export interface ServiceCommonServerWithDetails { + server: ServiceCommonServer; + profiles: RadarrProfile[]; + rootFolders: Partial[]; +} diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 2938e696..eba40ee2 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -4,3 +4,9 @@ export interface SettingsAboutResponse { totalMediaItems: number; tz?: string; } + +export interface PublicSettingsResponse { + initialized: boolean; + movie4kEnabled: boolean; + series4kEnabled: boolean; +} diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 6ee93f76..2c3330ca 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,6 +1,6 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; -import PlexAPI, { PlexLibraryItem } from '../../api/plexapi'; +import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi'; import TheMovieDb, { TmdbMovieDetails, TmdbTvDetails, @@ -11,15 +11,23 @@ import logger from '../../logger'; import { getSettings, Library } from '../../lib/settings'; import Season from '../../entity/Season'; import { uniqWith } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import animeList from '../../api/animelist'; +import AsyncLock from '../../utils/asyncLock'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); -const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)|hama:\/\/tvdb-([0-9]+)/); +const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); const plexRegex = new RegExp(/plex:\/\//); +// Hama agent uses ASS naming, see details here: +// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id +const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); +const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); +const HAMA_AGENT = 'com.plexapp.agents.hama'; interface SyncStatus { running: boolean; @@ -30,6 +38,7 @@ interface SyncStatus { } class JobPlexSync { + private sessionId: string; private tmdb: TheMovieDb; private plexClient: PlexAPI; private items: PlexLibraryItem[] = []; @@ -38,6 +47,9 @@ class JobPlexSync { private currentLibrary: Library; private running = false; private isRecentOnly = false; + private enable4kMovie = false; + private enable4kShow = false; + private asyncLock = new AsyncLock(); constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { this.tmdb = new TheMovieDb(); @@ -78,26 +90,71 @@ class JobPlexSync { } }); - const existing = await this.getExisting( - newMedia.tmdbId, - MediaType.MOVIE + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' ); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${metadata.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. Setting status AVAILABLE`, - 'info' + await this.asyncLock.dispatch(newMedia.tmdbId, async () => { + const existing = await this.getExisting( + newMedia.tmdbId, + MediaType.MOVIE ); - } else { - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${plexitem.title}`); - } + + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (!existing.mediaAddedAt && !changedExisting) { + existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } + } else { + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.mediaType = MediaType.MOVIE; + newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); + await mediaRepository.save(newMedia); + this.log(`Saved ${plexitem.title}`); + } + }); } else { let tmdbMovieId: number | undefined; let tmdbMovie: TmdbMovieDetails | undefined; @@ -118,30 +175,7 @@ class JobPlexSync { throw new Error('Unable to find TMDB ID'); } - const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); - if (existing && existing.status === MediaStatus.AVAILABLE) { - this.log(`Title exists and is already available ${plexitem.title}`); - } else if (existing && existing.status !== MediaStatus.AVAILABLE) { - existing.status = MediaStatus.AVAILABLE; - await mediaRepository.save(existing); - this.log( - `Request for ${plexitem.title} exists. Setting status AVAILABLE`, - 'info' - ); - } else { - // If we have a tmdb movie guid but it didn't already exist, only then - // do we request the movie from tmdb (to reduce api requests) - if (!tmdbMovie) { - tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); - } - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; - newMedia.status = MediaStatus.AVAILABLE; - newMedia.mediaType = MediaType.MOVIE; - await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); - } + await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId); } } catch (e) { this.log( @@ -155,6 +189,113 @@ class JobPlexSync { } } + private async processMovieWithId( + plexitem: PlexLibraryItem, + tmdbMovie: TmdbMovieDetails | undefined, + tmdbMovieId: number + ) { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(tmdbMovieId, async () => { + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); + const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); + + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + const hasOtherResolution = metadata.Media.some( + (media) => media.videoResolution !== '4k' + ); + + if (existing) { + let changedExisting = false; + + if ( + (hasOtherResolution || (!this.enable4kMovie && has4k)) && + existing.status !== MediaStatus.AVAILABLE + ) { + existing.status = MediaStatus.AVAILABLE; + existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.status4k !== MediaStatus.AVAILABLE + ) { + existing.status4k = MediaStatus.AVAILABLE; + changedExisting = true; + } + + if (!existing.mediaAddedAt && !changedExisting) { + existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Request for ${metadata.title} exists. New media types set to AVAILABLE`, + 'info' + ); + } else { + this.log( + `Title already exists and no new media types found ${metadata.title}` + ); + } + } else { + // If we have a tmdb movie guid but it didn't already exist, only then + // do we request the movie from tmdb (to reduce api requests) + if (!tmdbMovie) { + tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); + } + const newMedia = new Media(); + newMedia.imdbId = tmdbMovie.external_ids.imdb_id; + newMedia.tmdbId = tmdbMovie.id; + newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); + newMedia.status = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.status4k = + has4k && this.enable4kMovie + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newMedia.mediaType = MediaType.MOVIE; + await mediaRepository.save(newMedia); + this.log(`Saved ${tmdbMovie.title}`); + } + }); + } + + // this adds all movie episodes from specials season for Hama agent + private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) { + const specials = metadata.Children?.Metadata.find( + (md) => Number(md.index) === 0 + ); + if (specials) { + const episodes = await this.plexClient.getChildrenMetadata( + specials.ratingKey + ); + if (episodes) { + for (const episode of episodes) { + const special = animeList.getSpecialEpisode(tvdbId, episode.index); + if (special) { + if (special.tmdbId) { + await this.processMovieWithId(episode, undefined, special.tmdbId); + } else if (special.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: special.imdbId, + }); + await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id); + } + } + } + } + } + } + private async processShow(plexitem: PlexLibraryItem) { const mediaRepository = getRepository(Media); @@ -182,108 +323,272 @@ class JobPlexSync { if (matchedtmdb?.[1]) { tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) }); } - } + } else if (metadata.guid.match(hamaTvdbRegex)) { + const matched = metadata.guid.match(hamaTvdbRegex); + const tvdbId = matched?.[1]; - if (tvShow && metadata) { - // Lets get the available seasons from plex - const seasons = tvShow.seasons; - const media = await this.getExisting(tvShow.id, MediaType.TV); + if (tvdbId) { + tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) }); + if (animeList.isLoaded()) { + await this.processHamaSpecials(metadata, Number(tvdbId)); + } else { + this.log( + `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn' + ); + } + } + } else if (metadata.guid.match(hamaAnidbRegex)) { + const matched = metadata.guid.match(hamaAnidbRegex); - const newSeasons: Season[] = []; - - const currentSeasonAvailable = ( - media?.seasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ) ?? [] - ).length; - - seasons.forEach((season) => { - const matchedPlexSeason = metadata.Children?.Metadata.find( - (md) => Number(md.index) === season.season_number + if (!animeList.isLoaded()) { + this.log( + `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn' ); + } else if (matched?.[1]) { + const anidbId = Number(matched[1]); + const result = animeList.getFromAnidbId(anidbId); - const existingSeason = media?.seasons.find( - (es) => es.seasonNumber === season.season_number - ); - - // Check if we found the matching season and it has all the available episodes - if ( - matchedPlexSeason && - Number(matchedPlexSeason.leafCount) === season.episode_count - ) { - if (existingSeason) { - existingSeason.status = MediaStatus.AVAILABLE; + // first try to lookup tvshow by tvdbid + if (result?.tvdbId) { + const extResponse = await this.tmdb.getByExternalId({ + externalId: result.tvdbId, + type: 'tvdb', + }); + if (extResponse.tv_results[0]) { + tvShow = await this.tmdb.getTvShow({ + tvId: extResponse.tv_results[0].id, + }); } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.AVAILABLE, - }) + this.log( + `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}` ); } - } else if (matchedPlexSeason) { - if (existingSeason) { - existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE; - } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - status: MediaStatus.PARTIALLY_AVAILABLE, - }) + await this.processHamaSpecials(metadata, result.tvdbId); + } + + if (!tvShow) { + // 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, + undefined, + result.tmdbId + ); + } else if (result?.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: result.imdbId, + }); + return await this.processMovieWithId( + plexitem, + tmdbMovie, + tmdbMovie.id ); } } - }); + } + } - // Remove extras season. We dont count it for determining availability - const filteredSeasons = tvShow.seasons.filter( - (season) => season.season_number !== 0 - ); + if (tvShow) { + await this.asyncLock.dispatch(tvShow.id, async () => { + if (!tvShow) { + // this will never execute, but typescript thinks somebody could reset tvShow from + // outer scope back to null before this async gets called + return; + } - const isAllSeasons = - newSeasons.length + (media?.seasons.length ?? 0) >= - filteredSeasons.length; + // Lets get the available seasons from plex + const seasons = tvShow.seasons; + const media = await this.getExisting(tvShow.id, MediaType.TV); - if (media) { - // Update existing - media.seasons = [...media.seasons, ...newSeasons]; + const newSeasons: Season[] = []; - const newSeasonAvailable = ( - media.seasons.filter( + const currentStandardSeasonAvailable = ( + media?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; + const current4kSeasonAvailable = ( + media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; - // If at least one new season has become available, update - // the lastSeasonChange field so we can trigger notifications - if (newSeasonAvailable > currentSeasonAvailable) { - this.log( - `Detected ${ - newSeasonAvailable - currentSeasonAvailable - } new season(s) for ${tvShow.name}`, - 'debug' + for (const season of seasons) { + const matchedPlexSeason = metadata.Children?.Metadata.find( + (md) => Number(md.index) === season.season_number ); - media.lastSeasonChange = new Date(); + + const existingSeason = media?.seasons.find( + (es) => es.seasonNumber === season.season_number + ); + + // Check if we found the matching season and it has all the available episodes + if (matchedPlexSeason) { + // If we have a matched plex season, get its children metadata so we can check details + const episodes = await this.plexClient.getChildrenMetadata( + matchedPlexSeason.ratingKey + ); + // Total episodes that are in standard definition (not 4k) + const totalStandard = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution !== '4k') + ).length; + + // Total episodes that are in 4k + const total4k = episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution === '4k') + ).length; + + 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 + existingSeason.status = + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status; + existingSeason.status4k = + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : existingSeason.status4k; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.season_number, + // This ternary is the same as the ones above, but it just falls back to "UNKNOWN" + // if we dont have any items for the season + status: + totalStandard === season.episode_count + ? MediaStatus.AVAILABLE + : totalStandard > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: + total4k === season.episode_count + ? MediaStatus.AVAILABLE + : total4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + }) + ); + } + } } - media.status = isAllSeasons - ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE; - await mediaRepository.save(media); - this.log(`Updating existing title: ${tvShow.name}`); - } else { - const newMedia = new Media({ - mediaType: MediaType.TV, - seasons: newSeasons, - tmdbId: tvShow.id, - tvdbId: tvShow.external_ids.tvdb_id, - status: isAllSeasons + // Remove extras season. We dont count it for determining availability + const filteredSeasons = tvShow.seasons.filter( + (season) => season.season_number !== 0 + ); + + const isAllStandardSeasons = + newSeasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ).length ?? 0) >= + filteredSeasons.length; + + const isAll4kSeasons = + newSeasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length + + (media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ).length ?? 0) >= + filteredSeasons.length; + + if (media) { + // Update existing + media.seasons = [...media.seasons, ...newSeasons]; + + const newStandardSeasonAvailable = ( + media.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + const new4kSeasonAvailable = ( + media.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + // If at least one new season has become available, update + // the lastSeasonChange field so we can trigger notifications + if (newStandardSeasonAvailable > currentStandardSeasonAvailable) { + this.log( + `Detected ${ + newStandardSeasonAvailable - currentStandardSeasonAvailable + } new standard season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + media.mediaAddedAt = new Date(plexitem.addedAt * 1000); + } + + if (new4kSeasonAvailable > current4kSeasonAvailable) { + this.log( + `Detected ${ + new4kSeasonAvailable - current4kSeasonAvailable + } new 4K season(s) for ${tvShow.name}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + if (!media.mediaAddedAt) { + media.mediaAddedAt = new Date(plexitem.addedAt * 1000); + } + + media.status = isAllStandardSeasons ? MediaStatus.AVAILABLE - : MediaStatus.PARTIALLY_AVAILABLE, - }); - await mediaRepository.save(newMedia); - this.log(`Saved ${tvShow.name}`); - } + : media.seasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + media.status4k = isAll4kSeasons + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + await mediaRepository.save(media); + this.log(`Updating existing title: ${tvShow.name}`); + } else { + const newMedia = new Media({ + mediaType: MediaType.TV, + seasons: newSeasons, + tmdbId: tvShow.id, + tvdbId: tvShow.external_ids.tvdb_id, + mediaAddedAt: new Date(plexitem.addedAt * 1000), + status: isAllStandardSeasons + ? MediaStatus.AVAILABLE + : newSeasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + status4k: isAll4kSeasons + ? MediaStatus.AVAILABLE + : newSeasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, + }); + await mediaRepository.save(newMedia); + this.log(`Saved ${tvShow.name}`); + } + }); } else { this.log(`failed show: ${plexitem.guid}`); } @@ -322,22 +627,35 @@ class JobPlexSync { private async loop({ start = 0, end = BUNDLE_SIZE, + sessionId, }: { start?: number; end?: number; + sessionId?: string; } = {}) { const slicedItems = this.items.slice(start, end); - if (start < this.items.length && this.running) { + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { this.progress = start; await this.processItems(slicedItems); - await new Promise((resolve) => - setTimeout(async () => { - await this.loop({ + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ start: start + BUNDLE_SIZE, end: end + BUNDLE_SIZE, - }); - resolve(); + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); }, UPDATE_RATE) ); } @@ -351,9 +669,23 @@ class JobPlexSync { logger[level](message, { label: 'Plex Sync', ...optional }); } + // checks if any of this.libraries has Hama agent set in Plex + private async hasHamaAgent() { + const plexLibraries = await this.plexClient.getLibraries(); + return this.libraries.some((library) => + plexLibraries.some( + (plexLibrary) => + plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key + ) + ); + } + public async run(): Promise { const settings = getSettings(); - if (!this.running) { + const sessionId = uuid(); + this.sessionId = sessionId; + logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' }); + try { this.running = true; const userRepository = getRepository(User); const admin = await userRepository.findOne({ @@ -371,6 +703,27 @@ class JobPlexSync { (library) => library.enabled ); + this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4kMovie) { + this.log( + 'At least one 4K Radarr server was detected. 4K movie detection is now enabled', + 'info' + ); + } + + this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4kShow) { + this.log( + 'At least one 4K Sonarr server was detected. 4K series detection is now enabled', + 'info' + ); + } + + const hasHama = await this.hasHamaAgent(); + if (hasHama) { + await animeList.sync(); + } + if (this.isRecentOnly) { for (const library of this.libraries) { this.currentLibrary = library; @@ -397,18 +750,31 @@ class JobPlexSync { return mediaA.ratingKey === mediaB.ratingKey; }); - await this.loop(); + await this.loop({ sessionId }); } } else { for (const library of this.libraries) { this.currentLibrary = library; this.log(`Beginning to process library: ${library.name}`, 'info'); this.items = await this.plexClient.getLibraryContents(library.id); - await this.loop(); + await this.loop({ sessionId }); } } - this.running = false; - this.log('complete'); + this.log( + this.isRecentOnly + ? 'Recently Added Scan Complete' + : 'Full Scan Complete' + ); + } catch (e) { + logger.error('Sync interrupted', { + label: 'Plex Sync', + errorMessage: e.message, + }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } } } diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts new file mode 100644 index 00000000..c4c6e61a --- /dev/null +++ b/server/lib/email/index.ts @@ -0,0 +1,38 @@ +import nodemailer from 'nodemailer'; +import Email from 'email-templates'; +import { getSettings } from '../settings'; +class PreparedEmail extends Email { + public constructor() { + const settings = getSettings().notifications.agents.email; + + const transport = nodemailer.createTransport({ + host: settings.options.smtpHost, + port: settings.options.smtpPort, + secure: settings.options.secure, + tls: settings.options.allowSelfSigned + ? { + rejectUnauthorized: false, + } + : undefined, + auth: + settings.options.authUser && settings.options.authPass + ? { + user: settings.options.authUser, + pass: settings.options.authPass, + } + : undefined, + }); + super({ + message: { + from: { + name: settings.options.senderName, + address: settings.options.emailFrom, + }, + }, + send: true, + transport: transport, + }); + } +} + +export default PreparedEmail; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 9c1897d1..de6ed7ba 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -158,6 +158,28 @@ class DiscordAgent } ); + if (settings.main.applicationUrl) { + fields.push({ + name: 'View Media', + value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }); + } + break; + case Notification.MEDIA_DECLINED: + color = EmbedColors.RED; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Declined', + inline: true, + } + ); + if (settings.main.applicationUrl) { fields.push({ name: 'View Media', diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index d983a52e..a74a4c18 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -2,12 +2,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { hasNotificationType, Notification } from '..'; import path from 'path'; import { getSettings, NotificationAgentEmail } from '../../settings'; -import nodemailer from 'nodemailer'; -import Email from 'email-templates'; import logger from '../../../logger'; import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; +import PreparedEmail from '../../email'; class EmailAgent extends BaseAgent @@ -35,42 +34,6 @@ class EmailAgent return false; } - private getSmtpTransport() { - const emailSettings = this.getSettings().options; - - return nodemailer.createTransport({ - host: emailSettings.smtpHost, - port: emailSettings.smtpPort, - secure: emailSettings.secure, - tls: emailSettings.allowSelfSigned - ? { - rejectUnauthorized: false, - } - : undefined, - auth: - emailSettings.authUser && emailSettings.authPass - ? { - user: emailSettings.authUser, - pass: emailSettings.authPass, - } - : undefined, - }); - } - - private getNewEmail() { - const settings = this.getSettings(); - return new Email({ - message: { - from: { - name: settings.options.senderName, - address: settings.options.emailFrom, - }, - }, - send: true, - transport: this.getSmtpTransport(), - }); - } - private async sendMediaRequestEmail(payload: NotificationPayload) { // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; @@ -82,7 +45,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = this.getNewEmail(); + const email = new PreparedEmail(); email.send({ template: path.join( @@ -127,7 +90,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = this.getNewEmail(); + const email = new PreparedEmail(); email.send({ template: path.join( @@ -166,7 +129,7 @@ class EmailAgent // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join( @@ -199,11 +162,48 @@ class EmailAgent } } + private async sendMediaDeclinedEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; + try { + const email = new PreparedEmail(); + + await email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: 'Your request for the following media was declined:', + mediaName: payload.subject, + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.notifyUser.username, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + requestType: 'Request Declined', + }, + }); + return true; + } catch (e) { + logger.error('Mail notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + private async sendMediaAvailableEmail(payload: NotificationPayload) { // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join( @@ -240,7 +240,7 @@ class EmailAgent // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join(__dirname, '../../../templates/email/test-email'), @@ -275,6 +275,9 @@ class EmailAgent case Notification.MEDIA_APPROVED: this.sendMediaApprovedEmail(payload); break; + case Notification.MEDIA_DECLINED: + this.sendMediaDeclinedEmail(payload); + break; case Notification.MEDIA_AVAILABLE: this.sendMediaAvailableEmail(payload); break; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 072352ab..158d01c7 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -72,6 +72,13 @@ class PushoverAgent message += `Requested By\n${user}\n\n`; message += `Status\nAvailable\n`; break; + case Notification.MEDIA_DECLINED: + messageTitle = 'Request Declined'; + message += `${title}\n\n`; + message += `${plot}\n\n`; + message += `Requested By\n${user}\n\n`; + message += `Status\nDeclined\n`; + break; case Notification.TEST_NOTIFICATION: messageTitle = 'Test Notification'; message += `${title}\n\n`; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 03f901b2..df338884 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -96,6 +96,22 @@ class SlackAgent actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; } break; + case Notification.MEDIA_DECLINED: + header = 'Request Declined'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nDeclined', + } + ); + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; case Notification.MEDIA_AVAILABLE: header = 'Now available!'; fields.push( diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 2b5a9fdf..62de93ec 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -70,6 +70,14 @@ class TelegramAgent 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`; + break; case Notification.MEDIA_AVAILABLE: message += `\*Now available\\!\*\n`; diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts new file mode 100644 index 00000000..d0e502e8 --- /dev/null +++ b/server/lib/notifications/agents/webhook.ts @@ -0,0 +1,139 @@ +import axios from 'axios'; +import { get } from 'lodash'; +import { hasNotificationType, Notification } from '..'; +import { MediaStatus } from '../../../constants/media'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentWebhook } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +type KeyMapFunction = ( + payload: NotificationPayload, + type: Notification +) => string; + +const KeyMap: Record = { + notification_type: (_payload, type) => Notification[type], + subject: 'subject', + message: 'message', + image: 'image', + notifyuser_username: 'notifyUser.username', + notifyuser_email: 'notifyUser.email', + notifyuser_avatar: 'notifyUser.avatar', + media_tmdbid: 'media.tmdbId', + media_imdbid: 'media.imdbId', + media_tvdbid: 'media.tvdbId', + media_type: 'media.mediaType', + media_status: (payload) => + payload.media?.status ? MediaStatus[payload.media?.status] : '', + media_status4k: (payload) => + payload.media?.status ? MediaStatus[payload.media?.status4k] : '', +}; + +class WebhookAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentWebhook { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.webhook; + } + + private parseKeys( + finalPayload: Record, + payload: NotificationPayload, + type: Notification + ): Record { + Object.keys(finalPayload).forEach((key) => { + if (key === '{{extra}}') { + finalPayload.extra = payload.extra ?? []; + delete finalPayload[key]; + key = 'extra'; + } else if (key === '{{media}}') { + if (payload.media) { + finalPayload.media = finalPayload[key]; + } else { + finalPayload.media = null; + } + delete finalPayload[key]; + key = 'media'; + } + + if (typeof finalPayload[key] === 'string') { + Object.keys(KeyMap).forEach((keymapKey) => { + const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap]; + finalPayload[key] = (finalPayload[key] as string).replace( + `{{${keymapKey}}}`, + typeof keymapValue === 'function' + ? keymapValue(payload, type) + : get(payload, keymapValue) ?? '' + ); + }); + } else if (finalPayload[key] && typeof finalPayload[key] === 'object') { + finalPayload[key] = this.parseKeys( + finalPayload[key] as Record, + payload, + type + ); + } + }); + + return finalPayload; + } + + private buildPayload(type: Notification, payload: NotificationPayload) { + const payloadString = Buffer.from( + this.getSettings().options.jsonPayload, + 'base64' + ).toString('ascii'); + + const parsedJSON = JSON.parse(JSON.parse(payloadString)); + + return this.parseKeys(parsedJSON, payload, type); + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + this.getSettings().options.webhookUrl && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending webhook notification', { label: 'Notifications' }); + try { + const { webhookUrl, authHeader } = this.getSettings().options; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, this.buildPayload(type, payload), { + headers: { + Authorization: authHeader, + }, + }); + + return true; + } catch (e) { + logger.error('Error sending Webhook notification', { + label: 'Notifications', + errorMessage: e.message, + }); + return false; + } + } +} + +export default WebhookAgent; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 07127cf2..23fba116 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -7,6 +7,7 @@ export enum Notification { MEDIA_AVAILABLE = 8, MEDIA_FAILED = 16, TEST_NOTIFICATION = 32, + MEDIA_DECLINED = 64, } export const hasNotificationType = ( diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 6d328f8c..cfda793c 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -9,6 +9,10 @@ export enum Permission { AUTO_APPROVE = 128, AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_TV = 512, + REQUEST_4K = 1024, + REQUEST_4K_MOVIE = 2048, + REQUEST_4K_TV = 4096, + REQUEST_ADVANCED = 8192, } /** diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cea7774a..b9ad92a9 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -55,6 +55,11 @@ interface PublicSettings { initialized: boolean; } +interface FullPublicSettings extends PublicSettings { + movie4kEnabled: boolean; + series4kEnabled: boolean; +} + export interface NotificationAgentConfig { enabled: boolean; types: number; @@ -101,12 +106,21 @@ export interface NotificationAgentPushover extends NotificationAgentConfig { }; } +export interface NotificationAgentWebhook extends NotificationAgentConfig { + options: { + webhookUrl: string; + jsonPayload: string; + authHeader: string; + }; +} + interface NotificationAgents { email: NotificationAgentEmail; discord: NotificationAgentDiscord; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; pushover: NotificationAgentPushover; + webhook: NotificationAgentWebhook; } interface NotificationSettings { @@ -123,7 +137,9 @@ interface AllSettings { notifications: NotificationSettings; } -const SETTINGS_PATH = path.join(__dirname, '../../config/settings.json'); +const SETTINGS_PATH = process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/settings.json` + : path.join(__dirname, '../../config/settings.json'); class Settings { private data: AllSettings; @@ -194,6 +210,16 @@ class Settings { sound: '', }, }, + webhook: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + authHeader: '', + jsonPayload: + 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', + }, + }, }, }, }; @@ -246,6 +272,18 @@ class Settings { this.data.public = data; } + get fullPublicSettings(): FullPublicSettings { + return { + ...this.data.public, + movie4kEnabled: this.data.radarr.some( + (radarr) => radarr.is4k && radarr.isDefault + ), + series4kEnabled: this.data.sonarr.some( + (sonarr) => sonarr.is4k && sonarr.isDefault + ), + }; + } + get notifications(): NotificationSettings { return this.data.notifications; } diff --git a/server/logger.ts b/server/logger.ts index 75e80151..824de630 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -42,7 +42,9 @@ const logger = winston.createLogger({ ), }), new winston.transports.DailyRotateFile({ - filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), + filename: process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log` + : path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/1610070934506-LocalUsers.ts new file mode 100644 index 00000000..0ece00f4 --- /dev/null +++ b/server/migration/1610070934506-LocalUsers.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class LocalUsers1610070934506 implements MigrationInterface { + name = 'LocalUsers1610070934506'; + + 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 NOT NULL, "plexId" integer NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" 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 NOT NULL, "plexId" integer NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + 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 NOT NULL, "plexId" integer NOT NULL, "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')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts new file mode 100644 index 00000000..a313bf13 --- /dev/null +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Add4kStatusFields1610370640747 implements MigrationInterface { + name = 'Add4kStatusFields1610370640747'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"` + ); + await queryRunner.query(`DROP TABLE "season"`); + await queryRunner.query( + `ALTER TABLE "temporary_season" RENAME TO "season"` + ); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + 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, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" 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, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `ALTER TABLE "season" RENAME TO "temporary_season"` + ); + await queryRunner.query( + `CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"` + ); + await queryRunner.query(`DROP TABLE "temporary_season"`); + } +} diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts new file mode 100644 index 00000000..78dbc06e --- /dev/null +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaAddedFieldToMedia1610522845513 + implements MigrationInterface { + name = 'AddMediaAddedFieldToMedia1610522845513'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b1fb4bf8..5f60d512 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,6 +6,7 @@ import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; import logger from '../logger'; import { getSettings } from '../lib/settings'; +import { UserType } from '../constants/user'; const authRoutes = Router(); @@ -67,6 +68,7 @@ authRoutes.post('/login', async (req, res, next) => { plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(user); } @@ -89,6 +91,7 @@ authRoutes.post('/login', async (req, res, next) => { plexToken: account.authToken, permissions: settings.main.defaultPermissions, avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(user); } else { @@ -126,6 +129,53 @@ authRoutes.post('/login', async (req, res, next) => { } }); +authRoutes.post('/local', async (req, res, next) => { + const userRepository = getRepository(User); + const body = req.body as { email?: string; password?: string }; + + if (!body.email || !body.password) { + return res + .status(500) + .json({ error: 'You must provide an email and a password' }); + } + try { + const user = await userRepository.findOne({ + select: ['id', 'password'], + where: { email: body.email, userType: UserType.LOCAL }, + }); + + const isCorrectCredentials = await user?.passwordMatch(body.password); + + // User doesn't exist or credentials are incorrect + if (!isCorrectCredentials) { + logger.info('Failed login attempt from user with incorrect credentials', { + label: 'Auth', + account: { + email: body.email, + password: '__REDACTED__', + }, + }); + return next({ + status: 403, + message: 'You do not have access to this Plex server', + }); + } + + // Set logged in session + if (user && req.session) { + req.session.userId = user.id; + } + + return res.status(200).json(user?.filter() ?? {}); + } catch (e) { + logger.error(e.message, { label: 'Auth' }); + return next({ + status: 500, + message: 'Something went wrong.', + }); + } +}); + authRoutes.get('/logout', (req, res, next) => { req.session?.destroy((err) => { if (err) { diff --git a/server/routes/index.ts b/server/routes/index.ts index 29466bd7..282f0be8 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -14,6 +14,7 @@ import mediaRoutes from './media'; import personRoutes from './person'; import collectionRoutes from './collection'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; +import serviceRoutes from './service'; const router = Router(); @@ -30,7 +31,7 @@ router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); - return res.status(200).json(settings.public); + return res.status(200).json(settings.fullPublicSettings); }); router.use( '/settings', @@ -45,6 +46,7 @@ router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); +router.use('/service', isAuthenticated(), serviceRoutes); router.use('/auth', authRoutes); router.get('/', (_req, res) => { diff --git a/server/routes/media.ts b/server/routes/media.ts index 36e64e6e..f7d67d5c 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -47,6 +47,10 @@ mediaRoutes.get('/', async (req, res, next) => { updatedAt: 'DESC', }; break; + case 'mediaAdded': + sortFilter = { + mediaAddedAt: 'DESC', + }; } try { diff --git a/server/routes/request.ts b/server/routes/request.ts index ea82825c..baa73616 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -110,15 +110,21 @@ requestRoutes.post( media = new Media({ tmdbId: tmdbMedia.id, tvdbId: tmdbMedia.external_ids.tvdb_id, - status: MediaStatus.PENDING, + status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, }); await mediaRepository.save(media); } else { - if (media.status === MediaStatus.UNKNOWN) { + if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { media.status = MediaStatus.PENDING; await mediaRepository.save(media); } + + if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { + media.status4k = MediaStatus.PENDING; + await mediaRepository.save(media); + } } if (req.body.mediaType === 'movie') { @@ -137,6 +143,10 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) ? req.user : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, }); await requestRepository.save(request); @@ -149,13 +159,15 @@ requestRoutes.post( // already requested. In the case they were, we just throw out any duplicates but still approve the request. // (Unless there are no seasons, in which case we abort) if (media.requests) { - existingSeasons = media.requests.reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); + existingSeasons = media.requests + .filter((request) => request.is4k === req.body.is4k) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + return [...seasons, ...combinedSeasons]; + }, [] as number[]); } const finalSeasons = requestedSeasons.filter( @@ -186,6 +198,10 @@ requestRoutes.post( req.user?.hasPermission(Permission.AUTO_APPROVE_TV) ? req.user : undefined, + is4k: req.body.is4k, + serverId: req.body.serverId, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, seasons: finalSeasons.map( (sn) => new SeasonRequest({ @@ -205,11 +221,31 @@ requestRoutes.post( next({ status: 500, message: 'Invalid media type' }); } catch (e) { - next({ message: e.message, status: 500 }); + next({ status: 500, message: e.message }); } } ); +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, + }); + + return res.status(200).json({ + pending: pendingCount, + approved: approvedCount, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + requestRoutes.get('/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); @@ -225,6 +261,102 @@ requestRoutes.get('/:requestId', async (req, res, next) => { } }); +requestRoutes.put<{ requestId: string }>( + '/:requestId', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + try { + const request = await requestRepository.findOne( + Number(req.params.requestId) + ); + + if (!request) { + return next({ status: 404, message: 'Request not found' }); + } + + if (req.body.mediaType === 'movie') { + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + + requestRepository.save(request); + } else if (req.body.mediaType === 'tv') { + const mediaRepository = getRepository(Media); + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + + const requestedSeasons = req.body.seasons as number[] | undefined; + + if (!requestedSeasons || requestedSeasons.length === 0) { + throw new Error( + 'Missing seasons. If you want to cancel a tv request, use the DELETE method.' + ); + } + + // Get existing media so we can work with all the requests + const media = await mediaRepository.findOneOrFail({ + where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, + relations: ['requests'], + }); + + // Get all requested seasons that are not part of this request we are editing + const existingSeasons = media.requests + .filter((r) => r.is4k === request.is4k && r.id !== request.id) + .reduce((seasons, r) => { + const combinedSeasons = r.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + + const filteredSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); + + if (filteredSeasons.length === 0) { + return next({ + status: 202, + message: 'No seasons available to request', + }); + } + + const newSeasons = requestedSeasons.filter( + (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) + ); + + request.seasons = request.seasons.filter((rs) => + filteredSeasons.includes(rs.seasonNumber) + ); + + if (newSeasons.length > 0) { + logger.debug('Adding new seasons to request', { + label: 'Media Request', + newSeasons, + }); + request.seasons.push( + ...newSeasons.map( + (ns) => + new SeasonRequest({ + seasonNumber: ns, + status: MediaRequestStatus.PENDING, + }) + ) + ); + } + + await requestRepository.save(request); + } + + return res.status(200).json(request); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + requestRoutes.delete('/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); @@ -280,6 +412,7 @@ requestRoutes.post<{ } } ); + requestRoutes.get<{ requestId: string; status: 'pending' | 'approve' | 'decline'; diff --git a/server/routes/service.ts b/server/routes/service.ts new file mode 100644 index 00000000..c163a940 --- /dev/null +++ b/server/routes/service.ts @@ -0,0 +1,148 @@ +import { Router } from 'express'; +import RadarrAPI from '../api/radarr'; +import SonarrAPI from '../api/sonarr'; +import { + ServiceCommonServer, + ServiceCommonServerWithDetails, +} from '../interfaces/api/serviceInterfaces'; +import { getSettings } from '../lib/settings'; + +const serviceRoutes = Router(); + +serviceRoutes.get('/radarr', async (req, res) => { + const settings = getSettings(); + + const filteredRadarrServers: ServiceCommonServer[] = settings.radarr.map( + (radarr) => ({ + id: radarr.id, + name: radarr.name, + is4k: radarr.is4k, + isDefault: radarr.isDefault, + activeDirectory: radarr.activeDirectory, + activeProfileId: radarr.activeProfileId, + }) + ); + + return res.status(200).json(filteredRadarrServers); +}); + +serviceRoutes.get<{ radarrId: string }>( + '/radarr/:radarrId', + async (req, res, next) => { + const settings = getSettings(); + + const radarrSettings = settings.radarr.find( + (radarr) => radarr.id === Number(req.params.radarrId) + ); + + if (!radarrSettings) { + return next({ + status: 404, + message: 'Radarr server with provided ID does not exist.', + }); + } + + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + const rootFolders = await radarr.getRootFolders(); + + return res.status(200).json({ + server: { + id: radarrSettings.id, + name: radarrSettings.name, + is4k: radarrSettings.is4k, + isDefault: radarrSettings.isDefault, + activeDirectory: radarrSettings.activeDirectory, + activeProfileId: radarrSettings.activeProfileId, + }, + 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); + } +); + +serviceRoutes.get('/sonarr', async (req, res) => { + const settings = getSettings(); + + const filteredSonarrServers: ServiceCommonServer[] = settings.sonarr.map( + (sonarr) => ({ + id: sonarr.id, + name: sonarr.name, + is4k: sonarr.is4k, + isDefault: sonarr.isDefault, + activeDirectory: sonarr.activeDirectory, + activeProfileId: sonarr.activeProfileId, + activeAnimeProfileId: sonarr.activeAnimeProfileId, + activeAnimeDirectory: sonarr.activeAnimeDirectory, + }) + ); + + return res.status(200).json(filteredSonarrServers); +}); + +serviceRoutes.get<{ sonarrId: string }>( + '/sonarr/:sonarrId', + async (req, res, next) => { + const settings = getSettings(); + + const sonarrSettings = settings.sonarr.find( + (radarr) => radarr.id === Number(req.params.sonarrId) + ); + + if (!sonarrSettings) { + return next({ + status: 404, + message: 'Radarr server with provided ID does not exist.', + }); + } + + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await sonarr.getProfiles(); + const rootFolders = await sonarr.getRootFolders(); + + 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); + } +); + +export default serviceRoutes; diff --git a/server/routes/settings.ts b/server/routes/settings/index.ts similarity index 67% rename from server/routes/settings.ts rename to server/routes/settings/index.ts index 91d196e1..0b6ebaf4 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings/index.ts @@ -5,31 +5,28 @@ import { SonarrSettings, Library, MainSettings, -} from '../lib/settings'; +} from '../../lib/settings'; import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; -import PlexAPI from '../api/plexapi'; -import { jobPlexFullSync } from '../job/plexsync'; -import SonarrAPI from '../api/sonarr'; -import RadarrAPI from '../api/radarr'; -import logger from '../logger'; -import { scheduledJobs } from '../job/schedule'; -import { Permission } from '../lib/permissions'; -import { isAuthenticated } from '../middleware/auth'; +import { User } from '../../entity/User'; +import PlexAPI from '../../api/plexapi'; +import { jobPlexFullSync } from '../../job/plexsync'; +import SonarrAPI from '../../api/sonarr'; +import RadarrAPI from '../../api/radarr'; +import logger from '../../logger'; +import { scheduledJobs } from '../../job/schedule'; +import { Permission } from '../../lib/permissions'; +import { isAuthenticated } from '../../middleware/auth'; import { merge, omit } from 'lodash'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import { getAppVersion } from '../utils/appVersion'; -import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; -import { Notification } from '../lib/notifications'; -import DiscordAgent from '../lib/notifications/agents/discord'; -import EmailAgent from '../lib/notifications/agents/email'; -import SlackAgent from '../lib/notifications/agents/slack'; -import TelegramAgent from '../lib/notifications/agents/telegram'; -import PushoverAgent from '../lib/notifications/agents/pushover'; +import Media from '../../entity/Media'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { getAppVersion } from '../../utils/appVersion'; +import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; +import notificationRoutes from './notifications'; const settingsRoutes = Router(); +settingsRoutes.use('/notifications', notificationRoutes); + const filteredMainSettings = ( user: User, main: MainSettings @@ -437,176 +434,6 @@ settingsRoutes.get( } ); -settingsRoutes.get('/notifications/discord', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.discord); -}); - -settingsRoutes.post('/notifications/discord', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.discord = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.discord); -}); - -settingsRoutes.post('/notifications/discord/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const discordAgent = new DiscordAgent(req.body); - discordAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/slack', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.slack); -}); - -settingsRoutes.post('/notifications/slack', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.slack = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.slack); -}); - -settingsRoutes.post('/notifications/slack/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const slackAgent = new SlackAgent(req.body); - slackAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/telegram', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.telegram); -}); - -settingsRoutes.post('/notifications/telegram', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.telegram = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.telegram); -}); - -settingsRoutes.post('/notifications/telegram/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const telegramAgent = new TelegramAgent(req.body); - telegramAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/pushover', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.pushover); -}); - -settingsRoutes.post('/notifications/pushover', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.pushover = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.pushover); -}); - -settingsRoutes.post('/notifications/pushover/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const pushoverAgent = new PushoverAgent(req.body); - pushoverAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/email', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.email); -}); - -settingsRoutes.post('/notifications/email', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.email = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.email); -}); - -settingsRoutes.post('/notifications/email/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const emailAgent = new EmailAgent(req.body); - emailAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts new file mode 100644 index 00000000..10b0e7a5 --- /dev/null +++ b/server/routes/settings/notifications.ts @@ -0,0 +1,265 @@ +import { Router } from 'express'; +import { getSettings } from '../../lib/settings'; +import { Notification } from '../../lib/notifications'; +import DiscordAgent from '../../lib/notifications/agents/discord'; +import EmailAgent from '../../lib/notifications/agents/email'; +import 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'; + +const notificationRoutes = Router(); + +notificationRoutes.get('/discord', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.discord); +}); + +notificationRoutes.post('/discord', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.discord = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.discord); +}); + +notificationRoutes.post('/discord/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const discordAgent = new DiscordAgent(req.body); + discordAgent.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('/slack', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +notificationRoutes.post('/slack', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.slack = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +notificationRoutes.post('/slack/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const slackAgent = new SlackAgent(req.body); + slackAgent.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('/telegram', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.telegram); +}); + +notificationRoutes.post('/telegram', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.telegram = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.telegram); +}); + +notificationRoutes.post('/telegram/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const telegramAgent = new TelegramAgent(req.body); + telegramAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/pushover', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +notificationRoutes.post('/pushover', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.pushover = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +notificationRoutes.post('/pushover/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const pushoverAgent = new PushoverAgent(req.body); + pushoverAgent.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('/email', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.email); +}); + +notificationRoutes.post('/email', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.email = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.email); +}); + +notificationRoutes.post('/email/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const emailAgent = new EmailAgent(req.body); + emailAgent.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('/webhook', (_req, res) => { + const settings = getSettings(); + + const webhookSettings = settings.notifications.agents.webhook; + + const response: typeof webhookSettings = { + enabled: webhookSettings.enabled, + types: webhookSettings.types, + options: { + ...webhookSettings.options, + jsonPayload: JSON.parse( + Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString( + 'ascii' + ) + ), + }, + }; + + res.status(200).json(response); +}); + +notificationRoutes.post('/webhook', (req, res, next) => { + const settings = getSettings(); + try { + JSON.parse(req.body.options.jsonPayload); + + settings.notifications.agents.webhook = { + enabled: req.body.enabled, + types: req.body.types, + options: { + jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( + 'base64' + ), + webhookUrl: req.body.options.webhookUrl, + authHeader: req.body.options.authHeader, + }, + }; + settings.save(); + + res.status(200).json(settings.notifications.agents.webhook); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +notificationRoutes.post('/webhook/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + try { + JSON.parse(req.body.options.jsonPayload); + + const testBody = { + enabled: req.body.enabled, + types: req.body.types, + options: { + jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( + 'base64' + ), + webhookUrl: req.body.options.webhookUrl, + authHeader: req.body.options.authHeader, + }, + }; + + const webhookAgent = new WebhookAgent(testBody); + webhookAgent.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(); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +export default notificationRoutes; diff --git a/server/routes/user.ts b/server/routes/user.ts index acbdfdb3..b51b56cd 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -6,6 +6,8 @@ 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(); @@ -19,13 +21,34 @@ router.get('/', async (_req, res) => { router.post('/', async (req, res, next) => { try { + const settings = getSettings().notifications.agents.email; + + 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.enabled) { + throw new Error('Email notifications must be enabled'); + } + const user = new User({ - email: req.body.email, - permissions: req.body.permissions, + avatar: body.avatar ?? avatar, + username: body.username ?? body.email, + email: body.email, + password: body.password, + permissions: Permission.REQUEST, 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) { @@ -179,6 +202,7 @@ router.post('/import-from-plex', async (req, res, next) => { plexId: parseInt(account.id), plexToken: '', avatar: account.thumb, + userType: UserType.PLEX, }); await userRepository.save(newUser); createdUsers.push(newUser); diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index fe826284..92c4803e 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -8,12 +8,16 @@ import TheMovieDb from '../api/themoviedb'; import { MediaStatus, MediaType } from '../constants/media'; import Media from '../entity/Media'; import { MediaRequest } from '../entity/MediaRequest'; +import Season from '../entity/Season'; import notificationManager, { Notification } from '../lib/notifications'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { - private async notifyAvailableMovie(entity: Media) { - if (entity.status === MediaStatus.AVAILABLE) { + private async notifyAvailableMovie(entity: Media, dbEntity?: Media) { + if ( + entity.status === MediaStatus.AVAILABLE && + dbEntity?.status !== MediaStatus.AVAILABLE + ) { if (entity.mediaType === MediaType.MOVIE) { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ @@ -39,10 +43,13 @@ export class MediaSubscriber implements EntitySubscriberInterface { } private async notifyAvailableSeries(entity: Media, dbEntity: Media) { + const seasonRepository = getRepository(Season); const newAvailableSeasons = entity.seasons .filter((season) => season.status === MediaStatus.AVAILABLE) .map((season) => season.seasonNumber); - const oldAvailableSeasons = dbEntity.seasons + const oldSeasonIds = dbEntity.seasons.map((season) => season.id); + const oldSeasons = await seasonRepository.findByIds(oldSeasonIds); + const oldAvailableSeasons = oldSeasons .filter((season) => season.status === MediaStatus.AVAILABLE) .map((season) => season.seasonNumber); @@ -96,11 +103,15 @@ export class MediaSubscriber implements EntitySubscriberInterface { } public beforeUpdate(event: UpdateEvent): void { + if (!event.entity) { + return; + } + if ( event.entity.mediaType === MediaType.MOVIE && event.entity.status === MediaStatus.AVAILABLE ) { - this.notifyAvailableMovie(event.entity); + this.notifyAvailableMovie(event.entity, event.databaseEntity); } if ( diff --git a/server/templates/email/password/html.pug b/server/templates/email/password/html.pug new file mode 100644 index 00000000..afa2cdb8 --- /dev/null +++ b/server/templates/email/password/html.pug @@ -0,0 +1,98 @@ +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;\ + ') + | Overseerr + 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;') + | Your new password is: + div(style='font-size: 16px; text-align: center') + | #{password} + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=applicationUrl style='color: #3869d4') Open Overseerr +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;\ + ') + | Overseerr. diff --git a/server/templates/email/password/subject.pug b/server/templates/email/password/subject.pug new file mode 100644 index 00000000..51196b1d --- /dev/null +++ b/server/templates/email/password/subject.pug @@ -0,0 +1 @@ += `Password reset - Overseerr` diff --git a/server/utils/asyncLock.ts b/server/utils/asyncLock.ts new file mode 100644 index 00000000..51794a98 --- /dev/null +++ b/server/utils/asyncLock.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'events'; + +// whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save" +// then you need to put all of that code in "await asyncLock.dispatch" callback based on media id +// this will guarantee that only one part of code will run at the same for this media id to avoid code +// trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue) + +class AsyncLock { + private locked: { [key: string]: boolean } = {}; + private ee = new EventEmitter(); + + constructor() { + this.ee.setMaxListeners(0); + } + + private acquire = async (key: string) => { + return new Promise((resolve) => { + if (!this.locked[key]) { + this.locked[key] = true; + return resolve(undefined); + } + + const nextAcquire = () => { + if (!this.locked[key]) { + this.locked[key] = true; + this.ee.removeListener(key, nextAcquire); + return resolve(undefined); + } + }; + + this.ee.on(key, nextAcquire); + }); + }; + + private release = (key: string): void => { + delete this.locked[key]; + setImmediate(() => this.ee.emit(key)); + }; + + public dispatch = async ( + key: string | number, + callback: () => Promise + ) => { + const skey = String(key); + await this.acquire(skey); + try { + await callback(); + } finally { + this.release(skey); + } + }; +} + +export default AsyncLock; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 00000000..b5dd5491 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,93 @@ +name: overseerr +adopt-info: overseerr +license: MIT +summary: Request management and media discovery tool for the Plex ecosystem. +description: > + 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, Radarr and Plex! +base: core18 +confinement: strict + +parts: + overseerr: + plugin: nodejs + nodejs-version: "12.18.4" + nodejs-package-manager: "yarn" + nodejs-yarn-version: v1.22.5 + build-packages: + - git + - on arm64: + - build-essential + - automake + - python-gi + - python-gi-dev + - on armhf: + - libatomic1 + - build-essential + - automake + - python-gi + - python-gi-dev + source: . + override-pull: | + snapcraftctl pull + # Get information to determine snap grade and version + BRANCH=$(git rev-parse --abbrev-ref HEAD) + COMMIT=$(git rev-parse HEAD) + COMMIT_SHORT=$(git rev-parse --short HEAD) + VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/') + if [ "$VERSION" = "v0.1.0" ]; then + SNAP_VERSION=$COMMIT_SHORT + GRADE=devel + else + SNAP_VERSION=$VERSION + GRADE=stable + fi + # Write COMMIT_TAG as it is needed durring the build process + echo $COMMIT > commit.txt + # Print debug info for build version + echo "{\"commitShort\": \"$COMMIT_SHORT\", \ + \"version\": \"$VERSION\", \ + \"snapVersion\": \"$SNAP_VERSION\", \ + \"snapGrade\": \"$GRADE\", \ + \"branch\": \"$BRANCH\", \ + \"commit\": \"$COMMIT\"}" + echo "{\"commitTag\": \"$COMMIT\"}" > committag.json + # Set snap version and grade + snapcraftctl set-version "$SNAP_VERSION" + snapcraftctl set-grade "$GRADE" + build-environment: + - PATH: "$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH" + override-build: | + set -e + # Set COMMIT_TAG before the build begins + export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt) + snapcraftctl build + yarn build + # Copy files needed for staging + cp $SNAPCRAFT_PART_BUILD/committag.json $SNAPCRAFT_PART_INSTALL/ + cp -R $SNAPCRAFT_PART_BUILD/.next $SNAPCRAFT_PART_INSTALL/ + cp -R $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/ + cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/ + # Remove .github and gitbook as it will fail snap lint + rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml + stage: + [ .next, ./* ] + prime: + [ .next, ./* ] + +apps: + deamon: + command: /bin/sh -c "cd $SNAP && node dist/index.js" + daemon: simple + restart-condition: on-failure + restart-delay: 5s + plugs: + - home + - network + - network-bind + environment: + PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH" + OVERSEERR_SNAP: "True" + CONFIG_DIRECTORY: $SNAP_USER_COMMON + LOG_LEVEL: "debug" + NODE_ENV: "production" diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg new file mode 100644 index 00000000..20259b64 --- /dev/null +++ b/src/assets/bolt.svg @@ -0,0 +1 @@ + diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index ce93f0ca..00000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/useradd.svg b/src/assets/useradd.svg new file mode 100644 index 00000000..1fe26d46 --- /dev/null +++ b/src/assets/useradd.svg @@ -0,0 +1 @@ + diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 84c529c9..0202c27d 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -58,9 +58,9 @@ const Alert: React.FC = ({ title, children, type }) => {
{design.svg}
-

+
{title} -

+
{children}
diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 0165d5ae..422436c3 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -39,10 +39,10 @@ const ButtonWithDropdown: React.FC = ({ useClickOutside(buttonRef, () => setIsOpen(false)); return ( - +