diff --git a/.all-contributorsrc b/.all-contributorsrc
index 1c85e63f..a230a468 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -620,6 +620,51 @@
"contributions": [
"translation"
]
+ },
+ {
+ "login": "schambers",
+ "name": "Sean Chambers",
+ "avatar_url": "https://avatars.githubusercontent.com/u/31563?v=4",
+ "profile": "https://github.com/schambers",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "deniscerri",
+ "name": "deniscerri",
+ "avatar_url": "https://avatars.githubusercontent.com/u/64997243?v=4",
+ "profile": "https://github.com/deniscerri",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "tomgacz",
+ "name": "tomgacz",
+ "avatar_url": "https://avatars.githubusercontent.com/u/14138209?v=4",
+ "profile": "https://github.com/tomgacz",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "Andersborrits",
+ "name": "Andersborrits",
+ "avatar_url": "https://avatars.githubusercontent.com/u/29452218?v=4",
+ "profile": "https://github.com/Andersborrits",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "Maxentr",
+ "name": "Maxent",
+ "avatar_url": "https://avatars.githubusercontent.com/u/67283154?v=4",
+ "profile": "http://maxentrouault.fr",
+ "contributions": [
+ "translation"
+ ]
}
],
"badgeTemplate": "
-orange.svg\"/>",
diff --git a/.dockerignore b/.dockerignore
index 3ddaa574..7d669c86 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,6 +10,7 @@
.gitconfig
.github
.gitignore
+.husky
.next
.prettierignore
config/db/*
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f090d796..6effae04 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,5 +1,5 @@
# Global code ownership
-* @sct
+* @sct @TheCatLady @danshilm
# Documentation
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
@@ -12,3 +12,4 @@
# i18n locale files
/src/i18n/locale/ @sct @TheCatLady
+/src/i18n/locale/en.json @sct @TheCatLady @danshilm
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4ea4a77a..5aa1572d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,11 +11,12 @@ on:
jobs:
test:
name: Lint & Test Build
+ if: github.event_name == 'pull_request'
runs-on: ubuntu-20.04
- container: node:14.18-alpine
+ container: node:16.14-alpine
steps:
- name: Checkout
- uses: actions/checkout@v2.3.4
+ uses: actions/checkout@v3
- name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
@@ -27,36 +28,35 @@ jobs:
build_and_push:
name: Build & Publish Docker Images
- needs: test
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.3.4
+ uses: actions/checkout@v3
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1.2.0
+ uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1.3.0
+ uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
- uses: actions/cache@v2.1.6
+ uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v2.5.0
+ uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
@@ -86,7 +86,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.6
+ uses: technote-space/workflow-conclusion-action@v2
- name: Combine Job Status
id: status
run: |
diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml
index 809d4706..866f6193 100644
--- a/.github/workflows/deploy_docs.yml
+++ b/.github/workflows/deploy_docs.yml
@@ -9,14 +9,14 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Generate Swagger UI
- uses: Legion2/swagger-ui-action@v1.1.2
+ uses: Legion2/swagger-ui-action@v1
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
- uses: peaceiris/actions-gh-pages@v3.8.0
+ uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index a4246428..307c9263 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -11,27 +11,27 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout
- uses: actions/checkout@v2.3.4
+ uses: actions/checkout@v3
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1.2.0
+ uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1.3.0
+ uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v2.5.0
+ uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c3b40e3e..53109486 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,46 +6,29 @@ on:
- master
jobs:
- test:
- name: Lint & Test Build
- runs-on: ubuntu-20.04
- container: node:14.18-alpine
- steps:
- - name: Checkout
- uses: actions/checkout@v2.3.4
- - name: Install dependencies
- env:
- HUSKY_SKIP_INSTALL: 1
- run: yarn
- - name: Lint
- run: yarn lint
- - name: Build
- run: yarn build
-
semantic-release:
name: Tag and release latest version
- needs: test
runs-on: ubuntu-20.04
steps:
- name: Checkout
- uses: actions/checkout@v2.3.4
+ uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Node.js
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v3
with:
- node-version: 14
+ node-version: 16
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1.2.0
+ uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1.3.0
+ uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v1.9.0
+ uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -72,7 +55,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
- uses: actions/checkout@v2.3.4
+ uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Switch to master branch
@@ -89,7 +72,7 @@ jobs:
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
- uses: docker/setup-qemu-action@v1.2.0
+ uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
@@ -103,7 +86,7 @@ jobs:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
- uses: diddlesnaps/snapcraft-review-tools-action@v1.3.0
+ uses: diddlesnaps/snapcraft-review-tools-action@v1
with:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
@@ -120,7 +103,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.6
+ uses: technote-space/workflow-conclusion-action@v2
- name: Combine Job Status
id: status
run: |
diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml
deleted file mode 100644
index ce1e1d24..00000000
--- a/.github/workflows/snap.yaml
+++ /dev/null
@@ -1,107 +0,0 @@
-name: Publish Snap
-
-on:
- push:
- branches:
- - develop
-
-jobs:
- jobs:
- name: Job Check
- runs-on: ubuntu-20.04
- if: "!contains(github.event.head_commit.message, '[skip ci]')"
- steps:
- - name: Cancel Previous Runs
- uses: styfle/cancel-workflow-action@0.9.0
- with:
- access_token: ${{ secrets.GITHUB_TOKEN }}
-
- test:
- name: Lint & Test Build
- needs: jobs
- runs-on: ubuntu-20.04
- container: node:14.18-alpine
- steps:
- - name: Checkout
- uses: actions/checkout@v2.3.4
- - 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.3.4
- - 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.2.0
- with:
- image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- - 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.3.0
- 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
- if: always() && !contains(github.event.head_commit.message, '[skip ci]')
- runs-on: ubuntu-20.04
- steps:
- - name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.6
- - 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 09d86bc6..f562333b 100644
--- a/.github/workflows/support.yml
+++ b/.github/workflows/support.yml
@@ -8,7 +8,7 @@ jobs:
support:
runs-on: ubuntu-20.04
steps:
- - uses: dessant/support-requests@v2.0.1
+ - uses: dessant/support-requests@v2
with:
github-token: ${{ github.token }}
support-label: 'support'
diff --git a/.gitignore b/.gitignore
index 0bc4be4a..7d606105 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ config/settings.json
config/logs/*.log*
config/logs/*.json
config/logs/*.log.gz
+config/logs/*.json.gz
config/logs/*-audit.json
# anidb mapping file
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100755
index 00000000..61e13c0e
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+[[ -n $HUSKY_BYPASS ]] || npx commitlint --edit $1
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 00000000..36af2198
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx lint-staged
diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg
new file mode 100755
index 00000000..17e2764c
--- /dev/null
+++ b/.husky/prepare-commit-msg
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+exec < /dev/tty && npx cz --hook || true
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index fb896f97..80a16c64 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -19,9 +19,6 @@
"stylelint.vscode-stylelint",
- "bradlc.vscode-tailwindcss",
-
- // https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
- "heybourn.headwind"
+ "bradlc.vscode-tailwindcss"
]
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9e16f9ea..e54e45d6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,9 +68,9 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- - It is okay to squash your pull request down into a single commit that fits this standard.
- Pull requests with commits not following this standard will **not** be merged.
-- Please make meaningful commits, or squash them.
+- Please make meaningful commits, or squash them prior to opening a pull request.
+ - Do not squash commits once people have begun reviewing your changes.
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
- You can create a "draft" pull request early to get feedback on your work.
diff --git a/Dockerfile b/Dockerfile
index c38730f5..8f3ed32c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:14.18-alpine AS BUILD_IMAGE
+FROM node:16.14-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -26,18 +26,18 @@ RUN yarn build
# remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
-RUN rm -rf src server
+RUN rm -rf src server .next/cache
RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
-FROM node:14.18-alpine
+FROM node:16.14-alpine
WORKDIR /app
-RUN apk add --no-cache tzdata tini
+RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
# copy from build image
COPY --from=BUILD_IMAGE /app ./
diff --git a/Dockerfile.local b/Dockerfile.local
index bb8536be..f0228b6b 100644
--- a/Dockerfile.local
+++ b/Dockerfile.local
@@ -1,4 +1,4 @@
-FROM node:14.18-alpine
+FROM node:16.14-alpine
COPY . /app
WORKDIR /app
diff --git a/README.md b/README.md
index 5e8e928e..6bca69eb 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -160,6 +160,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
 Shaaft 🌍 |
 sr093906 🌍 |
 Nackophilz 🌍 |
+  Sean Chambers 💻 |
+  deniscerri 🌍 |
+  tomgacz 🌍 |
+
+
+  Andersborrits 🌍 |
+  Maxent 🌍 |
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 2b309dcc..a10d7e71 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -14,6 +14,7 @@
- [Email](using-overseerr/notifications/email.md)
- [Web Push](using-overseerr/notifications/webpush.md)
- [Discord](using-overseerr/notifications/discord.md)
+ - [Gotify](using-overseerr/notifications/gotify.md)
- [LunaSea](using-overseerr/notifications/lunasea.md)
- [Pushbullet](using-overseerr/notifications/pushbullet.md)
- [Pushover](using-overseerr/notifications/pushover.md)
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index f3dce15f..98e26f59 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -143,7 +143,7 @@ or the Docker Desktop app:
Then, create and start the Overseerr container:
```bash
-docker run -d -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr
+docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr:latest
```
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
diff --git a/docs/support/need-help.md b/docs/support/need-help.md
index 8e2cc856..2d478252 100644
--- a/docs/support/need-help.md
+++ b/docs/support/need-help.md
@@ -19,6 +19,11 @@ Please try to include as much information as possible. A vague statement like "i
Try to answer the following questions:
+- What version of Overseerr are you running? (You can find this in Settings → About → Version.)
+- How did you install Overseerr? Are you using the official Docker or snap images, or images published by a third-party?
+- How are you accessing Overseerr?
+ - Are you accessing Overseerr through your reverse proxy or via a local IP address?
+ - What browser are you using? What browser extensions are enabled?
- What were you trying to do, and how did you attempt it?
- What command did you enter?
- What did you click on?
diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md
index c894b0b2..2bff1388 100644
--- a/docs/using-overseerr/notifications/README.md
+++ b/docs/using-overseerr/notifications/README.md
@@ -7,6 +7,7 @@ Overseerr currently supports the following notification agents:
- [Email](./email.md)
- [Web Push](./webpush.md)
- [Discord](./discord.md)
+- [Gotify](./gotify.md)
- [LunaSea](./lunasea.md)
- [Pushbullet](./pushbullet.md)
- [Pushover](./pushover.md)
diff --git a/docs/using-overseerr/notifications/gotify.md b/docs/using-overseerr/notifications/gotify.md
new file mode 100644
index 00000000..16e7cd59
--- /dev/null
+++ b/docs/using-overseerr/notifications/gotify.md
@@ -0,0 +1,15 @@
+# Gotify
+
+## Configuration
+
+### Server URL
+
+Set this to the URL of your Gotify server.
+
+### Application Token
+
+Add an application to your Gotify server, and set this field to the generated application token.
+
+{% hint style="info" %}
+Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
+{% endhint %}
diff --git a/docs/using-overseerr/notifications/pushbullet.md b/docs/using-overseerr/notifications/pushbullet.md
index 6c6ba853..6e9be9c2 100644
--- a/docs/using-overseerr/notifications/pushbullet.md
+++ b/docs/using-overseerr/notifications/pushbullet.md
@@ -11,3 +11,7 @@ User notifications are separate from system notifications, and the available not
### Access Token
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API.
+
+### Channel Tag (optional)
+
+Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.
diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md
index 686f82be..37a5c048 100644
--- a/docs/using-overseerr/notifications/webhooks.md
+++ b/docs/using-overseerr/notifications/webhooks.md
@@ -47,15 +47,15 @@ These variables are for the target recipient of the notification.
{% hint style="info" %}
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
-- Media Requested
-- Media Automatically Approved
-- Media Failed
+- Request Pending Approval
+- Request Automatically Approved
+- Request Processing Failed
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
-- Media Approved
-- Media Declined
-- Media Available
+- Request Approved
+- Request Declined
+- Request Available
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
{% endhint %}
diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md
index 275e469c..139e935a 100644
--- a/docs/using-overseerr/users/README.md
+++ b/docs/using-overseerr/users/README.md
@@ -8,9 +8,9 @@ The user account created during Overseerr setup is the "Owner" account, which ca
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**.
-### Importing Users from Plex
+### Importing Plex Users
-Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
+Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login.
diff --git a/next-env.d.ts b/next-env.d.ts
index 9bc3dd46..4f11a03d 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,5 +1,4 @@
///
-///
///
// NOTE: This file should not be edited
diff --git a/overseerr-api.yml b/overseerr-api.yml
index f8be0895..77282ea1 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -165,6 +165,9 @@ components:
port:
type: number
example: 32400
+ useSsl:
+ type: boolean
+ nullable: true
libraries:
type: array
readOnly: true
@@ -172,6 +175,7 @@ components:
$ref: '#/components/schemas/PlexLibrary'
webAppUrl:
type: string
+ nullable: true
example: 'https://app.plex.tv/desktop'
required:
- name
@@ -298,6 +302,26 @@ components:
- provides
- owned
- connection
+ TautulliSettings:
+ type: object
+ properties:
+ hostname:
+ type: string
+ nullable: true
+ example: 'tautulli.example.com'
+ port:
+ type: number
+ nullable: true
+ example: 8181
+ useSsl:
+ type: boolean
+ nullable: true
+ apiKey:
+ type: string
+ nullable: true
+ externalUrl:
+ type: string
+ nullable: true
RadarrSettings:
type: object
properties:
@@ -1138,6 +1162,8 @@ components:
type: string
webhookUrl:
type: string
+ enableMentions:
+ type: boolean
SlackSettings:
type: object
properties:
@@ -1213,6 +1239,9 @@ components:
properties:
accessToken:
type: string
+ channelTag:
+ type: string
+ nullable: true
PushoverSettings:
type: object
properties:
@@ -1229,6 +1258,22 @@ components:
type: string
userToken:
type: string
+ GotifySettings:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ example: false
+ types:
+ type: number
+ example: 2
+ options:
+ type: object
+ properties:
+ url:
+ type: string
+ token:
+ type: string
LunaSeaSettings:
type: object
properties:
@@ -1308,7 +1353,7 @@ components:
running:
type: boolean
example: false
- PersonDetail:
+ PersonDetails:
type: object
properties:
id:
@@ -1976,6 +2021,67 @@ paths:
type: array
items:
$ref: '#/components/schemas/PlexDevice'
+ /settings/plex/users:
+ get:
+ summary: Get Plex users
+ description: |
+ Returns a list of Plex users in a JSON array.
+
+ Requires the `MANAGE_USERS` permission.
+ tags:
+ - settings
+ - users
+ responses:
+ '200':
+ description: Plex users
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ title:
+ type: string
+ username:
+ type: string
+ email:
+ type: string
+ thumb:
+ type: string
+ /settings/tautulli:
+ get:
+ summary: Get Tautulli settings
+ description: Retrieves current Tautulli settings.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TautulliSettings'
+ post:
+ summary: Update Tautulli settings
+ description: Updates Tautulli settings with the provided values.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TautulliSettings'
+ responses:
+ '200':
+ description: 'Values were successfully updated'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TautulliSettings'
/settings/radarr:
get:
summary: Get Radarr settings
@@ -2679,6 +2785,52 @@ paths:
responses:
'204':
description: Test notification attempted
+ /settings/notifications/gotify:
+ get:
+ summary: Get Gotify notification settings
+ description: Returns current Gotify notification settings in a JSON object.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: Returned Gotify settings
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GotifySettings'
+ post:
+ summary: Update Gotify notification settings
+ description: Update Gotify notification settings with the provided values.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GotifySettings'
+ responses:
+ '200':
+ description: 'Values were sucessfully updated'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GotifySettings'
+ /settings/notifications/gotify/test:
+ post:
+ summary: Test Gotify settings
+ description: Sends a test notification to the Gotify agent.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GotifySettings'
+ responses:
+ '204':
+ description: Test notification attempted
/settings/notifications/slack:
get:
summary: Get Slack notification settings
@@ -2890,6 +3042,9 @@ paths:
type: string
nullable: true
example: Asia/Tokyo
+ appDataPath:
+ type: string
+ example: /app/config
/auth/me:
get:
summary: Get logged-in user
@@ -3010,6 +3165,13 @@ paths:
security: []
tags:
- users
+ parameters:
+ - in: path
+ name: guid
+ required: true
+ schema:
+ type: number
+ example: 1
responses:
'200':
description: OK
@@ -3132,11 +3294,22 @@ paths:
post:
summary: Import all users from Plex
description: |
- Requests users from the Plex Server and creates a new user for each of them
+ Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported.
Requires the `MANAGE_USERS` permission.
tags:
- users
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ plexIds:
+ type: array
+ items:
+ type: string
responses:
'201':
description: A list of the newly created users
@@ -3538,6 +3711,35 @@ paths:
permissions:
type: number
example: 2
+ /user/{userId}/watch_data:
+ get:
+ summary: Get watch data
+ description: |
+ Returns play count, play duration, and recently watched media.
+
+ Requires the `ADMIN` permission to fetch results for other users.
+ tags:
+ - users
+ parameters:
+ - in: path
+ name: userId
+ required: true
+ schema:
+ type: number
+ responses:
+ '200':
+ description: Users
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ recentlyWatched:
+ type: array
+ items:
+ $ref: '#/components/schemas/MediaInfo'
+ playCount:
+ type: number
/search:
get:
summary: Search for movies, TV shows, or people
@@ -4317,21 +4519,22 @@ paths:
schema:
type: object
properties:
+ total:
+ type: number
+ movie:
+ type: number
+ tv:
+ type: number
pending:
type: number
- example: 0
approved:
type: number
- example: 10
+ declined:
+ type: number
processing:
type: number
- example: 4
available:
type: number
- example: 6
- required:
- - pending
- - approved
/request/{requestId}:
get:
summary: Get MediaRequest
@@ -4807,8 +5010,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/PersonDetail'
-
+ $ref: '#/components/schemas/PersonDetails'
/person/{personId}/combined_credits:
get:
summary: Get combined credits
@@ -4945,6 +5147,57 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MediaInfo'
+ /media/{mediaId}/watch_data:
+ get:
+ summary: Get watch data
+ description: |
+ Returns play count, play duration, and users who have watched the media.
+
+ Requires the `ADMIN` permission.
+ tags:
+ - media
+ parameters:
+ - in: path
+ name: mediaId
+ description: Media ID
+ required: true
+ example: '1'
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Users
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ playCount7Days:
+ type: number
+ playCount30Days:
+ type: number
+ playCount:
+ type: number
+ users:
+ type: array
+ items:
+ $ref: '#/components/schemas/User'
+ data4k:
+ type: object
+ properties:
+ playCount7Days:
+ type: number
+ playCount30Days:
+ type: number
+ playCount:
+ type: number
+ users:
+ type: array
+ items:
+ $ref: '#/components/schemas/User'
/collection/{collectionId}:
get:
summary: Get collection details
diff --git a/package.json b/package.json
index e1b82216..0469694d 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
- "format": "prettier --write ."
+ "format": "prettier --write .",
+ "prepare": "husky install"
},
"repository": {
"type": "git",
@@ -21,145 +22,139 @@
},
"license": "MIT",
"dependencies": {
- "@headlessui/react": "^1.4.1",
- "@heroicons/react": "^1.0.4",
- "@supercharge/request-ip": "^1.1.2",
- "@svgr/webpack": "^5.5.0",
- "@tanem/react-nprogress": "^3.0.79",
- "ace-builds": "^1.4.12",
- "axios": "^0.21.4",
+ "@headlessui/react": "^1.5.0",
+ "@heroicons/react": "^1.0.6",
+ "@supercharge/request-ip": "^1.2.0",
+ "@svgr/webpack": "^6.2.1",
+ "@tanem/react-nprogress": "^4.0.10",
+ "ace-builds": "^1.4.14",
+ "axios": "^0.26.1",
"bcrypt": "^5.0.1",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
- "cookie-parser": "^1.4.5",
+ "cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.1",
- "country-flag-icons": "^1.4.10",
+ "country-flag-icons": "^1.4.21",
"csurf": "^1.11.0",
- "email-templates": "^8.0.8",
- "express": "^4.17.1",
- "express-openapi-validator": "^4.13.1",
- "express-rate-limit": "^5.3.0",
+ "email-templates": "^8.0.10",
+ "express": "^4.17.3",
+ "express-openapi-validator": "^4.13.6",
+ "express-rate-limit": "^6.3.0",
"express-session": "^1.17.2",
"formik": "^2.2.9",
- "gravatar-url": "3.1.0",
+ "gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
- "next": "11.1.2",
+ "next": "12.1.0",
"node-cache": "^5.1.2",
- "node-schedule": "^2.0.0",
- "nodemailer": "^6.6.3",
- "openpgp": "^5.0.0-3",
- "plex-api": "^5.3.1",
+ "node-gyp": "^9.0.0",
+ "node-schedule": "^2.1.0",
+ "nodemailer": "^6.7.2",
+ "openpgp": "^5.2.0",
+ "plex-api": "^5.3.2",
"pug": "^3.0.2",
"react": "17.0.2",
- "react-ace": "^9.3.0",
+ "react-ace": "^9.5.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.2",
- "react-intersection-observer": "^8.32.1",
- "react-intl": "5.20.10",
- "react-markdown": "^6.0.2",
- "react-select": "^4.3.1",
- "react-spring": "^9.2.4",
+ "react-intersection-observer": "^8.33.1",
+ "react-intl": "5.24.7",
+ "react-markdown": "^8.0.0",
+ "react-select": "^5.2.2",
+ "react-spring": "^9.4.4",
"react-toast-notifications": "^2.5.1",
"react-transition-group": "^4.4.2",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"reflect-metadata": "^0.1.13",
"secure-random-password": "^0.2.3",
+ "semver": "^7.3.5",
"sqlite3": "^5.0.2",
- "swagger-ui-express": "^4.1.6",
- "swr": "^0.5.6",
- "typeorm": "0.2.37",
+ "swagger-ui-express": "^4.3.0",
+ "swr": "^1.2.2",
+ "typeorm": "0.2.45",
"web-push": "^3.4.5",
- "winston": "^3.3.3",
- "winston-daily-rotate-file": "^4.5.5",
+ "winston": "^3.6.0",
+ "winston-daily-rotate-file": "^4.6.1",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
- "yup": "^0.32.9"
+ "yup": "^0.32.11"
},
"devDependencies": {
- "@babel/cli": "^7.15.7",
- "@commitlint/cli": "^13.1.0",
- "@commitlint/config-conventional": "^13.1.0",
- "@semantic-release/changelog": "^5.0.1",
- "@semantic-release/commit-analyzer": "^9.0.1",
- "@semantic-release/exec": "^5.0.0",
- "@semantic-release/git": "^9.0.1",
- "@tailwindcss/aspect-ratio": "^0.2.1",
- "@tailwindcss/forms": "^0.3.3",
- "@tailwindcss/typography": "^0.4.1",
+ "@babel/cli": "^7.17.6",
+ "@commitlint/cli": "^16.2.1",
+ "@commitlint/config-conventional": "^16.2.1",
+ "@semantic-release/changelog": "^6.0.1",
+ "@semantic-release/commit-analyzer": "^9.0.2",
+ "@semantic-release/exec": "^6.0.3",
+ "@semantic-release/git": "^10.0.1",
+ "@tailwindcss/aspect-ratio": "^0.4.0",
+ "@tailwindcss/forms": "^0.5.0",
+ "@tailwindcss/typography": "^0.5.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.2",
"@types/email-templates": "^8.0.4",
"@types/express": "^4.17.13",
- "@types/express-rate-limit": "^5.1.3",
- "@types/express-session": "^1.17.3",
- "@types/lodash": "^4.14.173",
- "@types/node": "^15.6.1",
+ "@types/express-session": "^1.17.4",
+ "@types/lodash": "^4.14.179",
+ "@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
- "@types/react": "^17.0.22",
- "@types/react-dom": "^17.0.9",
- "@types/react-select": "^4.0.17",
- "@types/react-toast-notifications": "^2.4.1",
- "@types/react-transition-group": "^4.4.3",
+ "@types/react": "^17.0.40",
+ "@types/react-dom": "^17.0.13",
+ "@types/react-transition-group": "^4.4.4",
"@types/secure-random-password": "^0.2.1",
+ "@types/semver": "^7.3.9",
"@types/swagger-ui-express": "^4.1.3",
"@types/web-push": "^3.3.2",
"@types/xml2js": "^0.4.9",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.13",
- "@typescript-eslint/eslint-plugin": "^4.31.1",
- "@typescript-eslint/parser": "^4.31.1",
- "autoprefixer": "^10.3.4",
+ "@typescript-eslint/eslint-plugin": "^5.14.0",
+ "@typescript-eslint/parser": "^5.14.0",
+ "autoprefixer": "^10.4.2",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.4",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
- "eslint": "^7.32.0",
- "eslint-config-next": "^11.1.2",
- "eslint-config-prettier": "^8.3.0",
- "eslint-plugin-formatjs": "^2.17.6",
- "eslint-plugin-jsx-a11y": "^6.4.1",
+ "eslint": "^8.11.0",
+ "eslint-config-next": "^12.1.0",
+ "eslint-config-prettier": "^8.5.0",
+ "eslint-plugin-formatjs": "^3.0.0",
+ "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
- "eslint-plugin-react": "^7.25.3",
- "eslint-plugin-react-hooks": "^4.2.0",
+ "eslint-plugin-react": "^7.29.3",
+ "eslint-plugin-react-hooks": "^4.3.0",
"extract-react-intl-messages": "^4.1.1",
- "husky": "4.3.8",
- "lint-staged": "^11.1.2",
- "nodemon": "^2.0.12",
- "postcss": "^8.3.6",
- "prettier": "^2.4.1",
- "semantic-release": "^18.0.0",
+ "husky": "^7.0.4",
+ "lint-staged": "^12.3.5",
+ "nodemon": "^2.0.15",
+ "postcss": "^8.4.8",
+ "prettier": "^2.5.1",
+ "prettier-plugin-tailwindcss": "^0.1.8",
+ "semantic-release": "^19.0.2",
"semantic-release-docker-buildx": "^1.0.1",
- "tailwindcss": "^2.2.15",
- "ts-node": "^10.2.1",
- "typescript": "^4.4.3"
+ "tailwindcss": "^3.0.23",
+ "ts-node": "^10.7.0",
+ "typescript": "^4.6.2"
},
"resolutions": {
- "sqlite3/node-gyp": "^5.1.0"
+ "sqlite3/node-gyp": "^8.4.1"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
- "husky": {
- "hooks": {
- "pre-commit": "lint-staged",
- "prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
- "commit-msg": "[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS"
- }
- },
"lint-staged": {
"**/*.{ts,tsx,js}": [
"prettier --write",
"eslint"
],
- "**/*.{json,md}": [
+ "**/*.{json,md,css}": [
"prettier --write"
]
},
diff --git a/public/offline.html b/public/offline.html
index 12c6c29f..732782ee 100644
--- a/public/offline.html
+++ b/public/offline.html
@@ -4,6 +4,7 @@
+
You are offline
diff --git a/server/api/plextv.ts b/server/api/plextv.ts
index 9efcecc2..1733a85a 100644
--- a/server/api/plextv.ts
+++ b/server/api/plextv.ts
@@ -224,7 +224,7 @@ class PlexTvAPI {
const users = friends.MediaContainer.User;
- const user = users.find((u) => Number(u.$.id) === userId);
+ const user = users.find((u) => parseInt(u.$.id) === userId);
if (!user) {
throw new Error(
diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts
index 0e0a41f1..7305baf0 100644
--- a/server/api/servarr/radarr.ts
+++ b/server/api/servarr/radarr.ts
@@ -1,7 +1,7 @@
import logger from '../../logger';
import ServarrBase from './base';
-interface RadarrMovieOptions {
+export interface RadarrMovieOptions {
title: string;
qualityProfileId: number;
minimumAvailability: string;
@@ -27,7 +27,6 @@ export interface RadarrMovie {
profileId: number;
qualityProfileId: number;
added: string;
- downloaded: boolean;
hasFile: boolean;
}
@@ -85,7 +84,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
try {
const movie = await this.getMovieByTmdbId(options.tmdbId);
- if (movie.downloaded) {
+ if (movie.hasFile) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts
index b6793ed3..7440d278 100644
--- a/server/api/servarr/sonarr.ts
+++ b/server/api/servarr/sonarr.ts
@@ -63,7 +63,7 @@ export interface SonarrSeries {
};
}
-interface AddSeriesOptions {
+export interface AddSeriesOptions {
tvdbid: number;
title: string;
profileId: number;
@@ -149,6 +149,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
// If the series already exists, we will simply just update it
if (series.id) {
+ series.monitored = options.monitored ?? series.monitored;
series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts
new file mode 100644
index 00000000..bb7f3723
--- /dev/null
+++ b/server/api/tautulli.ts
@@ -0,0 +1,293 @@
+import axios, { AxiosInstance } from 'axios';
+import { uniqWith } from 'lodash';
+import { User } from '../entity/User';
+import { TautulliSettings } from '../lib/settings';
+import logger from '../logger';
+
+export interface TautulliHistoryRecord {
+ date: number;
+ duration: number;
+ friendly_name: string;
+ full_title: string;
+ grandparent_rating_key: number;
+ grandparent_title: string;
+ original_title: string;
+ group_count: number;
+ group_ids?: string;
+ guid: string;
+ ip_address: string;
+ live: number;
+ machine_id: string;
+ media_index: number;
+ media_type: string;
+ originally_available_at: string;
+ parent_media_index: number;
+ parent_rating_key: number;
+ parent_title: string;
+ paused_counter: number;
+ percent_complete: number;
+ platform: string;
+ product: string;
+ player: string;
+ rating_key: number;
+ reference_id?: number;
+ row_id?: number;
+ session_key?: string;
+ started: number;
+ state?: string;
+ stopped: number;
+ thumb: string;
+ title: string;
+ transcode_decision: string;
+ user: string;
+ user_id: number;
+ watched_status: number;
+ year: number;
+}
+
+interface TautulliHistoryResponse {
+ response: {
+ result: string;
+ message?: string;
+ data: {
+ draw: number;
+ recordsTotal: number;
+ recordsFiltered: number;
+ total_duration: string;
+ filter_duration: string;
+ data: TautulliHistoryRecord[];
+ };
+ };
+}
+
+interface TautulliWatchStats {
+ query_days: number;
+ total_time: number;
+ total_plays: number;
+}
+
+interface TautulliWatchStatsResponse {
+ response: {
+ result: string;
+ message?: string;
+ data: TautulliWatchStats[];
+ };
+}
+
+interface TautulliWatchUser {
+ friendly_name: string;
+ user_id: number;
+ user_thumb: string;
+ username: string;
+ total_plays: number;
+ total_time: number;
+}
+
+interface TautulliWatchUsersResponse {
+ response: {
+ result: string;
+ message?: string;
+ data: TautulliWatchUser[];
+ };
+}
+
+interface TautulliInfo {
+ tautulli_install_type: string;
+ tautulli_version: string;
+ tautulli_branch: string;
+ tautulli_commit: string;
+ tautulli_platform: string;
+ tautulli_platform_release: string;
+ tautulli_platform_version: string;
+ tautulli_platform_linux_distro: string;
+ tautulli_platform_device_name: string;
+ tautulli_python_version: string;
+}
+
+interface TautulliInfoResponse {
+ response: {
+ result: string;
+ message?: string;
+ data: TautulliInfo;
+ };
+}
+
+class TautulliAPI {
+ private axios: AxiosInstance;
+
+ constructor(settings: TautulliSettings) {
+ this.axios = axios.create({
+ baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
+ settings.port
+ }${settings.urlBase ?? ''}`,
+ params: { apikey: settings.apiKey },
+ });
+ }
+
+ public async getInfo(): Promise {
+ try {
+ return (
+ await this.axios.get('/api/v2', {
+ params: { cmd: 'get_tautulli_info' },
+ })
+ ).data.response.data;
+ } catch (e) {
+ logger.error('Something went wrong fetching Tautulli server info', {
+ label: 'Tautulli API',
+ errorMessage: e.message,
+ });
+ throw new Error(
+ `[Tautulli] Failed to fetch Tautulli server info: ${e.message}`
+ );
+ }
+ }
+
+ public async getMediaWatchStats(
+ ratingKey: string
+ ): Promise {
+ try {
+ return (
+ await this.axios.get('/api/v2', {
+ params: {
+ cmd: 'get_item_watch_time_stats',
+ rating_key: ratingKey,
+ grouping: 1,
+ },
+ })
+ ).data.response.data;
+ } catch (e) {
+ logger.error(
+ 'Something went wrong fetching media watch stats from Tautulli',
+ {
+ label: 'Tautulli API',
+ errorMessage: e.message,
+ ratingKey,
+ }
+ );
+ throw new Error(
+ `[Tautulli] Failed to fetch media watch stats: ${e.message}`
+ );
+ }
+ }
+
+ public async getMediaWatchUsers(
+ ratingKey: string
+ ): Promise {
+ try {
+ return (
+ await this.axios.get('/api/v2', {
+ params: {
+ cmd: 'get_item_user_stats',
+ rating_key: ratingKey,
+ grouping: 1,
+ },
+ })
+ ).data.response.data;
+ } catch (e) {
+ logger.error(
+ 'Something went wrong fetching media watch users from Tautulli',
+ {
+ label: 'Tautulli API',
+ errorMessage: e.message,
+ ratingKey,
+ }
+ );
+ throw new Error(
+ `[Tautulli] Failed to fetch media watch users: ${e.message}`
+ );
+ }
+ }
+
+ public async getUserWatchStats(user: User): Promise {
+ try {
+ if (!user.plexId) {
+ throw new Error('User does not have an associated Plex ID');
+ }
+
+ return (
+ await this.axios.get('/api/v2', {
+ params: {
+ cmd: 'get_user_watch_time_stats',
+ user_id: user.plexId,
+ query_days: 0,
+ grouping: 1,
+ },
+ })
+ ).data.response.data[0];
+ } catch (e) {
+ logger.error(
+ 'Something went wrong fetching user watch stats from Tautulli',
+ {
+ label: 'Tautulli API',
+ errorMessage: e.message,
+ user: user.displayName,
+ }
+ );
+ throw new Error(
+ `[Tautulli] Failed to fetch user watch stats: ${e.message}`
+ );
+ }
+ }
+
+ public async getUserWatchHistory(
+ user: User
+ ): Promise {
+ let results: TautulliHistoryRecord[] = [];
+
+ try {
+ if (!user.plexId) {
+ throw new Error('User does not have an associated Plex ID');
+ }
+
+ const take = 100;
+ let start = 0;
+
+ while (results.length < 20) {
+ const tautulliData = (
+ await this.axios.get('/api/v2', {
+ params: {
+ cmd: 'get_history',
+ grouping: 1,
+ order_column: 'date',
+ order_dir: 'desc',
+ user_id: user.plexId,
+ media_type: 'movie,episode',
+ length: take,
+ start,
+ },
+ })
+ ).data.response.data.data;
+
+ if (!tautulliData.length) {
+ return results;
+ }
+
+ results = uniqWith(results.concat(tautulliData), (recordA, recordB) =>
+ recordA.grandparent_rating_key && recordB.grandparent_rating_key
+ ? recordA.grandparent_rating_key === recordB.grandparent_rating_key
+ : recordA.parent_rating_key && recordB.parent_rating_key
+ ? recordA.parent_rating_key === recordB.parent_rating_key
+ : recordA.rating_key === recordB.rating_key
+ );
+
+ start += take;
+ }
+
+ return results.slice(0, 20);
+ } catch (e) {
+ logger.error(
+ 'Something went wrong fetching user watch history from Tautulli',
+ {
+ label: 'Tautulli API',
+ errorMessage: e.message,
+ user: user.displayName,
+ }
+ );
+ throw new Error(
+ `[Tautulli] Failed to fetch user watch history: ${e.message}`
+ );
+ }
+ }
+}
+
+export default TautulliAPI;
diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts
index ddc18059..cf5e280c 100644
--- a/server/api/themoviedb/index.ts
+++ b/server/api/themoviedb/index.ts
@@ -10,7 +10,7 @@ import {
TmdbMovieDetails,
TmdbNetwork,
TmdbPersonCombinedCredits,
- TmdbPersonDetail,
+ TmdbPersonDetails,
TmdbProductionCompany,
TmdbRegion,
TmdbSearchMovieResponse,
@@ -28,6 +28,10 @@ interface SearchOptions {
language?: string;
}
+interface SingleSearchOptions extends SearchOptions {
+ year?: number;
+}
+
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
@@ -116,15 +120,67 @@ class TheMovieDb extends ExternalAPI {
}
};
+ public searchMovies = async ({
+ query,
+ page = 1,
+ includeAdult = false,
+ language = 'en',
+ year,
+ }: SingleSearchOptions): Promise => {
+ try {
+ const data = await this.get('/search/movie', {
+ params: { query, page, include_adult: includeAdult, language, year },
+ });
+
+ return data;
+ } catch (e) {
+ return {
+ page: 1,
+ results: [],
+ total_pages: 1,
+ total_results: 0,
+ };
+ }
+ };
+
+ public searchTvShows = async ({
+ query,
+ page = 1,
+ includeAdult = false,
+ language = 'en',
+ year,
+ }: SingleSearchOptions): Promise => {
+ try {
+ const data = await this.get('/search/tv', {
+ params: {
+ query,
+ page,
+ include_adult: includeAdult,
+ language,
+ first_air_date_year: year,
+ },
+ });
+
+ return data;
+ } catch (e) {
+ return {
+ page: 1,
+ results: [],
+ total_pages: 1,
+ total_results: 0,
+ };
+ }
+ };
+
public getPerson = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
- }): Promise => {
+ }): Promise => {
try {
- const data = await this.get(`/person/${personId}`, {
+ const data = await this.get(`/person/${personId}`, {
params: { language },
});
@@ -561,13 +617,13 @@ class TheMovieDb extends ExternalAPI {
}
}
- public async getMovieByImdbId({
+ public async getMediaByImdbId({
imdbId,
language = 'en',
}: {
imdbId: string;
language?: string;
- }): Promise {
+ }): Promise {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
@@ -583,12 +639,19 @@ class TheMovieDb extends ExternalAPI {
return movie;
}
- throw new Error(
- '[TMDb] Failed to find a title with the provided IMDB id'
- );
+ if (extResponse.tv_results[0]) {
+ const tvshow = await this.getTvShow({
+ tvId: extResponse.tv_results[0].id,
+ language,
+ });
+
+ return tvshow;
+ }
+
+ throw new Error(`No movie or show returned from API for ID ${imdbId}`);
} catch (e) {
throw new Error(
- `[TMDb] Failed to get movie by external imdb ID: ${e.message}`
+ `[TMDb] Failed to find media using external IMDb ID: ${e.message}`
);
}
}
diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts
index 7892fe46..2282fe05 100644
--- a/server/api/themoviedb/interfaces.ts
+++ b/server/api/themoviedb/interfaces.ts
@@ -67,6 +67,7 @@ export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
export interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
+ person_results: TmdbPersonResult[];
}
export interface TmdbCreditCast {
@@ -315,7 +316,7 @@ export interface TmdbKeyword {
name: string;
}
-export interface TmdbPersonDetail {
+export interface TmdbPersonDetails {
id: number;
name: string;
birthday: string;
@@ -324,7 +325,7 @@ export interface TmdbPersonDetail {
also_known_as?: string[];
gender: number;
biography: string;
- popularity: string;
+ popularity: number;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
diff --git a/server/entity/Media.ts b/server/entity/Media.ts
index 9cb8cd79..9d106d4f 100644
--- a/server/entity/Media.ts
+++ b/server/entity/Media.ts
@@ -145,6 +145,9 @@ class Media {
public plexUrl?: string;
public plexUrl4k?: string;
+ public tautulliUrl?: string;
+ public tautulliUrl4k?: string;
+
constructor(init?: Partial) {
Object.assign(this, init);
}
@@ -152,6 +155,7 @@ class Media {
@AfterLoad()
public setPlexUrls(): void {
const { machineId, webAppUrl } = getSettings().plex;
+ const { externalUrl: tautulliUrl } = getSettings().tautulli;
if (this.ratingKey) {
this.plexUrl = `${
@@ -159,6 +163,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey
}`;
+
+ if (tautulliUrl) {
+ this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
+ }
}
if (this.ratingKey4k) {
@@ -167,6 +175,10 @@ class Media {
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
}`;
+
+ if (tautulliUrl) {
+ this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
+ }
}
}
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index 0e97f3d6..f7f82115 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -13,8 +13,11 @@ import {
RelationCount,
UpdateDateColumn,
} from 'typeorm';
-import RadarrAPI from '../api/servarr/radarr';
-import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
+import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
+import SonarrAPI, {
+ AddSeriesOptions,
+ SonarrSeries,
+} from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
@@ -135,55 +138,15 @@ export class MediaRequest {
where: { id: this.media.id },
});
if (!media) {
- logger.error('No parent media!', { label: 'Media Request' });
+ logger.error('Media data not found', {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
return;
}
- const tmdb = new TheMovieDb();
- if (this.type === MediaType.MOVIE) {
- const movie = await tmdb.getMovie({ movieId: media.tmdbId });
- notificationManager.sendNotification(Notification.MEDIA_PENDING, {
- event: `New ${this.is4k ? '4K ' : ''}Movie Request`,
- subject: `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`,
- message: truncate(movie.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
- media,
- request: this,
- notifyAdmin: true,
- });
- }
- if (this.type === MediaType.TV) {
- const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
- notificationManager.sendNotification(Notification.MEDIA_PENDING, {
- event: `New ${this.is4k ? '4K ' : ''}Series Request`,
- subject: `${tv.name}${
- tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
- }`,
- message: truncate(tv.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
- media,
- extra: [
- {
- name: 'Requested Seasons',
- value: this.seasons
- .map((season) => season.seasonNumber)
- .join(', '),
- },
- ],
- request: this,
- notifyAdmin: true,
- });
- }
+ this.sendNotification(media, Notification.MEDIA_PENDING);
}
}
@@ -204,90 +167,30 @@ export class MediaRequest {
where: { id: this.media.id },
});
if (!media) {
- logger.error('No parent media!', { label: 'Media Request' });
+ logger.error('Media data not found', {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
return;
}
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
logger.warn(
- 'Media became available before request was approved. Approval notification will be skipped.',
- { label: 'Media Request' }
+ 'Media became available before request was approved. Skipping approval notification',
+ { label: 'Media Request', requestId: this.id, mediaId: this.media.id }
);
return;
}
- const tmdb = new TheMovieDb();
- if (this.media.mediaType === MediaType.MOVIE) {
- const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
- notificationManager.sendNotification(
- this.status === MediaRequestStatus.APPROVED
- ? autoApproved
- ? Notification.MEDIA_AUTO_APPROVED
- : Notification.MEDIA_APPROVED
- : Notification.MEDIA_DECLINED,
- {
- event: `${this.is4k ? '4K ' : ''}Movie Request ${
- this.status === MediaRequestStatus.APPROVED
- ? autoApproved
- ? 'Automatically Approved'
- : 'Approved'
- : 'Declined'
- }`,
- subject: `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`,
- message: truncate(movie.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
- notifyAdmin: autoApproved,
- notifyUser: autoApproved ? undefined : this.requestedBy,
- media,
- request: this,
- }
- );
- } else if (this.media.mediaType === MediaType.TV) {
- const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
- notificationManager.sendNotification(
- this.status === MediaRequestStatus.APPROVED
- ? autoApproved
- ? Notification.MEDIA_AUTO_APPROVED
- : Notification.MEDIA_APPROVED
- : Notification.MEDIA_DECLINED,
- {
- event: `${this.is4k ? '4K ' : ''}Series Request ${
- this.status === MediaRequestStatus.APPROVED
- ? autoApproved
- ? 'Automatically Approved'
- : 'Approved'
- : 'Declined'
- }`,
- subject: `${tv.name}${
- tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
- }`,
- message: truncate(tv.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
- notifyAdmin: autoApproved,
- notifyUser: autoApproved ? undefined : this.requestedBy,
- media,
- extra: [
- {
- name: 'Requested Seasons',
- value: this.seasons
- .map((season) => season.seasonNumber)
- .join(', '),
- },
- ],
- request: this,
- }
- );
- }
+ this.sendNotification(
+ media,
+ this.status === MediaRequestStatus.APPROVED
+ ? autoApproved
+ ? Notification.MEDIA_AUTO_APPROVED
+ : Notification.MEDIA_APPROVED
+ : Notification.MEDIA_DECLINED
+ );
}
}
@@ -307,7 +210,11 @@ export class MediaRequest {
relations: ['requests'],
});
if (!media) {
- logger.error('No parent media!', { label: 'Media Request' });
+ logger.error('Media data not found', {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
return;
}
const seasonRequestRepository = getRepository(SeasonRequest);
@@ -395,8 +302,12 @@ export class MediaRequest {
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
- 'Skipped Radarr request as there is no Radarr server configured',
- { label: 'Media Request' }
+ 'No Radarr server configured, skipping request processing',
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
return;
}
@@ -415,18 +326,26 @@ export class MediaRequest {
);
logger.info(
`Request has an override server: ${radarrSettings?.name}`,
- { label: 'Media Request' }
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
}
if (!radarrSettings) {
- logger.info(
+ logger.warn(
`There is no default ${
this.is4k ? '4K ' : ''
}Radarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Radarr servers as default?`,
- { label: 'Media Request' }
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
return;
}
@@ -443,6 +362,8 @@ export class MediaRequest {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
});
}
@@ -451,15 +372,22 @@ export class MediaRequest {
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
- logger.info(`Request has an override profile id: ${qualityProfile}`, {
- label: 'Media Request',
- });
+ logger.info(
+ `Request has an override quality profile ID: ${qualityProfile}`,
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
+ );
}
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
tagIds: tags,
});
}
@@ -476,7 +404,11 @@ export class MediaRequest {
});
if (!media) {
- logger.error('Media not present');
+ logger.error('Media data not found', {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
return;
}
@@ -486,20 +418,22 @@ export class MediaRequest {
throw new Error('Media already available');
}
+ const radarrMovieOptions: RadarrMovieOptions = {
+ profileId: qualityProfile,
+ qualityProfileId: qualityProfile,
+ rootFolderPath: rootFolder,
+ minimumAvailability: radarrSettings.minimumAvailability,
+ title: movie.title,
+ tmdbId: movie.id,
+ year: Number(movie.release_date.slice(0, 4)),
+ monitored: true,
+ tags,
+ searchNow: !radarrSettings.preventSearch,
+ };
+
// Run this asynchronously so we don't wait for it on the UI side
radarr
- .addMovie({
- profileId: qualityProfile,
- qualityProfileId: qualityProfile,
- rootFolderPath: rootFolder,
- minimumAvailability: radarrSettings.minimumAvailability,
- title: movie.title,
- tmdbId: movie.id,
- year: Number(movie.release_date.slice(0, 4)),
- monitored: true,
- tags,
- searchNow: !radarrSettings.preventSearch,
- })
+ .addMovie(radarrMovieOptions)
.then(async (radarrMovie) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
@@ -507,7 +441,7 @@ export class MediaRequest {
});
if (!media) {
- throw new Error('Media data is missing');
+ throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
@@ -521,36 +455,30 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
- 'Newly added movie request failed to add to Radarr, marking as unknown',
+ 'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
{
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ radarrMovieOptions,
}
);
- notificationManager.sendNotification(Notification.MEDIA_FAILED, {
- event: `${this.is4k ? '4K ' : ''}Movie Request Failed`,
- subject: `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`,
- message: truncate(movie.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- media,
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
- request: this,
- notifyAdmin: true,
- });
+ this.sendNotification(media, Notification.MEDIA_FAILED);
});
- logger.info('Sent request to Radarr', { label: 'Media Request' });
- } catch (e) {
- const errorMessage = `Request failed to send to Radarr: ${e.message}`;
- logger.error('Request failed to send to Radarr', {
+ logger.info('Sent request to Radarr', {
label: 'Media Request',
- errorMessage,
+ requestId: this.id,
+ mediaId: this.media.id,
});
- throw new Error(errorMessage);
+ } catch (e) {
+ logger.error('Something went wrong sending request to Radarr', {
+ label: 'Media Request',
+ errorMessage: e.message,
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
+ throw new Error(e.message);
}
}
}
@@ -564,9 +492,13 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
- logger.info(
- 'Skipped Sonarr request as there is no Sonarr server configured',
- { label: 'Media Request' }
+ logger.warn(
+ 'No Sonarr server configured, skipping request processing',
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
return;
}
@@ -585,18 +517,26 @@ export class MediaRequest {
);
logger.info(
`Request has an override server: ${sonarrSettings?.name}`,
- { label: 'Media Request' }
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
}
if (!sonarrSettings) {
- logger.info(
+ logger.warn(
`There is no default ${
this.is4k ? '4K ' : ''
}Sonarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Sonarr servers as default?`,
- { label: 'Media Request' }
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
);
return;
}
@@ -607,7 +547,7 @@ export class MediaRequest {
});
if (!media) {
- throw new Error('Media data is missing');
+ throw new Error('Media data not found');
}
if (
@@ -628,7 +568,7 @@ export class MediaRequest {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
- throw new Error('Series was missing tvdb id');
+ throw new Error('TVDB ID not found');
}
let seriesType: SonarrSeries['seriesType'] = 'standard';
@@ -650,12 +590,10 @@ export class MediaRequest {
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId;
-
let languageProfile =
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
-
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
@@ -669,14 +607,21 @@ export class MediaRequest {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
});
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
- logger.info(`Request has an override profile ID: ${qualityProfile}`, {
- label: 'Media Request',
- });
+ logger.info(
+ `Request has an override quality profile ID: ${qualityProfile}`,
+ {
+ label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ }
+ );
}
if (
@@ -685,9 +630,11 @@ export class MediaRequest {
) {
languageProfile = this.languageProfileId;
logger.info(
- `Request has an override Language Profile: ${languageProfile}`,
+ `Request has an override language profile ID: ${languageProfile}`,
{
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
}
);
}
@@ -696,25 +643,29 @@ export class MediaRequest {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
tagIds: tags,
});
}
+ const sonarrSeriesOptions: AddSeriesOptions = {
+ profileId: qualityProfile,
+ languageProfileId: languageProfile,
+ rootFolderPath: rootFolder,
+ title: series.name,
+ tvdbid: tvdbId,
+ seasons: this.seasons.map((season) => season.seasonNumber),
+ seasonFolder: sonarrSettings.enableSeasonFolders,
+ seriesType,
+ tags,
+ monitored: true,
+ searchNow: !sonarrSettings.preventSearch,
+ };
+
// Run this asynchronously so we don't wait for it on the UI side
sonarr
- .addSeries({
- profileId: qualityProfile,
- languageProfileId: languageProfile,
- rootFolderPath: rootFolder,
- title: series.name,
- tvdbid: tvdbId,
- seasons: this.seasons.map((season) => season.seasonNumber),
- seasonFolder: sonarrSettings.enableSeasonFolders,
- seriesType,
- tags,
- monitored: true,
- searchNow: !sonarrSettings.preventSearch,
- })
+ .addSeries(sonarrSeriesOptions)
.then(async (sonarrSeries) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
@@ -723,7 +674,7 @@ export class MediaRequest {
});
if (!media) {
- throw new Error('Media data is missing');
+ throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
@@ -737,47 +688,116 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
- 'Newly added series request failed to add to Sonarr, marking as unknown',
+ 'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
{
label: 'Media Request',
+ requestId: this.id,
+ mediaId: this.media.id,
+ sonarrSeriesOptions,
}
);
- notificationManager.sendNotification(Notification.MEDIA_FAILED, {
- event: `${this.is4k ? '4K ' : ''}Series Request Failed`,
- subject: `${series.name}${
- series.first_air_date
- ? ` (${series.first_air_date.slice(0, 4)})`
- : ''
- }`,
- message: truncate(series.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
- media,
- extra: [
- {
- name: 'Requested Seasons',
- value: this.seasons
- .map((season) => season.seasonNumber)
- .join(', '),
- },
- ],
- request: this,
- notifyAdmin: true,
- });
+ this.sendNotification(media, Notification.MEDIA_FAILED);
});
- logger.info('Sent request to Sonarr', { label: 'Media Request' });
- } catch (e) {
- const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
- logger.error('Request failed to send to Sonarr', {
+ logger.info('Sent request to Sonarr', {
label: 'Media Request',
- errorMessage,
+ requestId: this.id,
+ mediaId: this.media.id,
});
- throw new Error(errorMessage);
+ } catch (e) {
+ logger.error('Something went wrong sending request to Sonarr', {
+ label: 'Media Request',
+ errorMessage: e.message,
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
+ throw new Error(e.message);
}
}
}
+
+ private async sendNotification(media: Media, type: Notification) {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
+ let event: string | undefined;
+ let notifyAdmin = true;
+
+ switch (type) {
+ case Notification.MEDIA_APPROVED:
+ event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`;
+ notifyAdmin = false;
+ break;
+ case Notification.MEDIA_DECLINED:
+ event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`;
+ notifyAdmin = false;
+ break;
+ case Notification.MEDIA_PENDING:
+ event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ event = `${
+ this.is4k ? '4K ' : ''
+ }${mediaType} Request Automatically Approved`;
+ break;
+ case Notification.MEDIA_FAILED:
+ event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
+ break;
+ }
+
+ if (this.type === MediaType.MOVIE) {
+ const movie = await tmdb.getMovie({ movieId: media.tmdbId });
+ notificationManager.sendNotification(type, {
+ media,
+ request: this,
+ notifyAdmin,
+ notifyUser: notifyAdmin ? undefined : this.requestedBy,
+ event,
+ subject: `${movie.title}${
+ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
+ }`,
+ message: truncate(movie.overview, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ }),
+ image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
+ });
+ } else if (this.type === MediaType.TV) {
+ const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
+ notificationManager.sendNotification(type, {
+ media,
+ request: this,
+ notifyAdmin,
+ notifyUser: notifyAdmin ? undefined : this.requestedBy,
+ event,
+ subject: `${tv.name}${
+ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
+ }`,
+ message: truncate(tv.overview, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ }),
+ image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
+ extra: [
+ {
+ name: 'Requested Seasons',
+ value: this.seasons
+ .map((season) => season.seasonNumber)
+ .join(', '),
+ },
+ ],
+ });
+ }
+ } catch (e) {
+ logger.error('Something went wrong sending media notification(s)', {
+ label: 'Notifications',
+ errorMessage: e.message,
+ requestId: this.id,
+ mediaId: this.media.id,
+ });
+ }
+ }
}
diff --git a/server/index.ts b/server/index.ts
index 24c007f2..c8053012 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -17,6 +17,7 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
+import GotifyAgent from './lib/notifications/agents/gotify';
import LunaSeaAgent from './lib/notifications/agents/lunasea';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
import PushoverAgent from './lib/notifications/agents/pushover';
@@ -76,6 +77,7 @@ app
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
+ new GotifyAgent(),
new LunaSeaAgent(),
new PushbulletAgent(),
new PushoverAgent(),
diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts
index e530d2d2..d17716d2 100644
--- a/server/interfaces/api/mediaInterfaces.ts
+++ b/server/interfaces/api/mediaInterfaces.ts
@@ -1,6 +1,22 @@
import type Media from '../../entity/Media';
+import { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse {
results: Media[];
}
+
+export interface MediaWatchDataResponse {
+ data?: {
+ users: User[];
+ playCount: number;
+ playCount7Days: number;
+ playCount30Days: number;
+ };
+ data4k?: {
+ users: User[];
+ playCount: number;
+ playCount7Days: number;
+ playCount30Days: number;
+ };
+}
diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts
index 336bab0b..8e4f66c4 100644
--- a/server/interfaces/api/settingsInterfaces.ts
+++ b/server/interfaces/api/settingsInterfaces.ts
@@ -17,6 +17,7 @@ export interface SettingsAboutResponse {
totalRequests: number;
totalMediaItems: number;
tz?: string;
+ appDataPath: string;
}
export interface PublicSettingsResponse {
@@ -35,6 +36,7 @@ export interface PublicSettingsResponse {
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
+ newPlexLogin: boolean;
}
export interface CacheItem {
diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts
index facacd54..e5f56482 100644
--- a/server/interfaces/api/userInterfaces.ts
+++ b/server/interfaces/api/userInterfaces.ts
@@ -1,3 +1,4 @@
+import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
@@ -22,3 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}
+export interface UserWatchDataResponse {
+ recentlyWatched: Media[];
+ playCount: number;
+}
diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts
index 0f743efe..a3e132d6 100644
--- a/server/interfaces/api/userSettingsInterfaces.ts
+++ b/server/interfaces/api/userSettingsInterfaces.ts
@@ -2,6 +2,7 @@ import { NotificationAgentKey } from '../../lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
+ discordId?: string;
locale?: string;
region?: string;
originalLanguage?: string;
diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts
index 0e5fc872..c067a7d5 100644
--- a/server/lib/email/openpgpEncrypt.ts
+++ b/server/lib/email/openpgpEncrypt.ts
@@ -1,6 +1,7 @@
import { randomBytes } from 'crypto';
import * as openpgp from 'openpgp';
import { Transform, TransformCallback } from 'stream';
+import logger from '../../logger';
interface EncryptorOptions {
signingKey?: string;
@@ -36,133 +37,149 @@ class PGPEncryptor extends Transform {
// Actually do stuff
_flush = async (callback: TransformCallback): Promise => {
- // Reconstruct message as buffer
const message = Buffer.concat(this._messageChunks, this._messageLength);
- const validPublicKeys = await Promise.all(
- this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }))
- );
- let privateKey: openpgp.PrivateKey | undefined;
- // Just return the message if there is no one to encrypt for
- if (!validPublicKeys.length) {
- this.push(message);
- return callback();
- }
+ try {
+ // Reconstruct message as buffer
+ const validPublicKeys = await Promise.all(
+ this._encryptionKeys.map((armoredKey) =>
+ openpgp.readKey({ armoredKey })
+ )
+ );
+ let privateKey: openpgp.PrivateKey | undefined;
- // Only sign the message if private key and password exist
- if (this._signingKey && this._password) {
- privateKey = await openpgp.decryptKey({
- privateKey: await openpgp.readPrivateKey({
- armoredKey: this._signingKey,
+ // Just return the message if there is no one to encrypt for
+ if (!validPublicKeys.length) {
+ this.push(message);
+ return callback();
+ }
+
+ // Only sign the message if private key and password exist
+ if (this._signingKey && this._password) {
+ privateKey = await openpgp.decryptKey({
+ privateKey: await openpgp.readPrivateKey({
+ armoredKey: this._signingKey,
+ }),
+ passphrase: this._password,
+ });
+ }
+
+ const emailPartDelimiter = '\r\n\r\n';
+ const messageParts = message.toString().split(emailPartDelimiter);
+
+ /**
+ * In this loop original headers are split up into two parts,
+ * one for the email that is sent
+ * and one for the encrypted content
+ */
+ const header = messageParts.shift() as string;
+ const emailHeaders: string[][] = [];
+ const contentHeaders: string[][] = [];
+ const linesInHeader = header.split('\r\n');
+ let previousHeader: string[] = [];
+ for (let i = 0; i < linesInHeader.length; i++) {
+ const line = linesInHeader[i];
+ /**
+ * If it is a multi-line header (current line starts with whitespace)
+ * or it's the first line in the iteration
+ * add the current line with previous header and move on
+ */
+ if (/^\s/.test(line) || i === 0) {
+ previousHeader.push(line);
+ continue;
+ }
+
+ /**
+ * This is done to prevent the last header
+ * from being missed
+ */
+ if (i === linesInHeader.length - 1) {
+ previousHeader.push(line);
+ }
+
+ /**
+ * We need to seperate the actual content headers
+ * so that we can add it as a header for the encrypted content
+ * So that the content will be displayed properly after decryption
+ */
+ if (
+ /^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
+ ) {
+ contentHeaders.push(previousHeader);
+ } else {
+ emailHeaders.push(previousHeader);
+ }
+ previousHeader = [line];
+ }
+
+ // Generate a new boundary for the email content
+ const boundary = 'nm_' + randomBytes(14).toString('hex');
+ /**
+ * Concatenate everything into single strings
+ * and add pgp headers to the email headers
+ */
+ const emailHeadersRaw =
+ emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
+ '\r\n' +
+ 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
+ '\r\n' +
+ ' boundary="' +
+ boundary +
+ '"' +
+ '\r\n' +
+ 'Content-Description: OpenPGP encrypted message' +
+ '\r\n' +
+ 'Content-Transfer-Encoding: 7bit';
+ const contentHeadersRaw = contentHeaders
+ .map((line) => line.join('\r\n'))
+ .join('\r\n');
+
+ const encryptedMessage = await openpgp.encrypt({
+ message: await openpgp.createMessage({
+ text:
+ contentHeadersRaw +
+ emailPartDelimiter +
+ messageParts.join(emailPartDelimiter),
}),
- passphrase: this._password,
+ encryptionKeys: validPublicKeys,
+ signingKeys: privateKey,
});
+
+ const body =
+ '--' +
+ boundary +
+ '\r\n' +
+ 'Content-Type: application/pgp-encrypted\r\n' +
+ 'Content-Transfer-Encoding: 7bit\r\n' +
+ '\r\n' +
+ 'Version: 1\r\n' +
+ '\r\n' +
+ '--' +
+ boundary +
+ '\r\n' +
+ 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
+ 'Content-Disposition: inline; filename=encrypted.asc\r\n' +
+ 'Content-Transfer-Encoding: 7bit\r\n' +
+ '\r\n' +
+ encryptedMessage +
+ '\r\n--' +
+ boundary +
+ '--\r\n';
+
+ this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
+ callback();
+ } catch (e) {
+ logger.error(
+ 'Something went wrong while encrypting email message with OpenPGP. Sending email without encryption',
+ {
+ label: 'Notifications',
+ errorMessage: e.message,
+ }
+ );
+
+ this.push(message);
+ callback();
}
-
- const emailPartDelimiter = '\r\n\r\n';
- const messageParts = message.toString().split(emailPartDelimiter);
-
- /**
- * In this loop original headers are split up into two parts,
- * one for the email that is sent
- * and one for the encrypted content
- */
- const header = messageParts.shift() as string;
- const emailHeaders: string[][] = [];
- const contentHeaders: string[][] = [];
- const linesInHeader = header.split('\r\n');
- let previousHeader: string[] = [];
- for (let i = 0; i < linesInHeader.length; i++) {
- const line = linesInHeader[i];
- /**
- * If it is a multi-line header (current line starts with whitespace)
- * or it's the first line in the iteration
- * add the current line with previous header and move on
- */
- if (/^\s/.test(line) || i === 0) {
- previousHeader.push(line);
- continue;
- }
-
- /**
- * This is done to prevent the last header
- * from being missed
- */
- if (i === linesInHeader.length - 1) {
- previousHeader.push(line);
- }
-
- /**
- * We need to seperate the actual content headers
- * so that we can add it as a header for the encrypted content
- * So that the content will be displayed properly after decryption
- */
- if (
- /^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
- ) {
- contentHeaders.push(previousHeader);
- } else {
- emailHeaders.push(previousHeader);
- }
- previousHeader = [line];
- }
-
- // Generate a new boundary for the email content
- const boundary = 'nm_' + randomBytes(14).toString('hex');
- /**
- * Concatenate everything into single strings
- * and add pgp headers to the email headers
- */
- const emailHeadersRaw =
- emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
- '\r\n' +
- 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
- '\r\n' +
- ' boundary="' +
- boundary +
- '"' +
- '\r\n' +
- 'Content-Description: OpenPGP encrypted message' +
- '\r\n' +
- 'Content-Transfer-Encoding: 7bit';
- const contentHeadersRaw = contentHeaders
- .map((line) => line.join('\r\n'))
- .join('\r\n');
-
- const encryptedMessage = await openpgp.encrypt({
- message: await openpgp.createMessage({
- text:
- contentHeadersRaw +
- emailPartDelimiter +
- messageParts.join(emailPartDelimiter),
- }),
- encryptionKeys: validPublicKeys,
- signingKeys: privateKey,
- });
-
- const body =
- '--' +
- boundary +
- '\r\n' +
- 'Content-Type: application/pgp-encrypted\r\n' +
- 'Content-Transfer-Encoding: 7bit\r\n' +
- '\r\n' +
- 'Version: 1\r\n' +
- '\r\n' +
- '--' +
- boundary +
- '\r\n' +
- 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
- 'Content-Disposition: inline; filename=encrypted.asc\r\n' +
- 'Content-Transfer-Encoding: 7bit\r\n' +
- '\r\n' +
- encryptedMessage +
- '\r\n--' +
- boundary +
- '--\r\n';
-
- this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
- callback();
};
}
diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts
index bd07c4de..32120035 100644
--- a/server/lib/notifications/agents/discord.ts
+++ b/server/lib/notifications/agents/discord.ts
@@ -258,35 +258,37 @@ class DiscordAgent
const userMentions: string[] = [];
try {
- if (payload.notifyUser) {
- if (
- payload.notifyUser.settings?.hasNotificationType(
- NotificationAgentKey.DISCORD,
- type
- ) &&
- payload.notifyUser.settings.discordId
- ) {
- userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
+ if (settings.options.enableMentions) {
+ if (payload.notifyUser) {
+ if (
+ payload.notifyUser.settings?.hasNotificationType(
+ NotificationAgentKey.DISCORD,
+ type
+ ) &&
+ payload.notifyUser.settings.discordId
+ ) {
+ userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
+ }
}
- }
- if (payload.notifyAdmin) {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
+ if (payload.notifyAdmin) {
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
- userMentions.push(
- ...users
- .filter(
- (user) =>
- user.settings?.hasNotificationType(
- NotificationAgentKey.DISCORD,
- type
- ) &&
- user.settings.discordId &&
- shouldSendAdminNotification(type, user, payload)
- )
- .map((user) => `<@${user.settings?.discordId}>`)
- );
+ userMentions.push(
+ ...users
+ .filter(
+ (user) =>
+ user.settings?.hasNotificationType(
+ NotificationAgentKey.DISCORD,
+ type
+ ) &&
+ user.settings.discordId &&
+ shouldSendAdminNotification(type, user, payload)
+ )
+ .map((user) => `<@${user.settings?.discordId}>`)
+ );
+ }
}
await axios.post(settings.options.webhookUrl, {
diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts
new file mode 100644
index 00000000..ecd54ce7
--- /dev/null
+++ b/server/lib/notifications/agents/gotify.ts
@@ -0,0 +1,148 @@
+import axios from 'axios';
+import { hasNotificationType, Notification } from '..';
+import { IssueStatus, IssueTypeName } from '../../../constants/issue';
+import logger from '../../../logger';
+import { getSettings, NotificationAgentGotify } from '../../settings';
+import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
+
+interface GotifyPayload {
+ title: string;
+ message: string;
+ priority: number;
+ extras: any;
+}
+
+class GotifyAgent
+ extends BaseAgent
+ implements NotificationAgent
+{
+ protected getSettings(): NotificationAgentGotify {
+ if (this.settings) {
+ return this.settings;
+ }
+
+ const settings = getSettings();
+
+ return settings.notifications.agents.gotify;
+ }
+
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.url && settings.options.token) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private getNotificationPayload(
+ type: Notification,
+ payload: NotificationPayload
+ ): GotifyPayload {
+ const { applicationUrl, applicationTitle } = getSettings().main;
+ let priority = 0;
+
+ const title = payload.event
+ ? `${payload.event} - ${payload.subject}`
+ : payload.subject;
+ let message = payload.message ?? '';
+
+ if (payload.request) {
+ message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
+
+ let status = '';
+ switch (type) {
+ case Notification.MEDIA_PENDING:
+ status = 'Pending Approval';
+ break;
+ case Notification.MEDIA_APPROVED:
+ case Notification.MEDIA_AUTO_APPROVED:
+ status = 'Processing';
+ break;
+ case Notification.MEDIA_AVAILABLE:
+ status = 'Available';
+ break;
+ case Notification.MEDIA_DECLINED:
+ status = 'Declined';
+ break;
+ case Notification.MEDIA_FAILED:
+ status = 'Failed';
+ break;
+ }
+
+ if (status) {
+ message += `\nRequest Status: ${status}`;
+ }
+ } else if (payload.comment) {
+ message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
+ } else if (payload.issue) {
+ message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
+ message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
+ message += `\nIssue Status: ${
+ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
+ }`;
+
+ if (type == Notification.ISSUE_CREATED) {
+ priority = 1;
+ }
+ }
+
+ for (const extra of payload.extra ?? []) {
+ message += `\n\n**${extra.name}**\n${extra.value}`;
+ }
+
+ if (applicationUrl && payload.media) {
+ const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
+ message += `\n\nOpen in ${applicationTitle}(${actionUrl})`;
+ }
+
+ return {
+ extras: {
+ 'client::display': {
+ contentType: 'text/markdown',
+ },
+ },
+ title,
+ message,
+ priority,
+ };
+ }
+
+ public async send(
+ type: Notification,
+ payload: NotificationPayload
+ ): Promise {
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending Gotify notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+ try {
+ const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
+ const notificationPayload = this.getNotificationPayload(type, payload);
+
+ await axios.post(endpoint, notificationPayload);
+
+ return true;
+ } catch (e) {
+ logger.error('Error sending Gotify notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
+ });
+
+ return false;
+ }
+ }
+}
+
+export default GotifyAgent;
diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts
index 092722c9..b7bc1919 100644
--- a/server/lib/notifications/agents/pushbullet.ts
+++ b/server/lib/notifications/agents/pushbullet.ts
@@ -19,6 +19,7 @@ interface PushbulletPayload {
type: string;
title: string;
body: string;
+ channel_tag?: string;
}
class PushbulletAgent
@@ -116,11 +117,15 @@ class PushbulletAgent
});
try {
- await axios.post(endpoint, notificationPayload, {
- headers: {
- 'Access-Token': settings.options.accessToken,
- },
- });
+ await axios.post(
+ endpoint,
+ { ...notificationPayload, channel_tag: settings.options.channelTag },
+ {
+ headers: {
+ 'Access-Token': settings.options.accessToken,
+ },
+ }
+ );
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
@@ -188,8 +193,9 @@ class PushbulletAgent
.map(async (user) => {
if (
user.settings?.pushbulletAccessToken &&
- user.settings.pushbulletAccessToken !==
- settings.options.accessToken
+ (settings.options.channelTag ||
+ user.settings.pushbulletAccessToken !==
+ settings.options.accessToken)
) {
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts
index 6c8f06dc..f8364c3f 100644
--- a/server/lib/notifications/agents/pushover.ts
+++ b/server/lib/notifications/agents/pushover.ts
@@ -173,12 +173,12 @@ class PushoverAgent
NotificationAgentKey.PUSHOVER,
type
) &&
- payload.notifyUser.settings?.pushoverApplicationToken &&
- payload.notifyUser.settings?.pushoverUserKey &&
- payload.notifyUser.settings.pushoverApplicationToken !==
- settings.options.accessToken &&
- payload.notifyUser.settings?.pushoverUserKey !==
- settings.options.userToken
+ payload.notifyUser.settings.pushoverApplicationToken &&
+ payload.notifyUser.settings.pushoverUserKey &&
+ (payload.notifyUser.settings.pushoverApplicationToken !==
+ settings.options.accessToken ||
+ payload.notifyUser.settings.pushoverUserKey !==
+ settings.options.userToken)
) {
logger.debug('Sending Pushover notification', {
label: 'Notifications',
diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts
index eede9f09..ca10c269 100644
--- a/server/lib/notifications/agents/slack.ts
+++ b/server/lib/notifications/agents/slack.ts
@@ -39,6 +39,7 @@ interface EmbedBlock {
}
interface SlackBlockEmbed {
+ text: string;
blocks: EmbedBlock[];
}
@@ -201,6 +202,7 @@ class SlackAgent
}
return {
+ text: payload.event ?? payload.subject,
blocks,
};
}
diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts
index 20835b9a..cd8dbd76 100644
--- a/server/lib/scanners/plex/index.ts
+++ b/server/lib/scanners/plex/index.ts
@@ -371,10 +371,10 @@ class PlexScanner
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
});
- mediaIds.tmdbId = tmdbMovie.id;
+ mediaIds.tmdbId = tmdbMedia.id;
}
// Cache GUIDs
@@ -385,10 +385,10 @@ class PlexScanner
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
});
- mediaIds.tmdbId = tmdbMovie.id;
+ mediaIds.tmdbId = tmdbMedia.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
@@ -473,7 +473,7 @@ class PlexScanner
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
@@ -522,7 +522,7 @@ class PlexScanner
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts
index 71d687dc..5f47b9d9 100644
--- a/server/lib/scanners/radarr/index.ts
+++ b/server/lib/scanners/radarr/index.ts
@@ -73,7 +73,7 @@ class RadarrScanner
}
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise {
- if (!radarrMovie.monitored && !radarrMovie.downloaded) {
+ if (!radarrMovie.monitored && !radarrMovie.hasFile) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
@@ -92,7 +92,7 @@ class RadarrScanner
externalServiceId: radarrMovie.id,
externalServiceSlug: radarrMovie.titleSlug,
title: radarrMovie.title,
- processing: !radarrMovie.downloaded,
+ processing: !radarrMovie.hasFile,
});
} catch (e) {
this.log('Failed to process Radarr media', 'error', {
diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts
index db3aef98..044f74ec 100644
--- a/server/lib/scanners/sonarr/index.ts
+++ b/server/lib/scanners/sonarr/index.ts
@@ -1,6 +1,7 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
+import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
@@ -83,24 +84,26 @@ class SonarrScanner
const mediaRepository = getRepository(Media);
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
- let tmdbId: number;
+ let tvShow: TmdbTvDetails;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
- const tvShow = await this.tmdb.getShowByTvdbId({
+ tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
-
- tmdbId = tvShow.id;
} else {
- tmdbId = media.tmdbId;
+ tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
}
+ const tmdbId = tvShow.id;
+
const filteredSeasons = sonarrSeries.seasons.filter(
- (sn) => sn.seasonNumber !== 0
+ (sn) =>
+ sn.seasonNumber !== 0 &&
+ tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
);
for (const season of filteredSeasons) {
diff --git a/server/lib/search.ts b/server/lib/search.ts
new file mode 100644
index 00000000..c625f512
--- /dev/null
+++ b/server/lib/search.ts
@@ -0,0 +1,212 @@
+import TheMovieDb from '../api/themoviedb';
+import {
+ TmdbMovieDetails,
+ TmdbMovieResult,
+ TmdbPersonDetails,
+ TmdbPersonResult,
+ TmdbSearchMovieResponse,
+ TmdbSearchMultiResponse,
+ TmdbSearchTvResponse,
+ TmdbTvDetails,
+ TmdbTvResult,
+} from '../api/themoviedb/interfaces';
+import {
+ mapMovieDetailsToResult,
+ mapPersonDetailsToResult,
+ mapTvDetailsToResult,
+} from '../models/Search';
+import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers';
+
+interface SearchProvider {
+ pattern: RegExp;
+ search: ({
+ id,
+ language,
+ query,
+ }: {
+ id: string;
+ language?: string;
+ query?: string;
+ }) => Promise;
+}
+
+const searchProviders: SearchProvider[] = [];
+
+export const findSearchProvider = (
+ query: string
+): SearchProvider | undefined => {
+ return searchProviders.find((provider) => provider.pattern.test(query));
+};
+
+searchProviders.push({
+ pattern: new RegExp(/(?<=tmdb:)\d+/),
+ search: async ({ id, language }) => {
+ const tmdb = new TheMovieDb();
+
+ const moviePromise = tmdb.getMovie({ movieId: parseInt(id), language });
+ const tvShowPromise = tmdb.getTvShow({ tvId: parseInt(id), language });
+ const personPromise = tmdb.getPerson({ personId: parseInt(id), language });
+
+ const responses = await Promise.allSettled([
+ moviePromise,
+ tvShowPromise,
+ personPromise,
+ ]);
+
+ const successfulResponses = responses.filter(
+ (r) => r.status === 'fulfilled'
+ ) as
+ | (
+ | PromiseFulfilledResult
+ | PromiseFulfilledResult
+ | PromiseFulfilledResult
+ )[];
+
+ const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
+
+ if (successfulResponses.length) {
+ results.push(
+ ...successfulResponses.map((r) => {
+ if (isMovieDetails(r.value)) {
+ return mapMovieDetailsToResult(r.value);
+ } else if (isTvDetails(r.value)) {
+ return mapTvDetailsToResult(r.value);
+ } else {
+ return mapPersonDetailsToResult(r.value);
+ }
+ })
+ );
+ }
+
+ return {
+ page: 1,
+ total_pages: 1,
+ total_results: results.length,
+ results,
+ };
+ },
+});
+
+searchProviders.push({
+ pattern: new RegExp(/(?<=imdb:)(tt|nm)\d+/),
+ search: async ({ id, language }) => {
+ const tmdb = new TheMovieDb();
+
+ const responses = await tmdb.getByExternalId({
+ externalId: id,
+ type: 'imdb',
+ language,
+ });
+
+ const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
+
+ // set the media_type here since searching by external id doesn't return it
+ results.push(
+ ...(responses.movie_results.map((movie) => ({
+ ...movie,
+ media_type: 'movie',
+ })) as TmdbMovieResult[]),
+ ...(responses.tv_results.map((tv) => ({
+ ...tv,
+ media_type: 'tv',
+ })) as TmdbTvResult[]),
+ ...(responses.person_results.map((person) => ({
+ ...person,
+ media_type: 'person',
+ })) as TmdbPersonResult[])
+ );
+
+ return {
+ page: 1,
+ total_pages: 1,
+ total_results: results.length,
+ results,
+ };
+ },
+});
+
+searchProviders.push({
+ pattern: new RegExp(/(?<=tvdb:)\d+/),
+ search: async ({ id, language }) => {
+ const tmdb = new TheMovieDb();
+
+ const responses = await tmdb.getByExternalId({
+ externalId: parseInt(id),
+ type: 'tvdb',
+ language,
+ });
+
+ const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
+
+ // set the media_type here since searching by external id doesn't return it
+ results.push(
+ ...(responses.movie_results.map((movie) => ({
+ ...movie,
+ media_type: 'movie',
+ })) as TmdbMovieResult[]),
+ ...(responses.tv_results.map((tv) => ({
+ ...tv,
+ media_type: 'tv',
+ })) as TmdbTvResult[]),
+ ...(responses.person_results.map((person) => ({
+ ...person,
+ media_type: 'person',
+ })) as TmdbPersonResult[])
+ );
+
+ return {
+ page: 1,
+ total_pages: 1,
+ total_results: results.length,
+ results,
+ };
+ },
+});
+
+searchProviders.push({
+ pattern: new RegExp(/(?<=year:)\d{4}/),
+ search: async ({ id: year, query }) => {
+ const tmdb = new TheMovieDb();
+
+ const moviesPromise = tmdb.searchMovies({
+ query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '',
+ year: parseInt(year),
+ });
+ const tvShowsPromise = tmdb.searchTvShows({
+ query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '',
+ year: parseInt(year),
+ });
+
+ const responses = await Promise.allSettled([moviesPromise, tvShowsPromise]);
+
+ const successfulResponses = responses.filter(
+ (r) => r.status === 'fulfilled'
+ ) as
+ | (
+ | PromiseFulfilledResult
+ | PromiseFulfilledResult
+ )[];
+
+ const results: (TmdbMovieResult | TmdbTvResult)[] = [];
+
+ if (successfulResponses.length) {
+ successfulResponses.forEach((response) => {
+ response.value.results.forEach((result) =>
+ // set the media_type here since the search endpoints don't return it
+ results.push(
+ isMovie(result)
+ ? { ...result, media_type: 'movie' }
+ : { ...result, media_type: 'tv' }
+ )
+ );
+ });
+ }
+
+ return {
+ page: 1,
+ total_pages: 1,
+ total_results: results.length,
+ results,
+ };
+ },
+});
diff --git a/server/lib/settings.ts b/server/lib/settings.ts
index f7780dfc..7a4f5f93 100644
--- a/server/lib/settings.ts
+++ b/server/lib/settings.ts
@@ -35,6 +35,15 @@ export interface PlexSettings {
webAppUrl?: string;
}
+export interface TautulliSettings {
+ hostname?: string;
+ port?: number;
+ useSsl?: boolean;
+ urlBase?: string;
+ apiKey?: string;
+ externalUrl?: string;
+}
+
export interface DVRSettings {
id: number;
name: string;
@@ -113,6 +122,7 @@ interface FullPublicSettings extends PublicSettings {
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
+ newPlexLogin: boolean;
}
export interface NotificationAgentConfig {
@@ -125,6 +135,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
+ enableMentions: boolean;
};
}
@@ -170,6 +181,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig {
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
options: {
accessToken: string;
+ channelTag?: string;
};
}
@@ -188,9 +200,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
};
}
+export interface NotificationAgentGotify extends NotificationAgentConfig {
+ options: {
+ url: string;
+ token: string;
+ };
+}
+
export enum NotificationAgentKey {
DISCORD = 'discord',
EMAIL = 'email',
+ GOTIFY = 'gotify',
PUSHBULLET = 'pushbullet',
PUSHOVER = 'pushover',
SLACK = 'slack',
@@ -202,6 +222,7 @@ export enum NotificationAgentKey {
interface NotificationAgents {
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
+ gotify: NotificationAgentGotify;
lunasea: NotificationAgentLunaSea;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
@@ -233,6 +254,7 @@ interface AllSettings {
vapidPrivate: string;
main: MainSettings;
plex: PlexSettings;
+ tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
public: PublicSettings;
@@ -279,6 +301,7 @@ class Settings {
useSsl: false,
libraries: [],
},
+ tautulli: {},
radarr: [],
sonarr: [],
public: {
@@ -304,6 +327,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
+ enableMentions: true,
},
},
lunasea: {
@@ -357,6 +381,14 @@ class Settings {
enabled: false,
options: {},
},
+ gotify: {
+ enabled: false,
+ types: 0,
+ options: {
+ url: '',
+ token: '',
+ },
+ },
},
},
jobs: {
@@ -405,6 +437,14 @@ class Settings {
this.data.plex = data;
}
+ get tautulli(): TautulliSettings {
+ return this.data.tautulli;
+ }
+
+ set tautulli(data: TautulliSettings) {
+ this.data.tautulli = data;
+ }
+
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
@@ -450,6 +490,7 @@ class Settings {
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
+ newPlexLogin: this.data.main.newPlexLogin,
};
}
diff --git a/server/logger.ts b/server/logger.ts
index 824de630..4f736e4a 100644
--- a/server/logger.ts
+++ b/server/logger.ts
@@ -1,7 +1,7 @@
+import fs from 'fs';
+import path from 'path';
import * as winston from 'winston';
import 'winston-daily-rotate-file';
-import path from 'path';
-import fs from 'fs';
// Migrate away from old log
const OLD_LOG_FILE = path.join(__dirname, '../config/logs/overseerr.log');
@@ -52,6 +52,22 @@ const logger = winston.createLogger({
createSymlink: true,
symlinkName: 'overseerr.log',
}),
+ new winston.transports.DailyRotateFile({
+ filename: process.env.CONFIG_DIRECTORY
+ ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs-%DATE%.json`
+ : path.join(__dirname, '../config/logs/.machinelogs-%DATE%.json'),
+ datePattern: 'YYYY-MM-DD',
+ zippedArchive: true,
+ maxSize: '20m',
+ maxFiles: '1d',
+ createSymlink: true,
+ symlinkName: '.machinelogs.json',
+ format: winston.format.combine(
+ winston.format.splat(),
+ winston.format.timestamp(),
+ winston.format.json()
+ ),
+ }),
],
});
diff --git a/server/models/Person.ts b/server/models/Person.ts
index 14925edb..087ab1c7 100644
--- a/server/models/Person.ts
+++ b/server/models/Person.ts
@@ -1,11 +1,11 @@
import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
- TmdbPersonDetail,
+ TmdbPersonDetails,
} from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
-export interface PersonDetail {
+export interface PersonDetails {
id: number;
name: string;
birthday: string;
@@ -14,7 +14,7 @@ export interface PersonDetail {
alsoKnownAs?: string[];
gender: number;
biography: string;
- popularity: string;
+ popularity: number;
placeOfBirth?: string;
profilePath?: string;
adult: boolean;
@@ -62,7 +62,7 @@ export interface CombinedCredit {
crew: PersonCreditCrew[];
}
-export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({
+export const mapPersonDetails = (person: TmdbPersonDetails): PersonDetails => ({
id: person.id,
name: person.name,
birthday: person.birthday,
diff --git a/server/models/Search.ts b/server/models/Search.ts
index 0dab4e58..73427a37 100644
--- a/server/models/Search.ts
+++ b/server/models/Search.ts
@@ -1,6 +1,9 @@
import type {
+ TmdbMovieDetails,
TmdbMovieResult,
+ TmdbPersonDetails,
TmdbPersonResult,
+ TmdbTvDetails,
TmdbTvResult,
} from '../api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '../constants/media';
@@ -140,3 +143,54 @@ export const mapSearchResults = (
return mapPersonResult(result);
}
});
+
+export const mapMovieDetailsToResult = (
+ movieDetails: TmdbMovieDetails
+): TmdbMovieResult => ({
+ id: movieDetails.id,
+ media_type: 'movie',
+ adult: movieDetails.adult,
+ genre_ids: movieDetails.genres.map((genre) => genre.id),
+ original_language: movieDetails.original_language,
+ original_title: movieDetails.original_title,
+ overview: movieDetails.overview ?? '',
+ popularity: movieDetails.popularity,
+ release_date: movieDetails.release_date,
+ title: movieDetails.title,
+ video: movieDetails.video,
+ vote_average: movieDetails.vote_average,
+ vote_count: movieDetails.vote_count,
+ backdrop_path: movieDetails.backdrop_path,
+ poster_path: movieDetails.poster_path,
+});
+
+export const mapTvDetailsToResult = (
+ tvDetails: TmdbTvDetails
+): TmdbTvResult => ({
+ id: tvDetails.id,
+ media_type: 'tv',
+ first_air_date: tvDetails.first_air_date,
+ genre_ids: tvDetails.genres.map((genre) => genre.id),
+ name: tvDetails.name,
+ origin_country: tvDetails.origin_country,
+ original_language: tvDetails.original_language,
+ original_name: tvDetails.original_name,
+ overview: tvDetails.overview,
+ popularity: tvDetails.popularity,
+ vote_average: tvDetails.vote_average,
+ vote_count: tvDetails.vote_count,
+ backdrop_path: tvDetails.backdrop_path,
+ poster_path: tvDetails.poster_path,
+});
+
+export const mapPersonDetailsToResult = (
+ personDetails: TmdbPersonDetails
+): TmdbPersonResult => ({
+ id: personDetails.id,
+ media_type: 'person',
+ name: personDetails.name,
+ popularity: personDetails.popularity,
+ adult: personDetails.adult,
+ profile_path: personDetails.profile_path,
+ known_for: [],
+});
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index 03fd0bad..1a12f9e4 100644
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -15,8 +15,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
if (!req.user) {
return res.status(500).json({
status: 500,
- error:
- 'Requested user endpoint without valid authenticated user in session',
+ error: 'Please sign in.',
});
}
const user = await userRepository.findOneOrFail({
@@ -32,10 +31,13 @@ authRoutes.post('/plex', async (req, res, next) => {
const body = req.body as { authToken?: string };
if (!body.authToken) {
- return res.status(500).json({ error: 'You must provide an auth token' });
+ return next({
+ status: 500,
+ message: 'Authentication token required.',
+ });
}
try {
- // First we need to use this auth token to get the users email from plex.tv
+ // First we need to use this auth token to get the user's email from plex.tv
const plextv = new PlexTvAPI(body.authToken);
const account = await plextv.getUser();
@@ -48,71 +50,78 @@ authRoutes.post('/plex', async (req, res, next) => {
})
.getOne();
- if (user) {
- // Let's check if their Plex token is up-to-date
- if (user.plexToken !== body.authToken) {
- user.plexToken = body.authToken;
- }
-
- // Update the user's avatar with their Plex thumbnail, in case it changed
- user.avatar = account.thumb;
- user.email = account.email;
- user.plexUsername = account.username;
-
- // In case the user was previously a local account
- if (user.userType === UserType.LOCAL) {
- user.userType = UserType.PLEX;
- user.plexId = account.id;
- }
+ if (!user && !(await userRepository.count())) {
+ user = new User({
+ email: account.email,
+ plexUsername: account.username,
+ plexId: account.id,
+ plexToken: account.authToken,
+ permissions: Permission.ADMIN,
+ avatar: account.thumb,
+ userType: UserType.PLEX,
+ });
await userRepository.save(user);
} else {
- // Here we check if it's the first user. If it is, we create the user with no check
- // and give them admin permissions
- const totalUsers = await userRepository.count();
+ const mainUser = await userRepository.findOneOrFail({
+ select: ['id', 'plexToken', 'plexId'],
+ order: { id: 'ASC' },
+ });
+ const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
- if (totalUsers === 0) {
- user = new User({
- email: account.email,
- plexUsername: account.username,
- plexId: account.id,
- plexToken: account.authToken,
- permissions: Permission.ADMIN,
- avatar: account.thumb,
- userType: UserType.PLEX,
- });
- await userRepository.save(user);
- }
+ if (
+ account.id === mainUser.plexId ||
+ (await mainPlexTv.checkUserAccess(account.id))
+ ) {
+ if (user) {
+ if (!user.plexId) {
+ logger.info(
+ 'Found matching Plex user; updating user with Plex data',
+ {
+ label: 'API',
+ ip: req.ip,
+ email: user.email,
+ userId: user.id,
+ plexId: account.id,
+ plexUsername: account.username,
+ }
+ );
+ }
- // Double check that we didn't create the first admin user before running this
- if (!user) {
- if (!settings.main.newPlexLogin) {
- logger.info(
- 'Failed sign-in attempt from user who has not been imported to Overseerr.',
+ user.plexToken = body.authToken;
+ user.plexId = account.id;
+ user.avatar = account.thumb;
+ user.email = account.email;
+ user.plexUsername = account.username;
+ user.userType = UserType.PLEX;
+
+ await userRepository.save(user);
+ } else if (!settings.main.newPlexLogin) {
+ logger.warn(
+ 'Failed sign-in attempt by unimported Plex user with access to the media server',
{
- label: 'Auth',
- account: {
- ...account,
- authentication_token: '__REDACTED__',
- authToken: '__REDACTED__',
- },
+ label: 'API',
+ ip: req.ip,
+ email: account.email,
+ plexId: account.id,
+ plexUsername: account.username,
}
);
return next({
status: 403,
message: 'Access denied.',
});
- }
-
- // If we get to this point, the user does not already exist so we need to create the
- // user _assuming_ they have access to the Plex server
- const mainUser = await userRepository.findOneOrFail({
- select: ['id', 'plexToken'],
- order: { id: 'ASC' },
- });
- const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
-
- if (await mainPlexTv.checkUserAccess(account.id)) {
+ } else {
+ logger.info(
+ 'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user',
+ {
+ label: 'API',
+ ip: req.ip,
+ email: account.email,
+ plexId: account.id,
+ plexUsername: account.username,
+ }
+ );
user = new User({
email: account.email,
plexUsername: account.username,
@@ -122,24 +131,24 @@ authRoutes.post('/plex', async (req, res, next) => {
avatar: account.thumb,
userType: UserType.PLEX,
});
+
await userRepository.save(user);
- } else {
- logger.info(
- 'Failed sign-in attempt from user without access to the Plex server.',
- {
- label: 'Auth',
- account: {
- ...account,
- authentication_token: '__REDACTED__',
- authToken: '__REDACTED__',
- },
- }
- );
- return next({
- status: 403,
- message: 'Access denied.',
- });
}
+ } else {
+ logger.warn(
+ 'Failed sign-in attempt by Plex user without access to the media server',
+ {
+ label: 'API',
+ ip: req.ip,
+ email: account.email,
+ plexId: account.id,
+ plexUsername: account.username,
+ }
+ );
+ return next({
+ status: 403,
+ message: 'Access denied.',
+ });
}
}
@@ -150,10 +159,14 @@ authRoutes.post('/plex', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
- logger.error(e.message, { label: 'Auth' });
+ logger.error('Something went wrong authenticating with Plex account', {
+ label: 'API',
+ errorMessage: e.message,
+ ip: req.ip,
+ });
return next({
status: 500,
- message: 'Something went wrong.',
+ message: 'Unable to authenticate.',
});
}
});
@@ -164,7 +177,7 @@ authRoutes.post('/local', async (req, res, next) => {
const body = req.body as { email?: string; password?: string };
if (!settings.main.localLogin) {
- return res.status(500).json({ error: 'Local user sign-in is disabled.' });
+ return res.status(500).json({ error: 'Password sign-in is disabled.' });
} else if (!body.email || !body.password) {
return res.status(500).json({
error: 'You must provide both an email address and a password.',
@@ -173,28 +186,77 @@ authRoutes.post('/local', async (req, res, next) => {
try {
const user = await userRepository
.createQueryBuilder('user')
- .select(['user.id', 'user.password'])
+ .select(['user.id', 'user.email', 'user.password', 'user.plexId'])
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
- const isCorrectCredentials = await user?.passwordMatch(body.password);
+ if (!user || !(await user.passwordMatch(body.password))) {
+ logger.warn('Failed sign-in attempt using invalid Overseerr password', {
+ label: 'API',
+ ip: req.ip,
+ email: body.email,
+ userId: user?.id,
+ });
+ return next({
+ status: 403,
+ message: 'Access denied.',
+ });
+ }
- // User doesn't exist or credentials are incorrect
- if (!isCorrectCredentials) {
- logger.info(
- 'Failed sign-in attempt from user with incorrect credentials.',
+ const mainUser = await userRepository.findOneOrFail({
+ select: ['id', 'plexToken', 'plexId'],
+ order: { id: 'ASC' },
+ });
+ const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
+
+ if (!user.plexId) {
+ const plexUsersResponse = await mainPlexTv.getUsers();
+ const account = plexUsersResponse.MediaContainer.User.find(
+ (account) =>
+ account.$.email &&
+ account.$.email.toLowerCase() === user.email.toLowerCase()
+ )?.$;
+
+ if (account) {
+ logger.info('Found matching Plex user; updating user with Plex data', {
+ label: 'API',
+ ip: req.ip,
+ email: body.email,
+ userId: user.id,
+ plexId: account.id,
+ plexUsername: account.username,
+ });
+
+ user.plexId = parseInt(account.id);
+ user.avatar = account.thumb;
+ user.email = account.email;
+ user.plexUsername = account.username;
+ user.userType = UserType.PLEX;
+
+ await userRepository.save(user);
+ }
+ }
+
+ if (
+ user.plexId &&
+ user.plexId !== mainUser.plexId &&
+ !(await mainPlexTv.checkUserAccess(user.plexId))
+ ) {
+ logger.warn(
+ 'Failed sign-in attempt from Plex user without access to the media server',
{
- label: 'Auth',
+ label: 'API',
account: {
ip: req.ip,
email: body.email,
- password: '__REDACTED__',
+ userId: user.id,
+ plexId: user.plexId,
},
}
);
return next({
status: 403,
- message: 'Your sign-in credentials are incorrect.',
+ message: 'Access denied.',
});
}
@@ -205,13 +267,18 @@ authRoutes.post('/local', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
- logger.error('Something went wrong while attempting to authenticate.', {
- label: 'Auth',
- error: e.message,
- });
+ logger.error(
+ 'Something went wrong authenticating with Overseerr password',
+ {
+ label: 'API',
+ errorMessage: e.message,
+ ip: req.ip,
+ email: body.email,
+ }
+ );
return next({
status: 500,
- message: 'Something went wrong.',
+ message: 'Unable to authenticate.',
});
}
});
@@ -221,7 +288,7 @@ authRoutes.post('/logout', (req, res, next) => {
if (err) {
return next({
status: 500,
- message: 'Something went wrong while attempting to sign out.',
+ message: 'Something went wrong.',
});
}
@@ -229,14 +296,15 @@ authRoutes.post('/logout', (req, res, next) => {
});
});
-authRoutes.post('/reset-password', async (req, res) => {
+authRoutes.post('/reset-password', async (req, res, next) => {
const userRepository = getRepository(User);
const body = req.body as { email?: string };
if (!body.email) {
- return res
- .status(500)
- .json({ error: 'You must provide an email address.' });
+ return next({
+ status: 500,
+ message: 'Email address required.',
+ });
}
const user = await userRepository
@@ -247,14 +315,16 @@ authRoutes.post('/reset-password', async (req, res) => {
if (user) {
await user.resetPassword();
userRepository.save(user);
- logger.info('Successful request made for recovery link.', {
- label: 'User Management',
- context: { ip: req.ip, email: body.email },
+ logger.info('Successfully sent password reset link', {
+ label: 'API',
+ ip: req.ip,
+ email: body.email,
});
} else {
- logger.info('Failed request made to reset a password.', {
- label: 'User Management',
- context: { ip: req.ip, email: body.email },
+ logger.error('Something went wrong sending password reset link', {
+ label: 'API',
+ ip: req.ip,
+ email: body.email,
});
}
@@ -264,48 +334,61 @@ authRoutes.post('/reset-password', async (req, res) => {
authRoutes.post('/reset-password/:guid', async (req, res, next) => {
const userRepository = getRepository(User);
- try {
- if (!req.body.password || req.body.password?.length < 8) {
- const message =
- 'Failed to reset password. Password must be at least 8 characters long.';
- logger.info(message, {
- label: 'User Management',
- context: { ip: req.ip, guid: req.params.guid },
- });
- return next({ status: 500, message: message });
- }
-
- const user = await userRepository.findOne({
- where: { resetPasswordGuid: req.params.guid },
+ if (!req.body.password || req.body.password?.length < 8) {
+ logger.warn('Failed password reset attempt using invalid new password', {
+ label: 'API',
+ ip: req.ip,
+ guid: req.params.guid,
});
-
- if (!user) {
- throw new Error('Guid invalid.');
- }
-
- if (
- !user.recoveryLinkExpirationDate ||
- user.recoveryLinkExpirationDate <= new Date()
- ) {
- throw new Error('Recovery link expired.');
- }
-
- await user.setPassword(req.body.password);
- user.recoveryLinkExpirationDate = null;
- userRepository.save(user);
- logger.info(`Successfully reset password`, {
- label: 'User Management',
- context: { ip: req.ip, guid: req.params.guid, email: user.email },
+ return next({
+ status: 500,
+ message: 'Password must be at least 8 characters long.',
});
-
- return res.status(200).json({ status: 'ok' });
- } catch (e) {
- logger.info(`Failed to reset password. ${e.message}`, {
- label: 'User Management',
- context: { ip: req.ip, guid: req.params.guid },
- });
- return res.status(200).json({ status: 'ok' });
}
+
+ const user = await userRepository.findOne({
+ where: { resetPasswordGuid: req.params.guid },
+ });
+
+ if (!user) {
+ logger.warn('Failed password reset attempt using invalid recovery link', {
+ label: 'API',
+ ip: req.ip,
+ guid: req.params.guid,
+ });
+ return next({
+ status: 500,
+ message: 'Invalid password reset link.',
+ });
+ }
+
+ if (
+ !user.recoveryLinkExpirationDate ||
+ user.recoveryLinkExpirationDate <= new Date()
+ ) {
+ logger.warn('Failed password reset attempt using expired recovery link', {
+ label: 'API',
+ ip: req.ip,
+ guid: req.params.guid,
+ email: user.email,
+ });
+ return next({
+ status: 500,
+ message: 'Invalid password reset link.',
+ });
+ }
+
+ await user.setPassword(req.body.password);
+ user.recoveryLinkExpirationDate = null;
+ userRepository.save(user);
+ logger.info('Successfully reset password', {
+ label: 'API',
+ ip: req.ip,
+ guid: req.params.guid,
+ email: user.email,
+ });
+
+ return res.status(200).json({ status: 'ok' });
});
export default authRoutes;
diff --git a/server/routes/collection.ts b/server/routes/collection.ts
index 8ffbb51c..aa894873 100644
--- a/server/routes/collection.ts
+++ b/server/routes/collection.ts
@@ -1,6 +1,7 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
+import logger from '../logger';
import { mapCollection } from '../models/Collection';
const collectionRoutes = Router();
@@ -20,7 +21,15 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
return res.status(200).json(mapCollection(collection, media));
} catch (e) {
- return next({ status: 404, message: 'Collection does not exist' });
+ logger.debug('Something went wrong retrieving collection', {
+ label: 'API',
+ errorMessage: e.message,
+ collectionId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve collection.',
+ });
}
});
diff --git a/server/routes/discover.ts b/server/routes/discover.ts
index 16654e81..ea78bf03 100644
--- a/server/routes/discover.ts
+++ b/server/routes/discover.ts
@@ -37,54 +37,15 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const discoverRoutes = Router();
-discoverRoutes.get('/movies', async (req, res) => {
+discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
- const data = await tmdb.getDiscoverMovies({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- genre: req.query.genre ? Number(req.query.genre) : undefined,
- studio: req.query.studio ? Number(req.query.studio) : undefined,
- });
-
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
-
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- results: data.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
- )
- )
- ),
- });
-});
-
-discoverRoutes.get<{ language: string }>(
- '/movies/language/:language',
- async (req, res, next) => {
- const tmdb = createTmdbWithRegionLanguage(req.user);
-
- const languages = await tmdb.getLanguages();
-
- const language = languages.find(
- (lang) => lang.iso_639_1 === req.params.language
- );
-
- if (!language) {
- return next({ status: 404, message: 'Unable to retrieve language' });
- }
-
+ try {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
- originalLanguage: req.params.language,
+ genre: req.query.genre ? Number(req.query.genre) : undefined,
+ studio: req.query.studio ? Number(req.query.studio) : undefined,
});
const media = await Media.getRelatedMedia(
@@ -95,7 +56,6 @@ discoverRoutes.get<{ language: string }>(
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
- language,
results: data.results.map((result) =>
mapMovieResult(
result,
@@ -106,6 +66,70 @@ discoverRoutes.get<{ language: string }>(
)
),
});
+ } catch (e) {
+ logger.debug('Something went wrong retrieving popular movies', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve popular movies.',
+ });
+ }
+});
+
+discoverRoutes.get<{ language: string }>(
+ '/movies/language/:language',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanguage(req.user);
+
+ try {
+ const languages = await tmdb.getLanguages();
+
+ const language = languages.find(
+ (lang) => lang.iso_639_1 === req.params.language
+ );
+
+ if (!language) {
+ return next({ status: 404, message: 'Language not found.' });
+ }
+
+ const data = await tmdb.getDiscoverMovies({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ originalLanguage: req.params.language,
+ });
+
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
+
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ language,
+ results: data.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving movies by language', {
+ label: 'API',
+ errorMessage: e.message,
+ language: req.params.language,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movies by language.',
+ });
+ }
}
);
@@ -114,43 +138,55 @@ discoverRoutes.get<{ genreId: string }>(
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
- const genres = await tmdb.getMovieGenres({
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const genres = await tmdb.getMovieGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
- const genre = genres.find(
- (genre) => genre.id === Number(req.params.genreId)
- );
+ const genre = genres.find(
+ (genre) => genre.id === Number(req.params.genreId)
+ );
- if (!genre) {
- return next({ status: 404, message: 'Unable to retrieve genre' });
- }
+ if (!genre) {
+ return next({ status: 404, message: 'Genre not found.' });
+ }
- const data = await tmdb.getDiscoverMovies({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- genre: Number(req.params.genreId),
- });
+ const data = await tmdb.getDiscoverMovies({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ genre: Number(req.params.genreId),
+ });
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- genre,
- results: data.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (req) =>
- req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ genre,
+ results: data.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving movies by genre', {
+ label: 'API',
+ errorMessage: e.message,
+ genreId: req.params.genreId,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movies by genre.',
+ });
+ }
}
);
@@ -188,12 +224,20 @@ discoverRoutes.get<{ studioId: string }>(
),
});
} catch (e) {
- return next({ status: 404, message: 'Unable to retrieve studio' });
+ logger.debug('Something went wrong retrieving movies by studio', {
+ label: 'API',
+ errorMessage: e.message,
+ studioId: req.params.studioId,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movies by studio.',
+ });
}
}
);
-discoverRoutes.get('/movies/upcoming', async (req, res) => {
+discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
const now = new Date();
@@ -202,79 +246,52 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
.toISOString()
.split('T')[0];
- const data = await tmdb.getDiscoverMovies({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- primaryReleaseDateGte: date,
- });
-
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
-
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- results: data.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
- )
- )
- ),
- });
-});
-
-discoverRoutes.get('/tv', async (req, res) => {
- const tmdb = createTmdbWithRegionLanguage(req.user);
-
- const data = await tmdb.getDiscoverTv({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- genre: req.query.genre ? Number(req.query.genre) : undefined,
- network: req.query.network ? Number(req.query.network) : undefined,
- });
-
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
-
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- results: data.results.map((result) =>
- mapTvResult(
- result,
- media.find(
- (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
- )
- )
- ),
- });
-});
-
-discoverRoutes.get<{ language: string }>(
- '/tv/language/:language',
- async (req, res, next) => {
- const tmdb = createTmdbWithRegionLanguage(req.user);
-
- const languages = await tmdb.getLanguages();
-
- const language = languages.find(
- (lang) => lang.iso_639_1 === req.params.language
- );
-
- if (!language) {
- return next({ status: 404, message: 'Unable to retrieve language' });
- }
-
- const data = await tmdb.getDiscoverTv({
+ try {
+ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
- originalLanguage: req.params.language,
+ primaryReleaseDateGte: date,
+ });
+
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
+
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ results: data.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving upcoming movies', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve upcoming movies.',
+ });
+ }
+});
+
+discoverRoutes.get('/tv', async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanguage(req.user);
+
+ try {
+ const data = await tmdb.getDiscoverTv({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ genre: req.query.genre ? Number(req.query.genre) : undefined,
+ network: req.query.network ? Number(req.query.network) : undefined,
});
const media = await Media.getRelatedMedia(
@@ -285,7 +302,6 @@ discoverRoutes.get<{ language: string }>(
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
- language,
results: data.results.map((result) =>
mapTvResult(
result,
@@ -295,6 +311,70 @@ discoverRoutes.get<{ language: string }>(
)
),
});
+ } catch (e) {
+ logger.debug('Something went wrong retrieving popular series', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve popular series.',
+ });
+ }
+});
+
+discoverRoutes.get<{ language: string }>(
+ '/tv/language/:language',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanguage(req.user);
+
+ try {
+ const languages = await tmdb.getLanguages();
+
+ const language = languages.find(
+ (lang) => lang.iso_639_1 === req.params.language
+ );
+
+ if (!language) {
+ return next({ status: 404, message: 'Language not found.' });
+ }
+
+ const data = await tmdb.getDiscoverTv({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ originalLanguage: req.params.language,
+ });
+
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
+
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ language,
+ results: data.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series by language', {
+ label: 'API',
+ errorMessage: e.message,
+ language: req.params.language,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series by language.',
+ });
+ }
}
);
@@ -303,42 +383,55 @@ discoverRoutes.get<{ genreId: string }>(
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
- const genres = await tmdb.getTvGenres({
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const genres = await tmdb.getTvGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
- const genre = genres.find(
- (genre) => genre.id === Number(req.params.genreId)
- );
+ const genre = genres.find(
+ (genre) => genre.id === Number(req.params.genreId)
+ );
- if (!genre) {
- return next({ status: 404, message: 'Unable to retrieve genre' });
- }
+ if (!genre) {
+ return next({ status: 404, message: 'Genre not found.' });
+ }
- const data = await tmdb.getDiscoverTv({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- genre: Number(req.params.genreId),
- });
+ const data = await tmdb.getDiscoverTv({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ genre: Number(req.params.genreId),
+ });
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- genre,
- results: data.results.map((result) =>
- mapTvResult(
- result,
- media.find(
- (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ genre,
+ results: data.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series by genre', {
+ label: 'API',
+ errorMessage: e.message,
+ genreId: req.params.genreId,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series by genre.',
+ });
+ }
}
);
@@ -376,12 +469,20 @@ discoverRoutes.get<{ networkId: string }>(
),
});
} catch (e) {
- return next({ status: 404, message: 'Unable to retrieve network' });
+ logger.debug('Something went wrong retrieving series by network', {
+ label: 'API',
+ errorMessage: e.message,
+ networkId: req.params.networkId,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series by network.',
+ });
}
}
);
-discoverRoutes.get('/tv/upcoming', async (req, res) => {
+discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
const now = new Date();
@@ -390,76 +491,47 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
.toISOString()
.split('T')[0];
- const data = await tmdb.getDiscoverTv({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- firstAirDateGte: date,
- });
+ try {
+ const data = await tmdb.getDiscoverTv({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ firstAirDateGte: date,
+ });
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- results: data.results.map((result) =>
- mapTvResult(
- result,
- media.find(
- (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ results: data.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving upcoming series', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve upcoming series.',
+ });
+ }
});
-discoverRoutes.get('/trending', async (req, res) => {
+discoverRoutes.get('/trending', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
- const data = await tmdb.getAllTrending({
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
-
- const media = await Media.getRelatedMedia(
- data.results.map((result) => result.id)
- );
-
- return res.status(200).json({
- page: data.page,
- totalPages: data.total_pages,
- totalResults: data.total_results,
- results: data.results.map((result) =>
- isMovie(result)
- ? mapMovieResult(
- result,
- media.find(
- (med) =>
- med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
- )
- )
- : isPerson(result)
- ? mapPersonResult(result)
- : mapTvResult(
- result,
- media.find(
- (med) =>
- med.tmdbId === result.id && med.mediaType === MediaType.TV
- )
- )
- ),
- });
-});
-
-discoverRoutes.get<{ keywordId: string }>(
- '/keyword/:keywordId/movies',
- async (req, res) => {
- const tmdb = new TheMovieDb();
-
- const data = await tmdb.getMoviesByKeyword({
- keywordId: Number(req.params.keywordId),
+ try {
+ const data = await tmdb.getAllTrending({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
});
@@ -473,15 +545,78 @@ discoverRoutes.get<{ keywordId: string }>(
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (med) =>
- med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
- )
- )
+ isMovie(result)
+ ? mapMovieResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
+ )
+ )
+ : isPerson(result)
+ ? mapPersonResult(result)
+ : mapTvResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
+ )
),
});
+ } catch (e) {
+ logger.debug('Something went wrong retrieving trending items', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve trending items.',
+ });
+ }
+});
+
+discoverRoutes.get<{ keywordId: string }>(
+ '/keyword/:keywordId/movies',
+ async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const data = await tmdb.getMoviesByKeyword({
+ keywordId: Number(req.params.keywordId),
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ const media = await Media.getRelatedMedia(
+ data.results.map((result) => result.id)
+ );
+
+ return res.status(200).json({
+ page: data.page,
+ totalPages: data.total_pages,
+ totalResults: data.total_results,
+ results: data.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving movies by keyword', {
+ label: 'API',
+ errorMessage: e.message,
+ keywordId: req.params.keywordId,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movies by keyword.',
+ });
+ }
}
);
@@ -515,7 +650,8 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
return res.status(200).json(sortedData);
} catch (e) {
- logger.error('Something went wrong retrieving the movie genre slider', {
+ logger.debug('Something went wrong retrieving the movie genre slider', {
+ label: 'API',
errorMessage: e.message,
});
return next({
@@ -556,12 +692,13 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
return res.status(200).json(sortedData);
} catch (e) {
- logger.error('Something went wrong retrieving the tv genre slider', {
+ logger.debug('Something went wrong retrieving the series genre slider', {
+ label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
- message: 'Unable to retrieve tv genre slider.',
+ message: 'Unable to retrieve series genre slider.',
});
}
}
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 3f57e815..e2866638 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -5,6 +5,7 @@ import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces';
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
+import logger from '../logger';
import { checkUser, isAuthenticated } from '../middleware/auth';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
@@ -114,78 +115,157 @@ router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
-router.get('/regions', isAuthenticated(), async (req, res) => {
+router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
- const regions = await tmdb.getRegions();
+ try {
+ const regions = await tmdb.getRegions();
- return res.status(200).json(regions);
+ return res.status(200).json(regions);
+ } catch (e) {
+ logger.debug('Something went wrong retrieving regions', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve regions.',
+ });
+ }
});
-router.get('/languages', isAuthenticated(), async (req, res) => {
+router.get('/languages', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
- const languages = await tmdb.getLanguages();
+ try {
+ const languages = await tmdb.getLanguages();
- return res.status(200).json(languages);
+ return res.status(200).json(languages);
+ } catch (e) {
+ logger.debug('Something went wrong retrieving languages', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve languages.',
+ });
+ }
});
-router.get<{ id: string }>('/studio/:id', async (req, res) => {
+router.get<{ id: string }>('/studio/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const studio = await tmdb.getStudio(Number(req.params.id));
+ try {
+ const studio = await tmdb.getStudio(Number(req.params.id));
- return res.status(200).json(mapProductionCompany(studio));
+ return res.status(200).json(mapProductionCompany(studio));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving studio', {
+ label: 'API',
+ errorMessage: e.message,
+ studioId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve studio.',
+ });
+ }
});
-router.get<{ id: string }>('/network/:id', async (req, res) => {
+router.get<{ id: string }>('/network/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const network = await tmdb.getNetwork(Number(req.params.id));
+ try {
+ const network = await tmdb.getNetwork(Number(req.params.id));
- return res.status(200).json(mapNetwork(network));
+ return res.status(200).json(mapNetwork(network));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving network', {
+ label: 'API',
+ errorMessage: e.message,
+ networkId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve network.',
+ });
+ }
});
-router.get('/genres/movie', isAuthenticated(), async (req, res) => {
+router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
- const genres = await tmdb.getMovieGenres({
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const genres = await tmdb.getMovieGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
- return res.status(200).json(genres);
+ return res.status(200).json(genres);
+ } catch (e) {
+ logger.debug('Something went wrong retrieving movie genres', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movie genres.',
+ });
+ }
});
-router.get('/genres/tv', isAuthenticated(), async (req, res) => {
+router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();
- const genres = await tmdb.getTvGenres({
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const genres = await tmdb.getTvGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
- return res.status(200).json(genres);
+ return res.status(200).json(genres);
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series genres', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series genres.',
+ });
+ }
});
-router.get('/backdrops', async (req, res) => {
+router.get('/backdrops', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
- const data = (
- await tmdb.getAllTrending({
- page: 1,
- timeWindow: 'week',
- })
- ).results.filter((result) => !isPerson(result)) as (
- | TmdbMovieResult
- | TmdbTvResult
- )[];
+ try {
+ const data = (
+ await tmdb.getAllTrending({
+ page: 1,
+ timeWindow: 'week',
+ })
+ ).results.filter((result) => !isPerson(result)) as (
+ | TmdbMovieResult
+ | TmdbTvResult
+ )[];
- return res
- .status(200)
- .json(
- data
- .map((result) => result.backdrop_path)
- .filter((backdropPath) => !!backdropPath)
- );
+ return res
+ .status(200)
+ .json(
+ data
+ .map((result) => result.backdrop_path)
+ .filter((backdropPath) => !!backdropPath)
+ );
+ } catch (e) {
+ logger.debug('Something went wrong retrieving backdrops', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve backdrops.',
+ });
+ }
});
router.get('/', (_req, res) => {
diff --git a/server/routes/media.ts b/server/routes/media.ts
index 34819782..429b2010 100644
--- a/server/routes/media.ts
+++ b/server/routes/media.ts
@@ -1,11 +1,17 @@
import { Router } from 'express';
-import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
-import Media from '../entity/Media';
+import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm';
+import TautulliAPI from '../api/tautulli';
import { MediaStatus, MediaType } from '../constants/media';
+import Media from '../entity/Media';
+import { User } from '../entity/User';
+import {
+ MediaResultsResponse,
+ MediaWatchDataResponse,
+} from '../interfaces/api/mediaInterfaces';
+import { Permission } from '../lib/permissions';
+import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
-import { Permission } from '../lib/permissions';
-import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces';
const mediaRoutes = Router();
@@ -161,4 +167,103 @@ mediaRoutes.delete(
}
);
+mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
+ '/:id/watch_data',
+ isAuthenticated(Permission.ADMIN),
+ async (req, res, next) => {
+ const settings = getSettings().tautulli;
+
+ if (!settings.hostname || !settings.port || !settings.apiKey) {
+ return next({
+ status: 404,
+ message: 'Tautulli API not configured.',
+ });
+ }
+
+ const media = await getRepository(Media).findOne({
+ where: { id: Number(req.params.id) },
+ });
+
+ if (!media) {
+ return next({ status: 404, message: 'Media does not exist.' });
+ }
+
+ try {
+ const tautulli = new TautulliAPI(settings);
+ const userRepository = getRepository(User);
+
+ const response: MediaWatchDataResponse = {};
+
+ if (media.ratingKey) {
+ const watchStats = await tautulli.getMediaWatchStats(media.ratingKey);
+ const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey);
+
+ const users = await userRepository
+ .createQueryBuilder('user')
+ .where('user.plexId IN (:...plexIds)', {
+ plexIds: watchUsers.map((u) => u.user_id),
+ })
+ .getMany();
+
+ const playCount =
+ watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0;
+
+ const playCount7Days =
+ watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0;
+
+ const playCount30Days =
+ watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0;
+
+ response.data = {
+ users: users,
+ playCount,
+ playCount7Days,
+ playCount30Days,
+ };
+ }
+
+ if (media.ratingKey4k) {
+ const watchStats4k = await tautulli.getMediaWatchStats(
+ media.ratingKey4k
+ );
+ const watchUsers4k = await tautulli.getMediaWatchUsers(
+ media.ratingKey4k
+ );
+
+ const users = await userRepository
+ .createQueryBuilder('user')
+ .where('user.plexId IN (:...plexIds)', {
+ plexIds: watchUsers4k.map((u) => u.user_id),
+ })
+ .getMany();
+
+ const playCount =
+ watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0;
+
+ const playCount7Days =
+ watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0;
+
+ const playCount30Days =
+ watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0;
+
+ response.data4k = {
+ users,
+ playCount,
+ playCount7Days,
+ playCount30Days,
+ };
+ }
+
+ return res.status(200).json(response);
+ } catch (e) {
+ logger.error('Something went wrong fetching media watch data', {
+ label: 'API',
+ errorMessage: e.message,
+ mediaId: req.params.id,
+ });
+ next({ status: 500, message: 'Failed to fetch watch data.' });
+ }
+ }
+);
+
export default mediaRoutes;
diff --git a/server/routes/movie.ts b/server/routes/movie.ts
index d871652a..98474c78 100644
--- a/server/routes/movie.ts
+++ b/server/routes/movie.ts
@@ -22,75 +22,105 @@ movieRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
} catch (e) {
- logger.error('Something went wrong getting movie', {
- label: 'Movie',
- message: e.message,
+ logger.debug('Something went wrong retrieving movie', {
+ label: 'API',
+ errorMessage: e.message,
+ movieId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movie.',
});
- return next({ status: 404, message: 'Movie does not exist' });
}
});
-movieRoutes.get('/:id/recommendations', async (req, res) => {
+movieRoutes.get('/:id/recommendations', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const results = await tmdb.getMovieRecommendations({
- movieId: Number(req.params.id),
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const results = await tmdb.getMovieRecommendations({
+ movieId: Number(req.params.id),
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
- const media = await Media.getRelatedMedia(
- results.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ results.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: results.page,
- totalPages: results.total_pages,
- totalResults: results.total_results,
- results: results.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ return res.status(200).json({
+ page: results.page,
+ totalPages: results.total_pages,
+ totalResults: results.total_results,
+ results: results.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving movie recommendations', {
+ label: 'API',
+ errorMessage: e.message,
+ movieId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movie recommendations.',
+ });
+ }
});
-movieRoutes.get('/:id/similar', async (req, res) => {
+movieRoutes.get('/:id/similar', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const results = await tmdb.getMovieSimilar({
- movieId: Number(req.params.id),
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const results = await tmdb.getMovieSimilar({
+ movieId: Number(req.params.id),
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
- const media = await Media.getRelatedMedia(
- results.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ results.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: results.page,
- totalPages: results.total_pages,
- totalResults: results.total_results,
- results: results.results.map((result) =>
- mapMovieResult(
- result,
- media.find(
- (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ return res.status(200).json({
+ page: results.page,
+ totalPages: results.total_pages,
+ totalResults: results.total_results,
+ results: results.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (req) =>
+ req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving similar movies', {
+ label: 'API',
+ errorMessage: e.message,
+ movieId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve similar movies.',
+ });
+ }
});
movieRoutes.get('/:id/ratings', async (req, res, next) => {
- try {
- const tmdb = new TheMovieDb();
- const rtapi = new RottenTomatoes();
+ const tmdb = new TheMovieDb();
+ const rtapi = new RottenTomatoes();
+ try {
const movie = await tmdb.getMovie({
movieId: Number(req.params.id),
});
@@ -101,12 +131,23 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
);
if (!rtratings) {
- return next({ status: 404, message: 'Unable to retrieve ratings' });
+ return next({
+ status: 404,
+ message: 'Rotten Tomatoes ratings not found.',
+ });
}
return res.status(200).json(rtratings);
} catch (e) {
- return next({ status: 404, message: 'Movie does not exist' });
+ logger.debug('Something went wrong retrieving movie ratings', {
+ label: 'API',
+ errorMessage: e.message,
+ movieId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movie ratings.',
+ });
}
});
diff --git a/server/routes/person.ts b/server/routes/person.ts
index e18e55c8..5093ae46 100644
--- a/server/routes/person.ts
+++ b/server/routes/person.ts
@@ -20,52 +20,71 @@ personRoutes.get('/:id', async (req, res, next) => {
});
return res.status(200).json(mapPersonDetails(person));
} catch (e) {
- logger.error(e.message);
- next({ status: 404, message: 'Person not found' });
+ logger.debug('Something went wrong retrieving person', {
+ label: 'API',
+ errorMessage: e.message,
+ personId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve person.',
+ });
}
});
-personRoutes.get('/:id/combined_credits', async (req, res) => {
+personRoutes.get('/:id/combined_credits', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const combinedCredits = await tmdb.getPersonCombinedCredits({
- personId: Number(req.params.id),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const combinedCredits = await tmdb.getPersonCombinedCredits({
+ personId: Number(req.params.id),
+ language: req.locale ?? (req.query.language as string),
+ });
- const castMedia = await Media.getRelatedMedia(
- combinedCredits.cast.map((result) => result.id)
- );
+ const castMedia = await Media.getRelatedMedia(
+ combinedCredits.cast.map((result) => result.id)
+ );
- const crewMedia = await Media.getRelatedMedia(
- combinedCredits.crew.map((result) => result.id)
- );
+ const crewMedia = await Media.getRelatedMedia(
+ combinedCredits.crew.map((result) => result.id)
+ );
- return res.status(200).json({
- cast: combinedCredits.cast
- .map((result) =>
- mapCastCredits(
- result,
- castMedia.find(
- (med) =>
- med.tmdbId === result.id && med.mediaType === result.media_type
+ return res.status(200).json({
+ cast: combinedCredits.cast
+ .map((result) =>
+ mapCastCredits(
+ result,
+ castMedia.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === result.media_type
+ )
)
)
- )
- .filter((item) => !item.adult),
- crew: combinedCredits.crew
- .map((result) =>
- mapCrewCredits(
- result,
- crewMedia.find(
- (med) =>
- med.tmdbId === result.id && med.mediaType === result.media_type
+ .filter((item) => !item.adult),
+ crew: combinedCredits.crew
+ .map((result) =>
+ mapCrewCredits(
+ result,
+ crewMedia.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === result.media_type
+ )
)
)
- )
- .filter((item) => !item.adult),
- id: combinedCredits.id,
- });
+ .filter((item) => !item.adult),
+ id: combinedCredits.id,
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving combined credits', {
+ label: 'API',
+ errorMessage: e.message,
+ personId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve combined credits.',
+ });
+ }
});
export default personRoutes;
diff --git a/server/routes/request.ts b/server/routes/request.ts
index 2e79ff4a..cd269f4e 100644
--- a/server/routes/request.ts
+++ b/server/routes/request.ts
@@ -259,6 +259,9 @@ requestRoutes.post('/', async (req, res, next) => {
.leftJoin('request.media', 'media')
.where('request.is4k = :is4k', { is4k: req.body.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
+ .andWhere('media.mediaType = :mediaType', {
+ mediaType: MediaType.MOVIE,
+ })
.andWhere('request.status != :requestStatus', {
requestStatus: MediaRequestStatus.DECLINED,
})
@@ -444,6 +447,20 @@ requestRoutes.get('/count', async (_req, res, next) => {
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media');
+ const totalCount = await query.getCount();
+
+ const movieCount = await query
+ .where('request.type = :requestType', {
+ requestType: MediaType.MOVIE,
+ })
+ .getCount();
+
+ const tvCount = await query
+ .where('request.type = :requestType', {
+ requestType: MediaType.TV,
+ })
+ .getCount();
+
const pendingCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.PENDING,
@@ -456,12 +473,18 @@ requestRoutes.get('/count', async (_req, res, next) => {
})
.getCount();
+ const declinedCount = await query
+ .where('request.status = :requestStatus', {
+ requestStatus: MediaRequestStatus.DECLINED,
+ })
+ .getCount();
+
const processingCount = await query
.where('request.status = :requestStatus', {
requestStatus: MediaRequestStatus.APPROVED,
})
.andWhere(
- '(request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus)',
+ '((request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus))',
{
availableStatus: MediaStatus.AVAILABLE,
}
@@ -473,7 +496,7 @@ requestRoutes.get('/count', async (_req, res, next) => {
requestStatus: MediaRequestStatus.APPROVED,
})
.andWhere(
- '(request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus)',
+ '((request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus))',
{
availableStatus: MediaStatus.AVAILABLE,
}
@@ -481,13 +504,21 @@ requestRoutes.get('/count', async (_req, res, next) => {
.getCount();
return res.status(200).json({
+ total: totalCount,
+ movie: movieCount,
+ tv: tvCount,
pending: pendingCount,
approved: approvedCount,
+ declined: declinedCount,
processing: processingCount,
available: availableCount,
});
} catch (e) {
- next({ status: 500, message: e.message });
+ logger.error('Something went wrong retrieving request counts', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ next({ status: 500, message: 'Unable to retrieve request counts.' });
}
});
diff --git a/server/routes/search.ts b/server/routes/search.ts
index c843e78c..3f26a393 100644
--- a/server/routes/search.ts
+++ b/server/routes/search.ts
@@ -1,29 +1,59 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
+import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
+import { findSearchProvider } from '../lib/search';
+import logger from '../logger';
import { mapSearchResults } from '../models/Search';
const searchRoutes = Router();
-searchRoutes.get('/', async (req, res) => {
- const tmdb = new TheMovieDb();
+searchRoutes.get('/', async (req, res, next) => {
+ const queryString = req.query.query as string;
+ const searchProvider = findSearchProvider(queryString.toLowerCase());
+ let results: TmdbSearchMultiResponse;
- const results = await tmdb.searchMulti({
- query: req.query.query as string,
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ if (searchProvider) {
+ const [id] = queryString
+ .toLowerCase()
+ .match(searchProvider.pattern) as RegExpMatchArray;
+ results = await searchProvider.search({
+ id,
+ language: req.locale ?? (req.query.language as string),
+ query: queryString,
+ });
+ } else {
+ const tmdb = new TheMovieDb();
- const media = await Media.getRelatedMedia(
- results.results.map((result) => result.id)
- );
+ results = await tmdb.searchMulti({
+ query: queryString,
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
+ }
- return res.status(200).json({
- page: results.page,
- totalPages: results.total_pages,
- totalResults: results.total_results,
- results: mapSearchResults(results.results, media),
- });
+ const media = await Media.getRelatedMedia(
+ results.results.map((result) => result.id)
+ );
+
+ return res.status(200).json({
+ page: results.page,
+ totalPages: results.total_pages,
+ totalResults: results.total_results,
+ results: mapSearchResults(results.results, media),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving search results', {
+ label: 'API',
+ errorMessage: e.message,
+ query: req.query.query,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve search results.',
+ });
+ }
});
export default searchRoutes;
diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts
index bad91eac..bd9c1164 100644
--- a/server/routes/settings/index.ts
+++ b/server/routes/settings/index.ts
@@ -1,13 +1,15 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
-import { merge, omit } from 'lodash';
+import { merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
+import semver from 'semver';
import { getRepository } from 'typeorm';
import { URL } from 'url';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
+import TautulliAPI from '../../api/tautulli';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
@@ -24,6 +26,7 @@ import { plexFullScanner } from '../../lib/scanners/plex';
import { getSettings, MainSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
+import { appDataPath } from '../../utils/appDataVolume';
import { getAppVersion } from '../../utils/appVersion';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
@@ -50,7 +53,7 @@ settingsRoutes.get('/main', (req, res, next) => {
const settings = getSettings();
if (!req.user) {
- return next({ status: 400, message: 'User missing from request' });
+ return next({ status: 400, message: 'User missing from request.' });
}
res.status(200).json(filteredMainSettings(req.user, settings.main));
@@ -71,7 +74,7 @@ settingsRoutes.post('/main/regenerate', (req, res, next) => {
const main = settings.regenerateApiKey();
if (!req.user) {
- return next({ status: 500, message: 'User missing from request' });
+ return next({ status: 500, message: 'User missing from request.' });
}
return res.status(200).json(filteredMainSettings(req.user, main));
@@ -98,16 +101,22 @@ settingsRoutes.post('/plex', async (req, res, next) => {
const result = await plexClient.getStatus();
- if (result?.MediaContainer?.machineIdentifier) {
- settings.plex.machineId = result.MediaContainer.machineIdentifier;
- settings.plex.name = result.MediaContainer.friendlyName;
-
- settings.save();
+ if (!result?.MediaContainer?.machineIdentifier) {
+ throw new Error('Server not found');
}
+
+ settings.plex.machineId = result.MediaContainer.machineIdentifier;
+ settings.plex.name = result.MediaContainer.friendlyName;
+
+ settings.save();
} catch (e) {
+ logger.error('Something went wrong testing Plex connection', {
+ label: 'API',
+ errorMessage: e.message,
+ });
return next({
status: 500,
- message: `Failed to connect to Plex: ${e.message}`,
+ message: 'Unable to connect to Plex.',
});
}
@@ -180,9 +189,13 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
}
return res.status(200).json(devices);
} catch (e) {
+ logger.error('Something went wrong retrieving Plex server list', {
+ label: 'API',
+ errorMessage: e.message,
+ });
return next({
status: 500,
- message: `Failed to connect to Plex: ${e.message}`,
+ message: 'Unable to retrieve Plex server list.',
});
}
});
@@ -225,6 +238,104 @@ settingsRoutes.post('/plex/sync', (req, res) => {
return res.status(200).json(plexFullScanner.status());
});
+settingsRoutes.get('/tautulli', (_req, res) => {
+ const settings = getSettings();
+
+ res.status(200).json(settings.tautulli);
+});
+
+settingsRoutes.post('/tautulli', async (req, res, next) => {
+ const settings = getSettings();
+
+ Object.assign(settings.tautulli, req.body);
+
+ try {
+ const tautulliClient = new TautulliAPI(settings.tautulli);
+
+ const result = await tautulliClient.getInfo();
+
+ if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
+ throw new Error('Tautulli version not supported');
+ }
+
+ settings.save();
+ } catch (e) {
+ logger.error('Something went wrong testing Tautulli connection', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to connect to Tautulli.',
+ });
+ }
+
+ return res.status(200).json(settings.tautulli);
+});
+
+settingsRoutes.get(
+ '/plex/users',
+ isAuthenticated(Permission.MANAGE_USERS),
+ async (req, res, next) => {
+ const userRepository = getRepository(User);
+ const qb = userRepository.createQueryBuilder('user');
+
+ try {
+ const admin = await userRepository.findOneOrFail({
+ select: ['id', 'plexToken'],
+ order: { id: 'ASC' },
+ });
+ const plexApi = new PlexTvAPI(admin.plexToken ?? '');
+ const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
+ (user) => user.$
+ ).filter((user) => user.email);
+
+ const unimportedPlexUsers: {
+ id: string;
+ title: string;
+ username: string;
+ email: string;
+ thumb: string;
+ }[] = [];
+
+ const existingUsers = await qb
+ .where('user.plexId IN (:...plexIds)', {
+ plexIds: plexUsers.map((plexUser) => plexUser.id),
+ })
+ .orWhere('user.email IN (:...plexEmails)', {
+ plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()),
+ })
+ .getMany();
+
+ await Promise.all(
+ plexUsers.map(async (plexUser) => {
+ if (
+ !existingUsers.find(
+ (user) =>
+ user.plexId === parseInt(plexUser.id) ||
+ user.email === plexUser.email.toLowerCase()
+ ) &&
+ (await plexApi.checkUserAccess(parseInt(plexUser.id)))
+ ) {
+ unimportedPlexUsers.push(plexUser);
+ }
+ })
+ );
+
+ return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
+ } catch (e) {
+ logger.error('Something went wrong getting unimported Plex users', {
+ label: 'API',
+ errorMessage: e.message,
+ });
+ next({
+ status: 500,
+ message: 'Unable to retrieve unimported Plex users.',
+ });
+ }
+ }
+);
+
settingsRoutes.get(
'/logs',
rateLimit({ windowMs: 60 * 1000, max: 50 }),
@@ -251,38 +362,42 @@ settingsRoutes.get(
}
const logFile = process.env.CONFIG_DIRECTORY
- ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr.log`
- : path.join(__dirname, '../../../config/logs/overseerr.log');
+ ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs.json`
+ : path.join(__dirname, '../../../config/logs/.machinelogs.json');
const logs: LogMessage[] = [];
+ const logMessageProperties = [
+ 'timestamp',
+ 'level',
+ 'label',
+ 'message',
+ 'data',
+ ];
try {
- fs.readFileSync(logFile)
- .toString()
- .split(/(?=\n\d{4}-\d{2})/g)
+ fs.readFileSync(logFile, 'utf-8')
+ .split('\n')
.forEach((line) => {
if (!line.length) return;
- const jsonRegexp = new RegExp(
- /[{[]{1}([,:{}[\]0-9.\-+Eaeflnr-u \n\r\t]|"[^"\n]*?")+[}\]]{1}/
- );
+ const logMessage = JSON.parse(line);
- const timestamp = line.match(new RegExp(/.{24}/)) || [];
- const level = line.match(new RegExp(/(?<=.{24}\s\[).+?(?=\])/)) || [];
- const label =
- line.match(new RegExp(/(?<=.{24}\s\[.+\]\[).+?(?=\])/)) || [];
- const message =
- line.match(new RegExp(/(?<=\[.+\]:\s)[\s\S][^\r]+/)) || [];
- const data = message[0].match(jsonRegexp) || [];
-
- if (level.length && filter.includes(level[0])) {
- logs.push({
- timestamp: timestamp[0],
- level: level[0],
- label: label[0],
- message: message[0].replace(jsonRegexp, ''),
- data: data.length ? JSON.parse(data[0]) : undefined,
- });
+ if (!filter.includes(logMessage.level)) {
+ return;
}
+
+ if (
+ !Object.keys(logMessage).every((key) =>
+ logMessageProperties.includes(key)
+ )
+ ) {
+ Object.keys(logMessage)
+ .filter((prop) => !logMessageProperties.includes(prop))
+ .forEach((prop) => {
+ set(logMessage, `data.${prop}`, logMessage[prop]);
+ });
+ }
+
+ logs.push(logMessage);
});
const displayedLogs = logs.reverse().slice(skip, skip + pageSize);
@@ -297,13 +412,13 @@ settingsRoutes.get(
results: displayedLogs,
} as LogsResultsResponse);
} catch (error) {
- logger.error('Something went wrong while fetching the logs', {
+ logger.error('Something went wrong while retrieving logs', {
label: 'Logs',
errorMessage: error.message,
});
return next({
status: 500,
- message: 'Something went wrong while fetching the logs',
+ message: 'Unable to retrieve logs.',
});
}
}
@@ -326,7 +441,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId);
if (!scheduledJob) {
- return next({ status: 404, message: 'Job not found' });
+ return next({ status: 404, message: 'Job not found.' });
}
scheduledJob.job.invoke();
@@ -349,7 +464,7 @@ settingsRoutes.post<{ jobId: string }>(
);
if (!scheduledJob) {
- return next({ status: 404, message: 'Job not found' });
+ return next({ status: 404, message: 'Job not found.' });
}
if (scheduledJob.cancelFn) {
@@ -375,7 +490,7 @@ settingsRoutes.post<{ jobId: string }>(
);
if (!scheduledJob) {
- return next({ status: 404, message: 'Job not found' });
+ return next({ status: 404, message: 'Job not found.' });
}
const result = rescheduleJob(scheduledJob.job, req.body.schedule);
@@ -394,7 +509,7 @@ settingsRoutes.post<{ jobId: string }>(
running: scheduledJob.running ? scheduledJob.running() : false,
});
} else {
- return next({ status: 400, message: 'Invalid job schedule' });
+ return next({ status: 400, message: 'Invalid job schedule.' });
}
}
);
@@ -421,7 +536,7 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
return res.status(204).send();
}
- next({ status: 404, message: 'Cache does not exist.' });
+ next({ status: 404, message: 'Cache not found.' });
}
);
@@ -450,6 +565,7 @@ settingsRoutes.get('/about', async (req, res) => {
totalMediaItems,
totalRequests,
tz: process.env.TZ,
+ appDataPath: appDataPath(),
} as SettingsAboutResponse);
});
diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts
index d98debb7..5a337237 100644
--- a/server/routes/settings/notifications.ts
+++ b/server/routes/settings/notifications.ts
@@ -4,6 +4,7 @@ import { Notification } from '../../lib/notifications';
import { NotificationAgent } from '../../lib/notifications/agents/agent';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
+import GotifyAgent from '../../lib/notifications/agents/gotify';
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
import PushoverAgent from '../../lib/notifications/agents/pushover';
@@ -377,4 +378,46 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
}
});
+notificationRoutes.get('/gotify', (_req, res) => {
+ const settings = getSettings();
+
+ res.status(200).json(settings.notifications.agents.gotify);
+});
+
+notificationRoutes.post('/gotify', (req, rest) => {
+ const settings = getSettings();
+
+ settings.notifications.agents.gotify = req.body;
+ settings.save();
+
+ rest.status(200).json(settings.notifications.agents.gotify);
+});
+
+notificationRoutes.post('/gotify/test', async (req, rest, next) => {
+ if (!req.user) {
+ return next({
+ status: 500,
+ message: 'User information is missing from request',
+ });
+ }
+
+ const gotifyAgent = new GotifyAgent(req.body);
+ if (
+ await gotifyAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyAdmin: false,
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return rest.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Gotify notification.',
+ });
+ }
+});
+
export default notificationRoutes;
diff --git a/server/routes/tv.ts b/server/routes/tv.ts
index 043e610f..201e7afe 100644
--- a/server/routes/tv.ts
+++ b/server/routes/tv.ts
@@ -21,104 +21,156 @@ tvRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapTvDetails(tv, media));
} catch (e) {
- logger.error('Failed to get tv show', {
+ logger.debug('Something went wrong retrieving series', {
label: 'API',
errorMessage: e.message,
+ tvId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series.',
});
- return next({ status: 404, message: 'TV Show does not exist' });
}
});
-tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
+tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const season = await tmdb.getTvSeason({
- tvId: Number(req.params.id),
- seasonNumber: Number(req.params.seasonNumber),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const season = await tmdb.getTvSeason({
+ tvId: Number(req.params.id),
+ seasonNumber: Number(req.params.seasonNumber),
+ language: req.locale ?? (req.query.language as string),
+ });
- return res.status(200).json(mapSeasonWithEpisodes(season));
+ return res.status(200).json(mapSeasonWithEpisodes(season));
+ } catch (e) {
+ logger.debug('Something went wrong retrieving season', {
+ label: 'API',
+ errorMessage: e.message,
+ tvId: req.params.id,
+ seasonNumber: req.params.seasonNumber,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve season.',
+ });
+ }
});
-tvRoutes.get('/:id/recommendations', async (req, res) => {
+tvRoutes.get('/:id/recommendations', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const results = await tmdb.getTvRecommendations({
- tvId: Number(req.params.id),
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const results = await tmdb.getTvRecommendations({
+ tvId: Number(req.params.id),
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
- const media = await Media.getRelatedMedia(
- results.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ results.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: results.page,
- totalPages: results.total_pages,
- totalResults: results.total_results,
- results: results.results.map((result) =>
- mapTvResult(
- result,
- media.find(
- (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
+ return res.status(200).json({
+ page: results.page,
+ totalPages: results.total_pages,
+ totalResults: results.total_results,
+ results: results.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series recommendations', {
+ label: 'API',
+ errorMessage: e.message,
+ tvId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series recommendations.',
+ });
+ }
});
-tvRoutes.get('/:id/similar', async (req, res) => {
+tvRoutes.get('/:id/similar', async (req, res, next) => {
const tmdb = new TheMovieDb();
- const results = await tmdb.getTvSimilar({
- tvId: Number(req.params.id),
- page: Number(req.query.page),
- language: req.locale ?? (req.query.language as string),
- });
+ try {
+ const results = await tmdb.getTvSimilar({
+ tvId: Number(req.params.id),
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ });
- const media = await Media.getRelatedMedia(
- results.results.map((result) => result.id)
- );
+ const media = await Media.getRelatedMedia(
+ results.results.map((result) => result.id)
+ );
- return res.status(200).json({
- page: results.page,
- totalPages: results.total_pages,
- totalResults: results.total_results,
- results: results.results.map((result) =>
- mapTvResult(
- result,
- media.find(
- (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
+ return res.status(200).json({
+ page: results.page,
+ totalPages: results.total_pages,
+ totalResults: results.total_results,
+ results: results.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
+ )
)
- )
- ),
- });
+ ),
+ });
+ } catch (e) {
+ logger.debug('Something went wrong retrieving similar series', {
+ label: 'API',
+ errorMessage: e.message,
+ tvId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve similar series.',
+ });
+ }
});
tvRoutes.get('/:id/ratings', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
- const tv = await tmdb.getTvShow({
- tvId: Number(req.params.id),
- });
+ try {
+ const tv = await tmdb.getTvShow({
+ tvId: Number(req.params.id),
+ });
- if (!tv) {
- return next({ status: 404, message: 'TV Show does not exist' });
+ const rtratings = await rtapi.getTVRatings(
+ tv.name,
+ tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined
+ );
+
+ if (!rtratings) {
+ return next({
+ status: 404,
+ message: 'Rotten Tomatoes ratings not found.',
+ });
+ }
+
+ return res.status(200).json(rtratings);
+ } catch (e) {
+ logger.debug('Something went wrong retrieving series ratings', {
+ label: 'API',
+ errorMessage: e.message,
+ tvId: req.params.id,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve series ratings.',
+ });
}
-
- const rtratings = await rtapi.getTVRatings(
- tv.name,
- tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined
- );
-
- if (!rtratings) {
- return next({ status: 404, message: 'Unable to retrieve ratings' });
- }
-
- return res.status(200).json(rtratings);
});
export default tvRoutes;
diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts
index e6fa09cd..a4e8861e 100644
--- a/server/routes/user/index.ts
+++ b/server/routes/user/index.ts
@@ -1,8 +1,12 @@
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
-import { getRepository, Not } from 'typeorm';
+import { findIndex, sortBy } from 'lodash';
+import { getRepository, In, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv';
+import TautulliAPI from '../../api/tautulli';
+import { MediaType } from '../../constants/media';
import { UserType } from '../../constants/user';
+import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription';
@@ -10,6 +14,7 @@ import {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
+ UserWatchDataResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
@@ -400,6 +405,7 @@ router.post(
try {
const settings = getSettings();
const userRepository = getRepository(User);
+ const body = req.body as { plexIds: string[] } | undefined;
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
@@ -434,7 +440,7 @@ router.post(
user.plexId = parseInt(account.id);
}
await userRepository.save(user);
- } else {
+ } else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
const newUser = new User({
plexUsername: account.username,
@@ -474,7 +480,8 @@ router.get<{ id: string }, QuotaResponse>(
) {
return next({
status: 403,
- message: 'You do not have permission to access this endpoint.',
+ message:
+ "You do not have permission to view this user's request limits.",
});
}
@@ -491,4 +498,112 @@ router.get<{ id: string }, QuotaResponse>(
}
);
+router.get<{ id: string }, UserWatchDataResponse>(
+ '/:id/watch_data',
+ async (req, res, next) => {
+ if (
+ Number(req.params.id) !== req.user?.id &&
+ !req.user?.hasPermission(Permission.ADMIN)
+ ) {
+ return next({
+ status: 403,
+ message:
+ "You do not have permission to view this user's recently watched media.",
+ });
+ }
+
+ const settings = getSettings().tautulli;
+
+ if (!settings.hostname || !settings.port || !settings.apiKey) {
+ return next({
+ status: 404,
+ message: 'Tautulli API not configured.',
+ });
+ }
+
+ try {
+ const user = await getRepository(User).findOneOrFail({
+ where: { id: Number(req.params.id) },
+ select: ['id', 'plexId'],
+ });
+
+ const tautulli = new TautulliAPI(settings);
+
+ const watchStats = await tautulli.getUserWatchStats(user);
+ const watchHistory = await tautulli.getUserWatchHistory(user);
+
+ const recentlyWatched = sortBy(
+ await getRepository(Media).find({
+ where: [
+ {
+ mediaType: MediaType.MOVIE,
+ ratingKey: In(
+ watchHistory
+ .filter((record) => record.media_type === 'movie')
+ .map((record) => record.rating_key)
+ ),
+ },
+ {
+ mediaType: MediaType.MOVIE,
+ ratingKey4k: In(
+ watchHistory
+ .filter((record) => record.media_type === 'movie')
+ .map((record) => record.rating_key)
+ ),
+ },
+ {
+ mediaType: MediaType.TV,
+ ratingKey: In(
+ watchHistory
+ .filter((record) => record.media_type === 'episode')
+ .map((record) => record.grandparent_rating_key)
+ ),
+ },
+ {
+ mediaType: MediaType.TV,
+ ratingKey4k: In(
+ watchHistory
+ .filter((record) => record.media_type === 'episode')
+ .map((record) => record.grandparent_rating_key)
+ ),
+ },
+ ],
+ }),
+ [
+ (media) =>
+ findIndex(
+ watchHistory,
+ (record) =>
+ (!!media.ratingKey &&
+ parseInt(media.ratingKey) ===
+ (record.media_type === 'movie'
+ ? record.rating_key
+ : record.grandparent_rating_key)) ||
+ (!!media.ratingKey4k &&
+ parseInt(media.ratingKey4k) ===
+ (record.media_type === 'movie'
+ ? record.rating_key
+ : record.grandparent_rating_key))
+ ),
+ ]
+ );
+
+ return res.status(200).json({
+ recentlyWatched,
+ playCount: watchStats.total_plays,
+ });
+ } catch (e) {
+ logger.error('Something went wrong fetching user watch data', {
+ label: 'API',
+ errorMessage: e.message,
+ userId: req.params.id,
+ });
+ next({
+ status: 500,
+ message: 'Failed to fetch user watch data.',
+ });
+ }
+ }
+);
+
export default router;
diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts
index 6558115a..0c53c94a 100644
--- a/server/routes/user/usersettings.ts
+++ b/server/routes/user/usersettings.ts
@@ -51,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({
username: user.username,
+ discordId: user.settings?.discordId,
locale: user.settings?.locale,
region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage,
@@ -109,11 +110,13 @@ userSettingsRoutes.post<
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
+ discordId: req.body.discordId,
locale: req.body.locale,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
});
} else {
+ user.settings.discordId = req.body.discordId;
user.settings.locale = req.body.locale;
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
@@ -123,8 +126,9 @@ userSettingsRoutes.post<
return res.status(200).json({
username: user.username,
- region: user.settings.region,
+ discordId: user.settings.discordId,
locale: user.settings.locale,
+ region: user.settings.region,
originalLanguage: user.settings.originalLanguage,
});
} catch (e) {
@@ -252,10 +256,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
return res.status(200).json({
emailEnabled: settings?.email.enabled,
pgpKey: user.settings?.pgpKey,
- discordEnabled: settings?.discord.enabled,
- discordEnabledTypes: settings?.discord.enabled
- ? settings?.discord.types
- : 0,
+ discordEnabled:
+ settings?.discord.enabled && settings.discord.options.enableMentions,
+ discordEnabledTypes:
+ settings?.discord.enabled && settings.discord.options.enableMentions
+ ? settings.discord.types
+ : 0,
discordId: user.settings?.discordId,
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts
index c844b614..1b1b7b55 100644
--- a/server/subscriber/IssueCommentSubscriber.ts
+++ b/server/subscriber/IssueCommentSubscriber.ts
@@ -12,6 +12,7 @@ import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
+import logger from '../logger';
@EventSubscriber()
export class IssueCommentSubscriber
@@ -26,62 +27,67 @@ export class IssueCommentSubscriber
let image: string;
const tmdb = new TheMovieDb();
- const issue = (
- await getRepository(IssueComment).findOne({
- where: { id: entity.id },
- relations: ['issue'],
- })
- )?.issue;
- if (!issue) {
- return;
- }
+ try {
+ const issue = (
+ await getRepository(IssueComment).findOneOrFail({
+ where: { id: entity.id },
+ relations: ['issue'],
+ })
+ ).issue;
- const media = await getRepository(Media).findOne({
- where: { id: issue.media.id },
- });
- if (!media) {
- return;
- }
-
- if (media.mediaType === MediaType.MOVIE) {
- const movie = await tmdb.getMovie({ movieId: media.tmdbId });
-
- title = `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`;
- image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
- } else {
- const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
-
- title = `${tvshow.name}${
- tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
- }`;
- image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
- }
-
- const [firstComment] = sortBy(issue.comments, 'id');
-
- if (entity.id !== firstComment.id) {
- // Send notifications to all issue managers
- notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
- event: `New Comment on ${
- issue.issueType !== IssueType.OTHER
- ? `${IssueTypeName[issue.issueType]} `
- : ''
- }Issue`,
- subject: title,
- message: firstComment.message,
- comment: entity,
- issue,
- media,
- image,
- notifyAdmin: true,
- notifyUser:
- !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
- issue.createdBy.id !== entity.user.id
- ? issue.createdBy
- : undefined,
+ const media = await getRepository(Media).findOneOrFail({
+ where: { id: issue.media.id },
});
+
+ if (media.mediaType === MediaType.MOVIE) {
+ const movie = await tmdb.getMovie({ movieId: media.tmdbId });
+
+ title = `${movie.title}${
+ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
+ }`;
+ image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
+ } else {
+ const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
+
+ title = `${tvshow.name}${
+ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
+ }`;
+ image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
+ }
+
+ const [firstComment] = sortBy(issue.comments, 'id');
+
+ if (entity.id !== firstComment.id) {
+ // Send notifications to all issue managers
+ notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
+ event: `New Comment on ${
+ issue.issueType !== IssueType.OTHER
+ ? `${IssueTypeName[issue.issueType]} `
+ : ''
+ }Issue`,
+ subject: title,
+ message: firstComment.message,
+ comment: entity,
+ issue,
+ media,
+ image,
+ notifyAdmin: true,
+ notifyUser:
+ !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
+ issue.createdBy.id !== entity.user.id
+ ? issue.createdBy
+ : undefined,
+ });
+ }
+ } catch (e) {
+ logger.error(
+ 'Something went wrong sending issue comment notification(s)',
+ {
+ label: 'Notifications',
+ errorMessage: e.message,
+ commentId: entity.id,
+ }
+ );
}
}
diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts
index 5cf2be59..b593095c 100644
--- a/server/subscriber/IssueSubscriber.ts
+++ b/server/subscriber/IssueSubscriber.ts
@@ -11,6 +11,7 @@ import { MediaType } from '../constants/media';
import Issue from '../entity/Issue';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
+import logger from '../logger';
@EventSubscriber()
export class IssueSubscriber implements EntitySubscriberInterface {
@@ -22,72 +23,81 @@ export class IssueSubscriber implements EntitySubscriberInterface {
let title: string;
let image: string;
const tmdb = new TheMovieDb();
- if (entity.media.mediaType === MediaType.MOVIE) {
- const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
- title = `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`;
- image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
- } else {
- const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
+ try {
+ if (entity.media.mediaType === MediaType.MOVIE) {
+ const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
- title = `${tvshow.name}${
- tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
- }`;
- image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
- }
+ title = `${movie.title}${
+ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
+ }`;
+ image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
+ } else {
+ const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
- const [firstComment] = sortBy(entity.comments, 'id');
- const extra: { name: string; value: string }[] = [];
-
- if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
- extra.push({
- name: 'Affected Season',
- value: entity.problemSeason.toString(),
- });
-
- if (entity.problemEpisode > 0) {
- extra.push({
- name: 'Affected Episode',
- value: entity.problemEpisode.toString(),
- });
+ title = `${tvshow.name}${
+ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
+ }`;
+ image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
}
- }
- notificationManager.sendNotification(type, {
- event:
- type === Notification.ISSUE_CREATED
- ? `New ${
- entity.issueType !== IssueType.OTHER
- ? `${IssueTypeName[entity.issueType]} `
- : ''
- }Issue Reported`
- : type === Notification.ISSUE_RESOLVED
- ? `${
- entity.issueType !== IssueType.OTHER
- ? `${IssueTypeName[entity.issueType]} `
- : ''
- }Issue Resolved`
- : `${
- entity.issueType !== IssueType.OTHER
- ? `${IssueTypeName[entity.issueType]} `
- : ''
- }Issue Reopened`,
- subject: title,
- message: firstComment.message,
- issue: entity,
- media: entity.media,
- image,
- extra,
- notifyAdmin: true,
- notifyUser:
- !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
- (type === Notification.ISSUE_RESOLVED ||
- type === Notification.ISSUE_REOPENED)
- ? entity.createdBy
- : undefined,
- });
+ const [firstComment] = sortBy(entity.comments, 'id');
+ const extra: { name: string; value: string }[] = [];
+
+ if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
+ extra.push({
+ name: 'Affected Season',
+ value: entity.problemSeason.toString(),
+ });
+
+ if (entity.problemEpisode > 0) {
+ extra.push({
+ name: 'Affected Episode',
+ value: entity.problemEpisode.toString(),
+ });
+ }
+ }
+
+ notificationManager.sendNotification(type, {
+ event:
+ type === Notification.ISSUE_CREATED
+ ? `New ${
+ entity.issueType !== IssueType.OTHER
+ ? `${IssueTypeName[entity.issueType]} `
+ : ''
+ }Issue Reported`
+ : type === Notification.ISSUE_RESOLVED
+ ? `${
+ entity.issueType !== IssueType.OTHER
+ ? `${IssueTypeName[entity.issueType]} `
+ : ''
+ }Issue Resolved`
+ : `${
+ entity.issueType !== IssueType.OTHER
+ ? `${IssueTypeName[entity.issueType]} `
+ : ''
+ }Issue Reopened`,
+ subject: title,
+ message: firstComment.message,
+ issue: entity,
+ media: entity.media,
+ image,
+ extra,
+ notifyAdmin: true,
+ notifyUser:
+ !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
+ (type === Notification.ISSUE_RESOLVED ||
+ type === Notification.ISSUE_REOPENED)
+ ? entity.createdBy
+ : undefined,
+ });
+ } catch (e) {
+ logger.error('Something went wrong sending issue notification(s)', {
+ label: 'Notifications',
+ errorMessage: e.message,
+ issueId: entity.id,
+ });
+ }
}
public afterInsert(event: InsertEvent): void {
diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts
index 1e279377..01752b0d 100644
--- a/server/subscriber/MediaSubscriber.ts
+++ b/server/subscriber/MediaSubscriber.ts
@@ -12,6 +12,7 @@ import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import Season from '../entity/Season';
import notificationManager, { Notification } from '../lib/notifications';
+import logger from '../logger';
@EventSubscriber()
export class MediaSubscriber implements EntitySubscriberInterface {
@@ -36,26 +37,40 @@ export class MediaSubscriber implements EntitySubscriberInterface {
if (relatedRequests.length > 0) {
const tmdb = new TheMovieDb();
- const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
- relatedRequests.forEach((request) => {
- notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
- event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
- notifyAdmin: false,
- notifyUser: request.requestedBy,
- subject: `${movie.title}${
- movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
- }`,
- message: truncate(movie.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- media: entity,
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
- request,
+ try {
+ const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
+
+ relatedRequests.forEach((request) => {
+ notificationManager.sendNotification(
+ Notification.MEDIA_AVAILABLE,
+ {
+ event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
+ notifyAdmin: false,
+ notifyUser: request.requestedBy,
+ subject: `${movie.title}${
+ movie.release_date
+ ? ` (${movie.release_date.slice(0, 4)})`
+ : ''
+ }`,
+ message: truncate(movie.overview, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ }),
+ media: entity,
+ image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
+ request,
+ }
+ );
});
- });
+ } catch (e) {
+ logger.error('Something went wrong sending media notification(s)', {
+ label: 'Notifications',
+ errorMessage: e.message,
+ mediaId: entity.id,
+ });
+ }
}
}
}
@@ -114,31 +129,40 @@ export class MediaSubscriber implements EntitySubscriberInterface {
processedSeasons.push(
...request.seasons.map((season) => season.seasonNumber)
);
- const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
- notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
- event: `${is4k ? '4K ' : ''}Series Request Now Available`,
- subject: `${tv.name}${
- tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
- }`,
- message: truncate(tv.overview, {
- length: 500,
- separator: /\s/,
- omission: '…',
- }),
- notifyAdmin: false,
- notifyUser: request.requestedBy,
- image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
- media: entity,
- extra: [
- {
- name: 'Requested Seasons',
- value: request.seasons
- .map((season) => season.seasonNumber)
- .join(', '),
- },
- ],
- request,
- });
+
+ try {
+ const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
+ notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
+ event: `${is4k ? '4K ' : ''}Series Request Now Available`,
+ subject: `${tv.name}${
+ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
+ }`,
+ message: truncate(tv.overview, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ }),
+ notifyAdmin: false,
+ notifyUser: request.requestedBy,
+ image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
+ media: entity,
+ extra: [
+ {
+ name: 'Requested Seasons',
+ value: request.seasons
+ .map((season) => season.seasonNumber)
+ .join(', '),
+ },
+ ],
+ request,
+ });
+ } catch (e) {
+ logger.error('Something went wrong sending media notification(s)', {
+ label: 'Notifications',
+ errorMessage: e.message,
+ mediaId: entity.id,
+ });
+ }
}
}
}
diff --git a/server/tsconfig.json b/server/tsconfig.json
index eb403703..d245100d 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
- "target": "ES2019",
- "lib": ["ES2019"],
+ "target": "ES2020",
"module": "commonjs",
"outDir": "../dist",
"noEmit": false
diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts
index ca12ddf4..04070244 100644
--- a/server/utils/typeHelpers.ts
+++ b/server/utils/typeHelpers.ts
@@ -1,7 +1,10 @@
import type {
+ TmdbMovieDetails,
TmdbMovieResult,
- TmdbTvResult,
+ TmdbPersonDetails,
TmdbPersonResult,
+ TmdbTvDetails,
+ TmdbTvResult,
} from '../api/themoviedb/interfaces';
export const isMovie = (
@@ -15,3 +18,15 @@ export const isPerson = (
): person is TmdbPersonResult => {
return (person as TmdbPersonResult).known_for !== undefined;
};
+
+export const isMovieDetails = (
+ movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
+): movie is TmdbMovieDetails => {
+ return (movie as TmdbMovieDetails).title !== undefined;
+};
+
+export const isTvDetails = (
+ tv: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
+): tv is TmdbTvDetails => {
+ return (tv as TmdbTvDetails).number_of_seasons !== undefined;
+};
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index 0cb277d2..0a7099cc 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -11,9 +11,9 @@ confinement: strict
parts:
overseerr:
plugin: nodejs
- nodejs-version: '14.18.1'
+ nodejs-version: '16.14.0'
nodejs-package-manager: 'yarn'
- nodejs-yarn-version: v1.22.10
+ nodejs-yarn-version: v1.22.17
build-packages:
- git
- on arm64:
diff --git a/src/assets/extlogos/gotify.svg b/src/assets/extlogos/gotify.svg
new file mode 100644
index 00000000..6d078992
--- /dev/null
+++ b/src/assets/extlogos/gotify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/services/trakt.svg b/src/assets/services/trakt.svg
new file mode 100644
index 00000000..bf7e6fc4
--- /dev/null
+++ b/src/assets/services/trakt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx
index e2952623..839f019a 100644
--- a/src/components/CollectionDetails/index.tsx
+++ b/src/components/CollectionDetails/index.tsx
@@ -41,13 +41,14 @@ const CollectionDetails: React.FC = ({
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
- const { data, error, revalidate } = useSWR(
- `/api/v1/collection/${router.query.collectionId}`,
- {
- initialData: collection,
- revalidateOnMount: true,
- }
- );
+ const {
+ data,
+ error,
+ mutate: revalidate,
+ } = useSWR(`/api/v1/collection/${router.query.collectionId}`, {
+ fallbackData: collection,
+ revalidateOnMount: true,
+ });
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx
index cddcf992..e9789c70 100644
--- a/src/components/Common/Alert/index.tsx
+++ b/src/components/Common/Alert/index.tsx
@@ -15,7 +15,7 @@ const Alert: React.FC = ({ title, children, type }) => {
bgColor: 'bg-yellow-600',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
- svg: ,
+ svg: ,
};
switch (type) {
@@ -24,7 +24,7 @@ const Alert: React.FC = ({ title, children, type }) => {
bgColor: 'bg-indigo-600',
titleColor: 'text-indigo-100',
textColor: 'text-indigo-300',
- svg: ,
+ svg: ,
};
break;
case 'error':
@@ -32,13 +32,13 @@ const Alert: React.FC = ({ title, children, type }) => {
bgColor: 'bg-red-600',
titleColor: 'text-red-100',
textColor: 'text-red-300',
- svg: ,
+ svg: ,
};
break;
}
return (
-