diff --git a/.all-contributorsrc b/.all-contributorsrc
index 3671ef223..0367e4ac8 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -312,6 +312,233 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "NuroDev",
+ "name": "nuro",
+ "avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4",
+ "profile": "https://nuro.dev",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "onedr0p",
+ "name": "ᗪєνιη ᗷυнʟ",
+ "avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4",
+ "profile": "https://github.com/onedr0p",
+ "contributions": [
+ "infra"
+ ]
+ },
+ {
+ "login": "JonnyWong16",
+ "name": "JonnyWong16",
+ "avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4",
+ "profile": "https://github.com/JonnyWong16",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "Roxedus",
+ "name": "Roxedus",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4",
+ "profile": "https://github.com/Roxedus",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "WoisWoi",
+ "name": "WoisWoi",
+ "avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4",
+ "profile": "https://github.com/WoisWoi",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "HubDuck",
+ "name": "HubDuck",
+ "avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
+ "profile": "https://github.com/HubDuck",
+ "contributions": [
+ "translation",
+ "doc"
+ ]
+ },
+ {
+ "login": "costaht",
+ "name": "costaht",
+ "avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4",
+ "profile": "https://github.com/costaht",
+ "contributions": [
+ "doc",
+ "translation"
+ ]
+ },
+ {
+ "login": "Shjosan",
+ "name": "Shjosan",
+ "avatar_url": "https://avatars.githubusercontent.com/u/20847626?v=4",
+ "profile": "https://github.com/Shjosan",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "kobaubarr",
+ "name": "kobaubarr",
+ "avatar_url": "https://avatars.githubusercontent.com/u/28481522?v=4",
+ "profile": "https://github.com/kobaubarr",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "notorius28",
+ "name": "Ricardo González",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1621513?v=4",
+ "profile": "https://github.com/notorius28",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "Torkiliuz",
+ "name": "Torkil",
+ "avatar_url": "https://avatars.githubusercontent.com/u/460764?v=4",
+ "profile": "http://torkili.uz",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "JagandeepBrar",
+ "name": "Jagandeep Brar",
+ "avatar_url": "https://avatars.githubusercontent.com/u/3048295?v=4",
+ "profile": "https://www.jagandeepbrar.io",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "dtalens",
+ "name": "dtalens",
+ "avatar_url": "https://avatars.githubusercontent.com/u/6631832?v=4",
+ "profile": "http://dtalens.com",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "acortelyou",
+ "name": "Alex Cortelyou",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1689668?v=4",
+ "profile": "https://github.com/acortelyou",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jonocairns",
+ "name": "Jono Cairns",
+ "avatar_url": "https://avatars.githubusercontent.com/u/182836?v=4",
+ "profile": "https://nz.linkedin.com/in/jonocairns",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "DJScias",
+ "name": "DJScias",
+ "avatar_url": "https://avatars.githubusercontent.com/u/439655?v=4",
+ "profile": "https://scias.net/",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "Dabu-dot",
+ "name": "Dabu-dot",
+ "avatar_url": "https://avatars.githubusercontent.com/u/52525576?v=4",
+ "profile": "https://github.com/Dabu-dot",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "Jabster28",
+ "name": "Jabster28",
+ "avatar_url": "https://avatars.githubusercontent.com/u/29015942?v=4",
+ "profile": "https://github.com/Jabster28",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "littlerooster",
+ "name": "littlerooster",
+ "avatar_url": "https://avatars.githubusercontent.com/u/83890654?v=4",
+ "profile": "https://github.com/littlerooster",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "dphildebrandt",
+ "name": "Dustin Hildebrandt",
+ "avatar_url": "https://avatars.githubusercontent.com/u/154459?v=4",
+ "profile": "https://github.com/dphildebrandt",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "Generator",
+ "name": "Bruno Guerreiro",
+ "avatar_url": "https://avatars.githubusercontent.com/u/44146?v=4",
+ "profile": "https://github.com/Generator",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "iceHtwoO",
+ "name": "Alexander Neuhäuser",
+ "avatar_url": "https://avatars.githubusercontent.com/u/27020492?v=4",
+ "profile": "https://github.com/iceHtwoO",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "liviokanone",
+ "name": "Livio",
+ "avatar_url": "https://avatars.githubusercontent.com/u/37431541?v=4",
+ "profile": "http://www.unext.co.jp",
+ "contributions": [
+ "design"
+ ]
+ },
+ {
+ "login": "tangentThought",
+ "name": "tangentThought",
+ "avatar_url": "https://avatars.githubusercontent.com/u/25516090?v=4",
+ "profile": "https://github.com/tangentThought",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "nicospz",
+ "name": "Nicolás Espinoza",
+ "avatar_url": "https://avatars.githubusercontent.com/u/31373060?v=4",
+ "profile": "https://github.com/nicospz",
+ "contributions": [
+ "code"
+ ]
}
],
"badgeTemplate": " -orange.svg\"/> ",
diff --git a/.dockerignore b/.dockerignore
index 4095dce56..3ddaa574e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,16 +8,20 @@
.git
.gitbook.yaml
.gitconfig
-.gitignore
.github
+.gitignore
.next
.prettierignore
-config/db/db.sqlite3
-config/db/logs/overseerr.log
+config/db/*
+config/logs/*
+config/*.json
+dist
Dockerfile*
docker-compose.yml
docs
LICENSE
node_modules
+public/os_logo_filled.png
+public/preview.jpg
snap
stylelint.config.js
diff --git a/.eslintrc.js b/.eslintrc.js
index c7286440c..b1c6f4b9f 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -4,12 +4,10 @@ module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
- 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
- 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
- 'prettier/react',
+ 'prettier',
],
parserOptions: {
ecmaVersion: 6,
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..2883a5d26
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,26 @@
+* text eol=lf
+
+#
+## These files are binary and should be left untouched
+#
+
+# (binary is a macro for -text -diff)
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.mov binary
+*.mp4 binary
+*.mp3 binary
+*.flv binary
+*.fla binary
+*.swf binary
+*.gz binary
+*.zip binary
+*.7z binary
+*.ttf binary
+*.eot binary
+*.woff binary
+*.pyc binary
+*.pdf binary
diff --git a/.gitbook.yaml b/.gitbook.yaml
index 6c5133ed3..2b0a6c4e8 100644
--- a/.gitbook.yaml
+++ b/.gitbook.yaml
@@ -1,5 +1,5 @@
root: ./docs
structure:
- readme: README.md
- summary: SUMMARY.md
+ readme: README.md
+ summary: SUMMARY.md
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 9d966899f..6b2dc7002 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,2 @@
-github: [sct]
+github: [sct]
patreon: overseerr
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 522628a6d..f65cfa766 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Support via Discord
- url: https://discord.gg/PkCWJSeCk7
+ url: https://discord.gg/overseerr
about: Chat with users and devs on support and setup related topics.
- name: Support via GitHub Discussions
url: https://github.com/sct/overseerr/discussions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ffc747549..9945e234d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,53 +12,55 @@ jobs:
test:
name: Lint & Test Build
runs-on: ubuntu-20.04
- container: node:12.18-alpine
+ container: node:14.17-alpine
steps:
- - name: checkout
- uses: actions/checkout@v2
- - name: install dependencies
+ - name: Checkout
+ uses: actions/checkout@v2.3.4
+ - name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
run: yarn
- - name: lint
+ - name: Lint
run: yarn lint
- - name: build
+ - name: Build
run: yarn build
+
build_and_push:
- name: Build & Publish to Docker Hub
+ 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
+ uses: actions/checkout@v2.3.4
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1
+ uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1
+ uses: docker/setup-buildx-action@v1.3.0
- name: Cache Docker layers
- uses: actions/cache@v2.1.4
+ uses: actions/cache@v2.1.6
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- - name: Login to DockerHub
- uses: docker/login-action@v1
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v1.9.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v1
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v1.9.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
- password: ${{ secrets.CR_PAT }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v2
+ uses: docker/build-push-action@v2.5.0
with:
context: .
file: ./Dockerfile
+ platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
@@ -68,7 +70,15 @@ jobs:
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
- cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
+ cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
+ - # Temporary fix
+ # https://github.com/docker/build-push-action/issues/252
+ # https://github.com/moby/buildkit/issues/1896
+ name: Move cache
+ run: |
+ rm -rf /tmp/.buildx-cache
+ mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+
discord:
name: Send Discord Notification
needs: build_and_push
@@ -76,8 +86,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.2
-
+ uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |
@@ -87,7 +96,6 @@ jobs:
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
-
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml
index 13e3e01d2..809d47068 100644
--- a/.github/workflows/deploy_docs.yml
+++ b/.github/workflows/deploy_docs.yml
@@ -11,12 +11,12 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Generate Swagger UI
- uses: Legion2/swagger-ui-action@v1
+ uses: Legion2/swagger-ui-action@v1.1.2
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
- uses: peaceiris/actions-gh-pages@v3
+ uses: peaceiris/actions-gh-pages@v3.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui
diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml
index 641b1d6a5..24f95d741 100644
--- a/.github/workflows/invalid_template.yml
+++ b/.github/workflows/invalid_template.yml
@@ -8,7 +8,7 @@ jobs:
support:
runs-on: ubuntu-20.04
steps:
- - uses: dessant/support-requests@v2
+ - uses: dessant/support-requests@v2.0.1
with:
github-token: ${{ github.token }}
support-label: 'invalid:template-incomplete'
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 000000000..a42464280
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,44 @@
+name: Overseerr Preview
+
+on:
+ push:
+ tags:
+ - 'preview-*'
+
+jobs:
+ build_and_push:
+ name: Build & Publish Docker Preview Images
+ runs-on: ubuntu-20.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2.3.4
+ - name: Get the version
+ id: get_version
+ run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v1.2.0
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v1.3.0
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v1.9.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_TOKEN }}
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v1.9.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v2.5.0
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ COMMIT_TAG=${{ github.sha }}
+ tags: |
+ sctx/overseerr:${{ steps.get_version.outputs.VERSION }}
+ ghcr.io/sct/overseerr:${{ steps.get_version.outputs.VERSION }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9e929ce9f..0f1f661d7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,31 +9,47 @@ jobs:
test:
name: Lint & Test Build
runs-on: ubuntu-20.04
- container: node:12.18-alpine
+ container: node:14.17-alpine
steps:
- - name: checkout
- uses: actions/checkout@v2
- - name: install dependencies
+ - name: Checkout
+ uses: actions/checkout@v2.3.4
+ - name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
run: yarn
- - name: lint
+ - name: Lint
run: yarn lint
- - name: build
+ - 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
+ uses: actions/checkout@v2.3.4
with:
fetch-depth: 0
- - name: Setup Node.js
- uses: actions/setup-node@v1
+ - name: Set up Node.js
+ uses: actions/setup-node@v2
with:
- node-version: 12
+ node-version: 14
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v1.2.0
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v1.3.0
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v1.9.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_TOKEN }}
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v1.9.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: yarn
- name: Release
@@ -42,6 +58,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release
+
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
@@ -55,14 +72,13 @@ jobs:
- armhf
steps:
- name: Checkout Code
- uses: actions/checkout@v2
+ uses: actions/checkout@v2.3.4
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Pull latest changes
run: git pull
-
- name: Prepare
id: prepare
run: |
@@ -72,35 +88,31 @@ jobs:
else
echo ::set-output name=RELEASE::edge
fi
-
- name: Set Up QEMU
- uses: docker/setup-qemu-action@v1
+ uses: docker/setup-qemu-action@v1.2.0
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
-
- name: Build Snap Package
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.2.0
+ 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: semantic-release
@@ -108,8 +120,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.2
-
+ uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |
@@ -119,7 +130,6 @@ jobs:
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
-
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml
index ac68a3278..3e82fe9ae 100644
--- a/.github/workflows/snap.yaml
+++ b/.github/workflows/snap.yaml
@@ -2,7 +2,8 @@ name: Publish Snap
on:
push:
- branches: [develop]
+ branches:
+ - develop
jobs:
jobs:
@@ -11,25 +12,27 @@ jobs:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
- uses: styfle/cancel-workflow-action@0.8.0
+ 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:12.18-alpine
+ container: node:14.17-alpine
steps:
- - name: checkout
- uses: actions/checkout@v2
- - name: install dependencies
+ - name: Checkout
+ uses: actions/checkout@v2.3.4
+ - name: Install dependencies
env:
HUSKY_SKIP_INSTALL: 1
run: yarn
- - name: lint
+ - name: Lint
run: yarn lint
- - name: build
+ - name: Build
run: yarn build
+
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: test
@@ -43,8 +46,7 @@ jobs:
- armhf
steps:
- name: Checkout Code
- uses: actions/checkout@v2
-
+ uses: actions/checkout@v2.3.4
- name: Prepare
id: prepare
run: |
@@ -54,35 +56,31 @@ jobs:
else
echo ::set-output name=RELEASE::edge
fi
-
- name: Set Up QEMU
- uses: docker/setup-qemu-action@v1
+ uses: docker/setup-qemu-action@v1.2.0
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
-
- name: Build Snap Package
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.2.0
+ 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
@@ -90,8 +88,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
- uses: technote-space/workflow-conclusion-action@v2.1.2
-
+ uses: technote-space/workflow-conclusion-action@v2.1.6
- name: Combine Job Status
id: status
run: |
@@ -101,7 +98,6 @@ jobs:
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
-
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
diff --git a/.github/workflows/support.yml b/.github/workflows/support.yml
index 4e9311ec1..09d86bc6b 100644
--- a/.github/workflows/support.yml
+++ b/.github/workflows/support.yml
@@ -8,7 +8,7 @@ jobs:
support:
runs-on: ubuntu-20.04
steps:
- - uses: dessant/support-requests@v2
+ - uses: dessant/support-requests@v2.0.1
with:
github-token: ${{ github.token }}
support-label: 'support'
@@ -18,8 +18,9 @@ jobs:
to be a support request. Please use our support channels
to get help with Overseerr.
- - [Discord](https://discord.gg/PkCWJSeCk7)
+ - [Discord](https://discord.gg/overseerr)
+
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
close-issue: true
- lock-issue: false
+ lock-issue: true
issue-lock-reason: 'off-topic'
diff --git a/.gitignore b/.gitignore
index 8e1866224..0bc4be4a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,7 +32,7 @@ yarn-error.log*
.vercel
# database
-config/db/*.sqlite3
+config/db/*.sqlite3*
config/settings.json
# logs
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c7f618e50..799ff92ae 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -15,8 +15,9 @@
"database": "./config/db/db.sqlite3"
}
],
- "i18n-ally.localesPaths": [
- "src/i18n",
- "src/i18n/locale"
- ]
+ "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locale"],
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": true
+ },
+ "editor.formatOnSave": true
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9a97a7966..9e16f9eab 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,80 +1,87 @@
# Contributing to Overseerr
-All help is welcome and greatly appreciated. If you would like to contribute to the project, the instructions below can get you started...
+All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
## Development
### Tools Required
-- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
-- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
+- HTML/Typescript/Javascript editor
+ - [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
+- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
- [Yarn](https://yarnpkg.com/)
- [Git](https://git-scm.com/downloads)
### Getting Started
-1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
+1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
```bash
git clone https://github.com/YOUR_USERNAME/overseerr.git
cd overseerr/
```
-2. Add the remote upstream.
+2. Add the remote `upstream`:
```bash
git remote add upstream https://github.com/sct/overseerr.git
```
-3. Create a new branch
+3. Create a new branch:
```bash
git checkout -b BRANCH_NAME develop
```
- - It is recommended to name the branch something relevant to the feature or fix you are working on.
- - An example of this would be `fix-title-cards` or `feature-new-system`.
- - Bad examples would be `patch` or `bug`.
+ - It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
+ - Good examples:
+ - `docs-docker`
+ - `feature-new-system`
+ - `fix-title-cards`
+ - Bad examples:
+ - `bug`
+ - `docs`
+ - `feature`
+ - `fix`
+ - `patch`
-4. Run development environment
+4. Run the development environment:
```bash
yarn
yarn dev
```
- - Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
+ - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
-5. Create your patch and run appropriate tests.
+5. Create your patch and test your changes.
-6. Follow the [guidelines](#contributing-code).
-
-7. Should you need to update your fork, you can do so by rebasing from `upstream`:
-
- ```bash
- git fetch upstream
- git rebase upstream/develop
- git push origin BRANCH_NAME -f
- ```
+ - Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
+ - Should you need to update your fork, you can do so by rebasing from `upstream`:
+ ```bash
+ git fetch upstream
+ git rebase upstream/develop
+ git push origin BRANCH_NAME -f
+ ```
### Contributing Code
-- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
+- 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 if you squash your PR down to be a single commit that fits this standard.
- - PRs with commits not following this standard will not be merged.
+ - 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.
-- Always rebase your commit to the latest `develop` branch. Do not merge `develop` into your branch.
-- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest `develop` branch.
+- 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.
-- Your code must be formatted correctly or the tests will fail.
- - We use Prettier to format our codebase. It should automatically run with a `git` hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
-- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
-- Only open pull requests to `develop`. Never `master`. Any PRs opened to `master` will be closed.
+- Your code **must** be formatted correctly, or the tests will fail.
+ - We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
+- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/overseerr).
+- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
### UI Text Style
-When adding new UI text, please be sure to adhere to the following guidelines:
+When adding new UI text, please try to adhere to the following guidelines:
1. Be concise and clear, and use as few words as possible to make your point.
2. Use the Oxford comma where appropriate.
@@ -90,7 +97,7 @@ When adding new UI text, please be sure to adhere to the following guidelines:
## Translation
-We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
+We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
diff --git a/Dockerfile b/Dockerfile
index 281ba10e4..cb80274ee 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,33 +1,46 @@
-FROM node:14.15-alpine AS BUILD_IMAGE
+FROM node:14.17-alpine AS BUILD_IMAGE
+
+WORKDIR /app
+
+ARG TARGETPLATFORM
+ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
+
+RUN \
+ case "${TARGETPLATFORM}" in \
+ 'linux/arm64') apk add --no-cache python make g++ ;; \
+ 'linux/arm/v7') apk add --no-cache python make g++ ;; \
+ esac
+
+COPY package.json yarn.lock ./
+RUN yarn install --frozen-lockfile --network-timeout 1000000
+
+COPY . ./
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
-COPY . /app
-WORKDIR /app
-
-RUN yarn --frozen-lockfile && \
- yarn build
+RUN yarn build
# remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
-RUN rm -rf src && \
- rm -rf server
+RUN rm -rf src server
RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
-FROM node:14.15-alpine
+FROM node:14.17-alpine
-RUN apk add --no-cache tzdata
-
-# copy from build image
-COPY --from=BUILD_IMAGE /app /app
WORKDIR /app
-CMD yarn start
+RUN apk add --no-cache tzdata tini
+
+# copy from build image
+COPY --from=BUILD_IMAGE /app ./
+
+ENTRYPOINT [ "/sbin/tini", "--" ]
+CMD [ "yarn", "start" ]
EXPOSE 5055
diff --git a/Dockerfile.local b/Dockerfile.local
index 474705136..b0b922e1f 100644
--- a/Dockerfile.local
+++ b/Dockerfile.local
@@ -1,4 +1,4 @@
-FROM node:12.18-alpine
+FROM node:14.17-alpine
COPY . /app
WORKDIR /app
diff --git a/README.md b/README.md
index 70bd55d5d..02c4a81a8 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,18 @@
-
+
-
+
-
+
@@ -22,18 +22,14 @@
- Full Plex integration. Authenticate and manage user access with Plex!
- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come!
-- Plex library sync, to keep track of the titles which are already available.
+- Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
-## Planned Features
-
-- Additional notification types.
-- Issues system. This will allow users to report issues with content on your media server.
-- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
+With more features on the way! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
## Getting Started
@@ -41,26 +37,6 @@ Check out our documentation for instructions on how to install and run Overseerr
https://docs.overseerr.dev/getting-started/installation
-## Running Overseerr
-
-Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with:
-
-```
-docker run -d \
- -e LOG_LEVEL=info \
- -e TZ=Asia/Tokyo \
- -p 5055:5055 \
- -v /path/to/appdata/config:/app/config \
- --restart unless-stopped \
- sctx/overseerr
-```
-
-After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
-
-For more information or alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
-
-⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
-
## Preview
@@ -68,7 +44,7 @@ For more information or alternative installation methods, please see the [Overse
## Support
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
-- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
+- You can get support on [Discord](https://discord.gg/overseerr).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
@@ -82,7 +58,7 @@ You can also access the API documentation from your local Overseerr install at h
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
-If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/PkCWJSeCk7)!
+If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/overseerr)!
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
@@ -140,6 +116,39 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
David 💻
Douglas Parker 📖
Daniel Carter 💻
+ nuro 📖
+ ᗪєνιη ᗷυнʟ 🚇
+
+
+ JonnyWong16 📖
+ Roxedus 📖
+ WoisWoi 🌍
+ HubDuck 🌍 📖
+ costaht 📖 🌍
+ Shjosan 🌍
+ kobaubarr 🌍
+
+
+ Ricardo González 🌍
+ Torkil 🌍
+ Jagandeep Brar 📖
+ dtalens 🌍
+ Alex Cortelyou 💻
+ Jono Cairns 💻
+ DJScias 🌍
+
+
+ Dabu-dot 🌍
+ Jabster28 💻
+ littlerooster 🌍
+ Dustin Hildebrandt 💻
+ Bruno Guerreiro 🌍
+ Alexander Neuhäuser 🌍
+ Livio 🎨
+
+
+ tangentThought 💻
+ Nicolás Espinoza 💻
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 0dc3de7c5..2b309dccb 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -8,15 +8,26 @@
## Using Overseerr
+- [Settings](using-overseerr/settings/README.md)
+- [Users](using-overseerr/users/README.md)
- [Notifications](using-overseerr/notifications/README.md)
- - [Custom Webhooks](using-overseerr/notifications/webhooks.md)
+ - [Email](using-overseerr/notifications/email.md)
+ - [Web Push](using-overseerr/notifications/webpush.md)
+ - [Discord](using-overseerr/notifications/discord.md)
+ - [LunaSea](using-overseerr/notifications/lunasea.md)
+ - [Pushbullet](using-overseerr/notifications/pushbullet.md)
+ - [Pushover](using-overseerr/notifications/pushover.md)
+ - [Slack](using-overseerr/notifications/slack.md)
+ - [Telegram](using-overseerr/notifications/telegram.md)
+ - [Webhook](using-overseerr/notifications/webhooks.md)
## Support
- [Frequently Asked Questions (FAQ)](support/faq.md)
-- [Asking for Support](support/asking-for-support.md)
+- [Need Help?](support/need-help.md)
## Extending Overseerr
-- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
-- [Fail2ban Filter](extending-overseerr/fail2ban.md)
+- [Reverse Proxy](extending-overseerr/reverse-proxy.md)
+- [Fail2ban](extending-overseerr/fail2ban.md)
+- [Third-Party Integrations](extending-overseerr/third-party.md)
diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md
deleted file mode 100644
index 8fa120ee9..000000000
--- a/docs/extending-overseerr/reverse-proxy-examples.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Reverse Proxy Examples
-
-{% hint style="warning" %}
-Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
-{% endhint %}
-
-## [SWAG (Secure Web Application Gateway, formerly known as `letsencrypt`)](https://github.com/linuxserver/docker-swag)
-
-A sample proxy configuration is included in SWAG. However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
-
-To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
-
-```nginx
-server {
- listen 443 ssl http2;
- listen [::]:443 ssl http2;
-
- server_name overseerr.*;
-
- include /config/nginx/ssl.conf;
-
- client_max_body_size 0;
-
- location / {
-
- include /config/nginx/proxy.conf;
- resolver 127.0.0.11 valid=30s;
- set $upstream_app overseerr;
- set $upstream_port 5055;
- set $upstream_proto http;
- proxy_pass $upstream_proto://$upstream_app:$upstream_port;
-
- }
-
-}
-```
-
-## Traefik \(v2\)
-
-Add the following labels to the Overseerr service in your `docker-compose.yml` file:
-
-```text
-labels:
- - "traefik.enable=true"
- ## HTTP Routers
- - "traefik.http.routers.overseerr-rtr.entrypoints=https"
- - "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
- - "traefik.http.routers.overseerr-rtr.tls=true"
- ## HTTP Services
- - "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
- - "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
-```
-
-For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
-
-## `nginx`
-
-Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
-
-```text
-server {
- listen 80;
- server_name overseerr.example.com;
- return 301 https://$server_name$request_uri;
-}
-
-server {
- listen 443 ssl http2;
- server_name overseerr.example.com;
-
- ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
-
- proxy_set_header Referer $http_referer;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Real-Port $remote_port;
- proxy_set_header X-Forwarded-Host $host:$remote_port;
- proxy_set_header X-Forwarded-Server $host;
- proxy_set_header X-Forwarded-Port $remote_port;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Forwarded-Ssl on;
- real_ip_header CF-Connecting-IP;
- # Control the behavior of the Referer header (Referrer-Policy)
- add_header Referrer-Policy "no-referrer";
- # HTTP Strict Transport Security
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
- # Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
- # add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
- # Prevent some categories of XSS attacks (X-XSS-Protection)
- add_header X-XSS-Protection "1; mode=block" always;
- # Provide clickjacking protection (X-Frame-Options)
- add_header X-Frame-Options "SAMEORIGIN" always;
- # Prevent Sniff Mimetype (X-Content-Type-Options)
- add_header X-Content-Type-Options "nosniff" always;
- # Tell crawling bots to not index the site
- add_header X-Robots-Tag "noindex, nofollow" always;
-
- access_log /var/log/nginx/overseerr.example.com-access.log;
- error_log /var/log/nginx/overseerr.example.com-error.log;
-
- location / {
- proxy_pass http://127.0.0.1:5055;
- }
-}
-```
-
-Then, create a symlink to `/etc/nginx/sites-enabled`:
-
-```bash
-sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
-```
-
-Next, test the configuration:
-
-```bash
-sudo nginx -t
-```
-
-Finally, reload `nginx` for the new configuration to take effect:
-
-```bash
-sudo systemctl reload nginx
-```
diff --git a/docs/extending-overseerr/reverse-proxy.md b/docs/extending-overseerr/reverse-proxy.md
new file mode 100644
index 000000000..1ebb4b469
--- /dev/null
+++ b/docs/extending-overseerr/reverse-proxy.md
@@ -0,0 +1,173 @@
+# Reverse Proxy
+
+{% hint style="warning" %}
+Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
+
+A Nginx subfolder workaround configuration is provided below, but it is not officially supported.
+{% endhint %}
+
+## Nginx
+
+{% tabs %}
+{% tab title="SWAG" %}
+
+A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag).
+
+However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
+
+To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`.
+
+Alternatively, you can create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
+
+```nginx
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+
+ server_name overseerr.*;
+
+ include /config/nginx/ssl.conf;
+
+ client_max_body_size 0;
+
+ location / {
+ include /config/nginx/proxy.conf;
+ resolver 127.0.0.11 valid=30s;
+ set $upstream_app overseerr;
+ set $upstream_port 5055;
+ set $upstream_proto http;
+ proxy_pass $upstream_proto://$upstream_app:$upstream_port;
+ }
+
+}
+```
+
+{% endtab %}
+
+{% tab title="Nginx Proxy Manager" %}
+
+Add a new proxy host with the following settings:
+
+### Details
+
+- **Domain Names:** Your desired external Overseerr hostname; e.g., `overseerr.example.com`
+- **Scheme:** `http`
+- **Forward Hostname / IP:** Internal Overseerr hostname or IP
+- **Forward Port:** `5055`
+- **Cache Assets:** yes
+- **Block Common Exploits:** yes
+
+### SSL
+
+- **SSL Certificate:** Select one of the options; if you are not sure, pick “Request a new SSL Certificate”
+- **Force SSL:** yes
+- **HTTP/2 Support:** yes
+
+{% endtab %}
+
+{% tab title="Subdomain" %}
+
+Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
+
+```nginx
+server {
+ listen 80;
+ server_name overseerr.example.com;
+ return 301 https://$server_name$request_uri;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name overseerr.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
+
+ proxy_set_header Referer $http_referer;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Real-Port $remote_port;
+ proxy_set_header X-Forwarded-Host $host:$remote_port;
+ proxy_set_header X-Forwarded-Server $host;
+ proxy_set_header X-Forwarded-Port $remote_port;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Ssl on;
+
+ location / {
+ proxy_pass http://127.0.0.1:5055;
+ }
+}
+```
+
+Then, create a symlink to `/etc/nginx/sites-enabled`:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
+```
+
+{% endtab %}
+
+{% tab title="Subfolder" %}
+
+{% hint style="warning" %}
+This Nginx subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Overseerr is updated.
+
+If you encounter any issues with Overseerr while using this workaround, we may ask you to try to reproduce the problem without the Nginx proxy.
+{% endhint %}
+
+Add the following location block to your existing `nginx.conf` file.
+
+```nginx
+location ^~ /overseerr {
+ set $app 'overseerr';
+
+ # Remove /overseerr path to pass to the app
+ rewrite ^/overseerr/?(.*)$ /$1 break;
+ proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
+
+ # Redirect location headers
+ proxy_redirect ^ /$app;
+ proxy_redirect /setup /$app/setup;
+ proxy_redirect /login /$app/login;
+
+ # Sub filters to replace hardcoded paths
+ proxy_set_header Accept-Encoding "";
+ sub_filter_once off;
+ sub_filter_types *;
+ sub_filter 'href="/"' 'href="/$app"';
+ sub_filter 'href="/login"' 'href="/$app/login"';
+ sub_filter 'href:"/"' 'href:"/$app"';
+ sub_filter '/_next' '/$app/_next';
+ sub_filter '/api/v1' '/$app/api/v1';
+ sub_filter '/login/plex/loading' '/$app/login/plex/loading';
+ sub_filter '/images/' '/$app/images/';
+ sub_filter '/android-' '/$app/android-';
+ sub_filter '/apple-' '/$app/apple-';
+ sub_filter '/favicon' '/$app/favicon';
+ sub_filter '/logo_full.svg' '/$app/logo_full.svg';
+ sub_filter '/logo_stacked.svg' '/$app/logo_stacked.svg';
+ sub_filter '/site.webmanifest' '/$app/site.webmanifest';
+}
+```
+
+{% endtab %}
+{% endtabs %}
+
+## Traefik (v2)
+
+Add the following labels to the Overseerr service in your `docker-compose.yml` file:
+
+```text
+labels:
+ - "traefik.enable=true"
+ ## HTTP Routers
+ - "traefik.http.routers.overseerr-rtr.entrypoints=https"
+ - "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
+ - "traefik.http.routers.overseerr-rtr.tls=true"
+ ## HTTP Services
+ - "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
+ - "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
+```
+
+For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md
new file mode 100644
index 000000000..c7d57fd4f
--- /dev/null
+++ b/docs/extending-overseerr/third-party.md
@@ -0,0 +1,13 @@
+# Third-Party Integrations
+
+{% hint style="warning" %}
+We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself!
+{% endhint %}
+
+- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
+- [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher
+- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
+- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
+- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
+- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
+- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index b0efe68d2..205aa99f7 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -1,7 +1,7 @@
# Installation
{% hint style="danger" %}
-Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`** instead!
+**Overseerr is currently in BETA.** If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
{% endhint %}
{% hint style="info" %}
@@ -15,7 +15,8 @@ After running Overseerr for the first time, configure it by visiting the web UI
```bash
docker run -d \
- -e LOG_LEVEL=info \
+ --name overseerr \
+ -e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
@@ -25,12 +26,37 @@ docker run -d \
{% endtab %}
+{% tab title="Compose" %}
+
+**docker-compose.yml:**
+
+```yaml
+---
+version: '3'
+
+services:
+ overseerr:
+ image: sctx/overseerr:latest
+ container_name: overseerr
+ environment:
+ - LOG_LEVEL=debug
+ - TZ=Asia/Tokyo
+ ports:
+ - 5055:5055
+ volumes:
+ - /path/to/appdata/config:/app/config
+ restart: unless-stopped
+```
+
+{% endtab %}
+
{% tab title="UID/GID" %}
```text
docker run -d \
+ --name overseerr \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
- -e LOG_LEVEL=info \
+ -e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
@@ -42,7 +68,7 @@ docker run -d \
{% tab title="Manual Update" %}
-```text
+```bash
# Stop the Overseerr container
docker stop overseerr
@@ -66,31 +92,56 @@ Use a 3rd party updating mechanism such as [Watchtower](https://github.com/conta
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
-2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
+2. Inside the **Community Applications** app store, search for **Overseerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**\(Appdata\) as needed.
5. Click apply and access "Overseerr" at your `` in a web browser.
## Windows
-Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
+Please refer to the [Docker Desktop for Windows user manual](https://docs.docker.com/docker-for-windows/) for details on how to install Docker on Windows. There is no need to install a Linux distro if using named volumes like in the example below.
{% hint style="danger" %}
-**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed!**
+**WSL2 will need to be installed to prevent DB corruption!** Please see the [Docker Desktop WSL 2 backend documentation](https://docs.docker.com/docker-for-windows/wsl/) for instructions on how to enable WSL2. The commands below will only work with WSL2 installed!
{% endhint %}
+First, create a volume to store the configuration data for Overseerr using using either the Docker CLI:
+
```bash
-docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/here:/app/config" --restart unless-stopped sctx/overseerr
+docker volume create overseerr-data
```
+or the Docker Desktop app:
+
+1. Open the Docker Desktop app
+2. Head to the Volumes tab
+3. Click on the "New Volume" button near the top right
+4. Enter a name for the volume (example: `overseerr-data`) and hit "Create"
+
+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
+```
+
+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.
+
+To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
+
{% hint style="info" %}
-Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
+Docker on Windows works differently than it does on Linux; it runs Docker inside of a stripped-down Linux VM. Volume mounts are exposed to Docker inside this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database, which can lead to slow behavior and crashes.
+
+**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
+
+Named volumes, like in the example commands above, are automatically mounted inside the VM.
{% endhint %}
## Linux
{% hint style="info" %}
-The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd).
+The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supported Linux install method aside from [Docker](#docker).
+
+Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
{% endhint %}
**To install:**
@@ -116,7 +167,7 @@ sudo snap install overseerr --edge
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
-## Third Party
+## Third-Party
{% tabs %}
@@ -125,7 +176,7 @@ Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
-Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
+Efforts will be made to keep up-to-date with the latest releases; however, this cannot be guaranteed.
**To enable:**
To enable using `eselect repository`, run:
diff --git a/docs/support/asking-for-support.md b/docs/support/asking-for-support.md
deleted file mode 100644
index 2743827ed..000000000
--- a/docs/support/asking-for-support.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Asking for Support
-
-## Before Asking for Support
-
-Before seeking help, please make sure you have tried these following first:
-
-- **Update** to the latest version.
-- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
-- **Analyze** your logs, you just might find the solution yourself!
-- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
-- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
-
-## What should I include when asking for support?
-
-When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
-
-- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
- - What command did you enter?
- - What did you click on?
- - What settings did you change?
- - Provide a step-by-step list of what you tried.
-- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
- - Did something happen?
- - Did something not happen?
- - Are there any error messages showing?
- - Provide screenshots to help us see what you are seeing.
- - Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
-
-## How can I share my logs?
-
-1. Locate the log file at `/logs/overseerr.log`
-2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
-3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).
diff --git a/docs/support/faq.md b/docs/support/faq.md
index 0fd6938f2..2add67a41 100644
--- a/docs/support/faq.md
+++ b/docs/support/faq.md
@@ -1,46 +1,54 @@
# Frequently Asked Questions (FAQ)
{% hint style="info" %}
-If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
+If you can't find the solution to your problem here, please read [Need Help?](./need-help.md) and reach out to us on [Discord](https://discord.gg/overseerr).
+
+_Please do not post questions or support requests on the GitHub issue tracker!_
{% endhint %}
## General
-### I receive 409 or 400 errors when requesting a movie or TV series!
-
-**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions.
-
### How do I keep Overseerr up-to-date?
-**A:** Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
+Use a third-party update mechanism (such as [Watchtower](https://github.com/containrrr/watchtower), [Ouroboros](https://github.com/pyouroboros/ouroboros), or [Pullio](https://hotio.dev/pullio)) to keep Overseerr up-to-date automatically.
-### How can I access Overseerr outside my home network?
+### How can I access Overseerr outside of my home network?
-**A:** The easy and least secure method is to forward an external port \(`5055`\) on your router to the internal port used by Overseerr \(default is TCP `5055`\). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You will then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
+The easiest but least secure method is to simply forward an external port (e.g., `5055`) on your router to the internal port used by Overseerr (default is TCP `5055`). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You would then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
-The more advanced and most preferred method \(and more secure if you use SSL\) is to set up a web server with NGINX/Apache, and use a reverse proxy to access Overseerr. You can lookup many guides on the internet to find out how to do this. There are several reverse proxy config examples located [here](../extending-overseerr/reverse-proxy-examples.md).
+A more advanced, user-friendly, and secure (if using SSL) method is to set up a web server and use a reverse proxy to access Overseerr. Please refer to our [reverse proxy examples](../extending-overseerr/reverse-proxy.md) for more information.
-The most secure method, but also the most inconvenient, is to set up a VPN tunnel to your home server, then you can access Overseerr as if it is on a local network via `http://LOCAL-IP-ADDRESS:5055`.
+The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`.
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
-**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
+You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
-**A:** You can find the changelog in the **Settings → About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
+You can find the changelog for your version (stable/`latest`,s or `develop`) in the **Settings → About** page in your Overseerr instance.
-### Can I make 4K requests?
-
-**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings → Services**, tick the `4K Server` checkbox. You also need to tick the `Default Server` checkbox if it is the default server you would like to use for 4K content requests. (To enable 4K requests, there need to be default Sonarr/Radarr servers for both 4K content **and** non-4K content.)
+You can alternatively review the [stable release history](https://github.com/sct/overseerr/releases) and [`develop` branch commit history](https://github.com/sct/overseerr/commits/develop) on GitHub.
### Some media is missing from Overseerr that I know is in Plex!
-**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
+Overseerr currently supports the following agents:
-**Troubleshooting Steps:**
+- New Plex Movie
+- Legacy Plex Movie
+- New Plex TV
+- Legacy Plex TV
+- TheTVDB
+- TMDb
+- [HAMA](https://github.com/ZeroQI/Hama.bundle)
-First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
+Please verify that your library is using one of the agents previously listed.
+
+When changing agents, a full metadata refresh of your Plex library is required. (Caution: This can take a long time depending on the size of your library.)
+
+#### Troubleshooting Steps
+
+First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched.
1. Verify that you are using one of the agents mentioned above.
2. Refresh the metadata for just that item.
@@ -58,40 +66,56 @@ You can also perform the following to verify the media item has a GUID Overseerr
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
-### TV series requests are failing after I updated Overseerr!
+### Where can I find the log files?
-**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
+Please see [these instructions on how to locate and share your logs](./need-help.md#how-can-i-share-my-logs).
-### Where can I find the logs?
+## Users
-**A:** The logs are located at `/logs/overseerr.log`
+### Why can't I see all of my Plex users?
-## User management
-
-### Why can't I see all my Plex users?
-
-**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings → General Settings** page beforehand.
+Please see the [documentation for importing users from Plex](../using-overseerr/users/README.md#importing-users-from-plex).
### Can I create local users in Overseerr?
-**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
+Yes! Please see the [documentation for creating local users](../using-overseerr/users/README.md#creating-local-users).
### Is is possible to set user roles in Overseerr?
-**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
+Permissions can be configured for each user via the **User List** or their **User Settings** page. The list of assignable permissions is still growing, so if you have any suggestions, [submit a feature request](https://github.com/sct/overseerr/issues/new/choose)!
## Requests
+### I receive 409 or 400 errors when requesting a movie or TV series!
+
+Verify you are running v3 of both Radarr and Sonarr. Overseerr is not backwards-compatible with previous versions.
+
+### Can I allow users to submit 4K requests?
+
+Yes! If you keep both non-4K and 4K content in your media libraries, you can link separate 4K Radarr/Sonarr servers to allow users to submit 4K requests. (You must configure default non-4K **and** default 4K Radarr/Sonarr servers.)
+
+Please see the [Services documentation](../using-overseerr/settings/README.md#services) for details on how to configure your Radarr and/or Sonarr servers.
+
+Note that users must also have the **Request 4K**, **Request 4K Movies**, and/or **Request 4K Series** permissions in order to submit requests for 4K content.
+
### I approved a requested movie and Radarr didn't search for it!
-**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
+Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
### Help! My request still shows "requested" even though it is in Plex!
-**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
+See "[Some media is missing from Overseerr that I know is in Plex!](#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
+
+### Series requests keep failing!
+
+If you configured a URL base in Sonarr, make sure you have also configured the [URL Base](../using-overseerr/settings/README.md#url-base) setting for your Sonarr server in Overseerr.
+
+Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
+
+Language profile support for Sonarr was added in [v1.20.0](https://github.com/sct/overseerr/releases/tag/v1.20.0) along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
## Notifications
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
-**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
+If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
diff --git a/docs/support/need-help.md b/docs/support/need-help.md
new file mode 100644
index 000000000..8e2cc8567
--- /dev/null
+++ b/docs/support/need-help.md
@@ -0,0 +1,40 @@
+# Need Help?
+
+Before seeking assistance, please make sure you have first tried these following:
+
+- **Updating** Overseerr to the latest version.
+- **Stopping and restarting** Overseerr.
+- **Restarting** your machine.
+- **Clearing** your browser cache.
+- **Analyzing** your logs, you just might find the solution yourself!
+- **Searching** the [documentation](../README.md), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
+
+If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/overseerr)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
+
+Be sure to also include a link to your logs. (Please see [How can I share my logs?](#how-can-i-share-my-logs) below.)
+
+## What should I include when requesting support?
+
+Please try to include as much information as possible. A vague statement like "it doesn't work" provides very little to go on, and makes it difficult for us to help you.
+
+Try to answer the following questions:
+
+- What were you trying to do, and how did you attempt it?
+ - What command did you enter?
+ - What did you click on?
+ - What settings did you change?
+ - Did you follow official instructions, or a third-party guide?
+ - Provide a step-by-step list of what you tried.
+ - Provide a brief description of your setup.
+- What exactly do you see?
+ - Did something happen?
+ - Did something not happen?
+ - Are there any error messages showing?
+ - Provide screenshots to help us see what you are seeing.
+ - Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues (see [How can I share my logs?](#how-can-i-share-my-logs) below).
+
+## How can I share my logs?
+
+1. Locate the current log file at `/logs/overseerr.log`.
+2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
+3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/overseerr).
diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md
index 3f13c0a90..c894b0b2e 100644
--- a/docs/using-overseerr/notifications/README.md
+++ b/docs/using-overseerr/notifications/README.md
@@ -1,29 +1,25 @@
# Notifications
-Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it.
+## Supported Notification Agents
-## Currently Supported Notification Agents
+Overseerr currently supports the following notification agents:
-- Discord
-- Email
-- Pushbullet
-- Pushover
-- Slack
-- Telegram
+- [Email](./email.md)
+- [Web Push](./webpush.md)
+- [Discord](./discord.md)
+- [LunaSea](./lunasea.md)
+- [Pushbullet](./pushbullet.md)
+- [Pushover](./pushover.md)
+- [Slack](./slack.md)
+- [Telegram](./telegram.md)
- [Webhooks](./webhooks.md)
## Setting Up Notifications
-Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
+Simply configure your desired notification agents in **Settings → Notifications**.
-You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
-
-Some agents may have specific configuration "gotchas" covered in their documentation pages.
-
-{% hint style="danger" %}
-You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled.
-{% endhint %}
+Users can customize their personal notification preferences in their own user notification settings.
## Requesting New Notification Agents
-If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
+If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
diff --git a/docs/using-overseerr/notifications/discord.md b/docs/using-overseerr/notifications/discord.md
new file mode 100644
index 000000000..5112b5c08
--- /dev/null
+++ b/docs/using-overseerr/notifications/discord.md
@@ -0,0 +1,21 @@
+# Discord
+
+The Discord notification agent enables you to post notifications to a channel in a server you manage.
+
+{% hint style="info" %}
+Users can optionally opt-in to being mentioned in Discord notifications by configuring their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) in their user settings.
+{% endhint %}
+
+## Configuration
+
+### Webhook URL
+
+You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
+
+### Bot Username (optional)
+
+If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
+
+### Bot Avatar URL (optional)
+
+Similar to the bot username, you can override the avatar for your bot.
diff --git a/docs/using-overseerr/notifications/email.md b/docs/using-overseerr/notifications/email.md
new file mode 100644
index 000000000..89dfbab4d
--- /dev/null
+++ b/docs/using-overseerr/notifications/email.md
@@ -0,0 +1,45 @@
+# Email
+
+## Configuration
+
+{% hint style="info" %}
+If the [Application URL](../settings/README.md#application-url) setting is configured in **Settings → General**, Overseerr will explicitly set the origin server hostname when connecting to the SMTP host.
+{% endhint %}
+
+### Sender Name (optional)
+
+Configure a friendly name for the email sender (e.g., "Overseerr").
+
+### Sender Address
+
+Set this to the email address you would like to appear in the "from" field of the email message.
+
+Depending on your email provider, this may need to be an address you own. For example, Gmail requires this to be your actual email address.
+
+### SMTP Host
+
+Set this to the hostname or IP address of your SMTP host/server.
+
+### SMTP Port
+
+Set this to a supported port number for your SMTP host. `465` and `587` are commonly used.
+
+### Encryption Method
+
+In most cases, [Use Implicit TLS](https://tools.ietf.org/html/rfc8314) should be selected for port 465, and [Use STARTTLS if available](https://en.wikipedia.org/wiki/Opportunistic_TLS) for port 587. Please refer to your email provider's documentations for details on how to configure this setting.
+
+The default value for this setting is **Use STARTTLS if available**.
+
+### SMTP Username & Password
+
+{% hint style="info" %}
+If your account has two-factor authentication enabled, you may need to create an application password instead of using your account password.
+{% endhint %}
+
+Configure these values as appropriate to authenticate with your SMTP host.
+
+### PGP Private Key & Password (optional)
+
+Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their **PGP public keys** configured in their user settings in order for PGP encryption to be used in messages addressed to them.
+
+When configuring the PGP keys, be sure to keep the entire contents of the key intact. For example, private keys always begin with `-----BEGIN PGP PRIVATE KEY BLOCK-----` and end with `-----END PGP PRIVATE KEY BLOCK-----`.
diff --git a/docs/using-overseerr/notifications/lunasea.md b/docs/using-overseerr/notifications/lunasea.md
new file mode 100644
index 000000000..5271a2c1e
--- /dev/null
+++ b/docs/using-overseerr/notifications/lunasea.md
@@ -0,0 +1,17 @@
+# LunaSea
+
+## Configuration
+
+### Webhook URL
+
+Copy either a device- or user-based webhook URL from the LunaSea app into this field.
+
+### Profile Name (optional)
+
+If not using the `default` profile in the LunaSea app, specify the name of the profile here.
+
+Note that the entered profile name **_must_** match the name in LunaSea exactly (including any capitalization, punctuation, and/or whitespace).
+
+{% hint style="info" %}
+Please refer to the [LunaSea documentation](https://docs.lunasea.app/lunasea/notifications/overseerr) for more details on configuring these notifications.
+{% endhint %}
diff --git a/docs/using-overseerr/notifications/pushbullet.md b/docs/using-overseerr/notifications/pushbullet.md
new file mode 100644
index 000000000..45edcc3a0
--- /dev/null
+++ b/docs/using-overseerr/notifications/pushbullet.md
@@ -0,0 +1,7 @@
+# Pushbullet
+
+## Configuration
+
+### Access Token
+
+[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API.
diff --git a/docs/using-overseerr/notifications/pushover.md b/docs/using-overseerr/notifications/pushover.md
new file mode 100644
index 000000000..55893dbad
--- /dev/null
+++ b/docs/using-overseerr/notifications/pushover.md
@@ -0,0 +1,15 @@
+# Pushover
+
+## Configuration
+
+### Application/API Token
+
+[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/sct/overseerr/tree/develop/public) when configuring the application.)
+
+For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
+
+### User Key
+
+Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
+
+For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).
diff --git a/docs/using-overseerr/notifications/slack.md b/docs/using-overseerr/notifications/slack.md
new file mode 100644
index 000000000..0d5a9892d
--- /dev/null
+++ b/docs/using-overseerr/notifications/slack.md
@@ -0,0 +1,11 @@
+# Slack
+
+## Configuration
+
+### Webhook URL
+
+Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
+
+{% hint style="info" %}
+Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
+{% endhint %}
diff --git a/docs/using-overseerr/notifications/telegram.md b/docs/using-overseerr/notifications/telegram.md
new file mode 100644
index 000000000..d0e6f6fcb
--- /dev/null
+++ b/docs/using-overseerr/notifications/telegram.md
@@ -0,0 +1,31 @@
+# Telegram
+
+{% hint style="info" %}
+Users can optionally configure their own notifications in their user settings.
+{% endhint %}
+
+## Configuration
+
+{% hint style="info" %}
+In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
+
+Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
+{% endhint %}
+
+### Bot Username (optional)
+
+If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
+
+The bot username should end with `_bot`, and the `@` prefix should be omitted.
+
+### Bot Authentication Token
+
+At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
+
+### Chat ID
+
+To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
+
+### Send Silently (optional)
+
+Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.
diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md
index 7adbc24a2..c16374808 100644
--- a/docs/using-overseerr/notifications/webhooks.md
+++ b/docs/using-overseerr/notifications/webhooks.md
@@ -1,22 +1,24 @@
-# Webhooks
+# Webhook
-Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes.
+The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
## Configuration
-The following configuration options are available:
-
-### Webhook URL (required)
+### Webhook URL
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
-### Authorization Header
+### Authorization Header (optional)
-Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
+{% hint style="info" %}
+This is typically not needed. Please refer to your webhook provider's documentation for details.
+{% endhint %}
-### JSON Payload (required)
+This value will be sent as an `Authorization` HTTP header.
-Customize the JSON payload to suit your needs. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
+### JSON Payload
+
+Customize the JSON payload to suit your needs. Overseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
## Template Variables
@@ -29,23 +31,29 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
### User
-These variables are usually the target user of the notification.
+These variables are for the target recipient of the notification.
- `{{notifyuser_username}}` Target user's username.
-- `{{notifyuser_email}}` Target user's email.
-- `{{notifyuser_avatar}}` Target user's avatar.
-- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
+- `{{notifyuser_email}}` Target user's email address.
+- `{{notifyuser_avatar}}` Target user's avatar URL.
+- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
+- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
-### Media
+{% hint style="info" %}
+The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
-These variables are only included in media related notifications, such as requests.
+- Media Requested
+- Media Automatically Approved
+- Media Failed
-- `{{media_type}}` Media type. Either `movie` or `tv`.
-- `{{media_tmdbid}}` Media's TMDb ID.
-- `{{media_imdbid}}` Media's IMDb ID.
-- `{{media_tvdbid}}` Media's TVDB ID.
-- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
-- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
+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
+
+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 %}
### Special
@@ -54,3 +62,25 @@ The following variables must be used as a key in the JSON payload (e.g., `"{{ext
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
+
+#### Media
+
+These `{{media}}` special variables are only included in media-related notifications, such as requests.
+
+- `{{media_type}}` Media type (`movie` or `tv`).
+- `{{media_tmdbid}}` Media's TMDb ID.
+- `{{media_imdbid}}` Media's IMDb ID.
+- `{{media_tvdbid}}` Media's TVDB ID.
+- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
+- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
+
+#### Request
+
+The `{{request}}` special variables are only included in request-related notifications.
+
+- `{{request_id}}` Request ID.
+- `{{requestedBy_username}}` Requesting user's username.
+- `{{requestedBy_email}}` Requesting user's email address.
+- `{{requestedBy_avatar}}` Requesting user's avatar URL.
+- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
+- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
diff --git a/docs/using-overseerr/notifications/webpush.md b/docs/using-overseerr/notifications/webpush.md
new file mode 100644
index 000000000..65d914f06
--- /dev/null
+++ b/docs/using-overseerr/notifications/webpush.md
@@ -0,0 +1,17 @@
+# Web Push
+
+The web push notification agent enables you and your users to receive Overseerr notifications in a supported browser.
+
+This notification agent does not require any configuration, but is not enabled in Overseerr by default.
+
+{% hint style="warning" %}
+**The web push agent only works via HTTPS.** Refer to our [reverse proxy examples](../../extending-overseerr/reverse-proxy.md) for help on proxying Overseerr traffic via HTTPS.
+{% endhint %}
+
+To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**. You and your users will then be prompted to allow notifications in your web browser.
+
+Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings.
+
+{% hint style="info" %}
+Web push notifications offer a native notification experience without the need to install an app. iOS devices do not have support for these notifications at this time, however.
+{% endhint %}
diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md
new file mode 100644
index 000000000..820430736
--- /dev/null
+++ b/docs/using-overseerr/settings/README.md
@@ -0,0 +1,213 @@
+# Settings
+
+## General
+
+### API Key
+
+This is your Overseerr API key, which can be used to integrate Overseerr with third-party applications. Do **not** share this key publicly, as it can be used to gain administrator access!
+
+If you need to generate a new API key for any reason, simply click the button to the right of the text box.
+
+### Application Title
+
+If you aren't a huge fan of the name "Overseerr" and would like to display something different to your users, you can customize the application title!
+
+### Application URL
+
+Set this to the externally-accessible URL of your Overseerr instance.
+
+You must configure this setting in order to enable password reset and [generation](../users/README.md#automatically-generate-password) emails.
+
+### Enable Proxy Support
+
+If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html).
+
+This setting is **disabled** by default.
+
+### Enable CSRF Protection
+
+{% hint style="danger" %}
+**This is an advanced setting.** We do not recommend enabling it unless you understand the implications of doing so.
+{% endhint %}
+
+CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Overseerr application data is blocked.
+
+If you do not use Overseerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
+
+One caveat, however, is that _HTTPS is required_, meaning that once this setting is enabled, you will no longer be able to access your Overseerr instance over HTTP (including using an IP address and port number).
+
+If you enable this setting and find yourself unable to access Overseerr, you can disable the setting by modifying `settings.json` in `/app/config`.
+
+This setting is **disabled** by default.
+
+### Display Language
+
+Set the default display language for Overseerr. Users can override this setting in their user settings.
+
+### Discover Region & Discover Language
+
+These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
+
+### Hide Available Media
+
+When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
+
+Available media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title.
+
+This setting is **disabled** by default.
+
+### Allow Partial Series Requests
+
+When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
+
+This setting is **enabled** by default.
+
+## Users
+
+### Enable Local Sign-In
+
+When enabled, users who have configured passwords will be allowed to sign in using their email address.
+
+When disabled, Plex OAuth becomes the only sign-in option, and any "local users" you have created will not be able to sign in to Overseerr.
+
+This setting is **enabled** by default.
+
+### Enable New Plex Sign-In
+
+When enabled, users with access to your Plex server will be able to sign in to Overseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
+
+This setting is **enabled** by default.
+
+### Global Movie Request Limit & Global Series Request Limit
+
+Select the request limits you would like granted to users.
+
+Unless an [override](../users/README.md#movie-request-limit-and-series-request-limit) is configured, users are granted these global request limits.
+
+Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users.
+
+### Default Permissions
+
+Select the permissions you would like assigned to new users to have by default upon account creation.
+
+If [Enable New Plex Sign-In](#enable-new-plex-sign-in) is enabled, any user with access to your Plex server will be able to sign in to Overseerr, and they will be granted the permissions you select here upon first sign-in.
+
+This setting only affects new users, and has no impact on existing users. In order to modify permissions for existing users, you will need to [edit the users](../users/README.md#editing-users).
+
+## Plex
+
+### Plex Settings
+
+{% hint style="info" %}
+To set up Plex, you can either enter your details manually or select a server retrieved from [plex.tv](https://plex.tv/). Press the button to the right of the "Server" dropdown to retrieve available servers.
+
+Depending on your setup/configuration, you may need to enter your Plex server details manually in order to establish a connection from Overseerr.
+{% endhint %}
+
+#### Hostname or IP Address
+
+If you have Overseerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`).
+
+#### Port
+
+This value should be set to the port that your Plex server listens on. The default port that Plex uses is `32400`, but you may need to set this to `443` or some other value if your Plex server is hosted on a VPS or cloud provider.
+
+#### Use SSL
+
+Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
+
+#### Web App URL (optional)
+
+The **Play on Plex** buttons on media pages link to items on your Plex server. By default, these links use the [Plex Web App](https://support.plex.tv/articles/200288666-opening-plex-web-app/) hosted from plex.tv, but you can provide the URL to the web app on your Plex server and we'll use that instead!
+
+Note that you will need to enter the full path to the web app (e.g., `https://plex.myawesomeserver.com/web`).
+
+### Plex Libraries
+
+In this section, simply select the libraries you would like Overseerr to scan. Overseerr will periodically check the selected libraries for available content to update the media status that is displayed to users.
+
+If you do not see your Plex libraries listed, verify your Plex settings are correct and click the **Sync Libraries** button.
+
+### Manual Library Scan
+
+Overseerr will perform a full scan of your Plex libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Plex, a one-time full manual library scan is recommended!
+
+## Services
+
+{% hint style="info" %}
+**If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Overseerr.**
+
+Overseerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media_.
+
+**If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.**
+{% endhint %}
+
+### Radarr/Sonarr Settings
+
+{% hint style="warning" %}
+**Only v3 Radarr/Sonarr servers are supported!** If your Radarr/Sonarr server is still running v2, you will need to upgrade in order to add it to Overseerr.
+{% endhint %}
+
+#### Default Server
+
+At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr.
+
+If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K servers _in addition to_ default non-4K servers.
+
+#### 4K Server
+
+Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do _not_ check this box!
+
+#### Server Name
+
+Enter a friendly name for the Radarr/Sonarr server.
+
+#### Hostname or IP Address
+
+If you have Overseerr installed on the same network as Radarr/Sonarr, you can set this to the local IP address of your Radarr/Sonarr server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`).
+
+#### Port
+
+This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider.
+
+#### Use SSL
+
+Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
+
+#### API Key
+
+Enter your Radarr/Sonarr API key here. Do _not_ share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers!
+
+You can locate the required API keys in Radarr/Sonarr in **Settings → General → Security**.
+
+#### URL Base
+
+If you have configured a URL base for your Radarr/Sonarr server, you _must_ enter it here in order for Overseerr to connect to those services!
+
+You can verify whether or not you have a URL base configured in your Radarr/Sonarr server at **Settings → General → Host**. (Note that a restart of your Radarr/Sonarr server is required if you modify this setting!)
+
+#### Profiles, Root Folder, Minimum Availability
+
+Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured!
+
+#### External URL (optional)
+
+If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages.
+
+#### Enable Scan (optional)
+
+Enable this setting if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
+
+#### Enable Automatic Search (optional)
+
+Enable this setting to have Radarr/Sonarr to automatically search for media upon approval of a request.
+
+## Notifications
+
+Please see [Notifications](../notifications/README.md) for details on configuring and enabling notifications.
+
+## Jobs & Cache
+
+Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered on this page.
+
+Overseerr also caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls. If necessary, the cache for any particular endpoint can be cleared by clicking the "Flush Cache" button.
diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md
new file mode 100644
index 000000000..275e469c0
--- /dev/null
+++ b/docs/using-overseerr/users/README.md
@@ -0,0 +1,79 @@
+# Users
+
+## Owner Account
+
+The user account created during Overseerr setup is the "Owner" account, which cannot be deleted or modified by other users. This account's credentials are used to authenticate with Plex.
+
+## Adding Users
+
+There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**.
+
+### Importing Users from Plex
+
+Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
+
+Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login.
+
+### Creating Local Users
+
+If you would like to grant Overseerr access to a user who doesn't have their own Plex account and/or access to the Plex server, you can manually add them by clicking the **Create Local User** button.
+
+#### Email Address
+
+Enter a valid email address at which the user can receive messages pertaining to their account and other notifications. The email address currently cannot be modified after the account is created.
+
+#### Automatically Generate Password
+
+If an [application URL](../settings/README.md#application-url) is set and [email notifications](../notifications/email.md) have been configured and enabled, Overseerr can automatically generate a password for the new user.
+
+#### Password
+
+If you would prefer to manually configure a password, enter a password here that is a minimum of 8 characters.
+
+## Editing Users
+
+From the **User List**, you can click the **Edit** button to modify a particular user's settings.
+
+You can also click the check boxes and click the **Bulk Edit** button to set user permissions for multiple users at once.
+
+### General
+
+#### Display Name
+
+You can optionally set a "friendly name" for any user. This name will be used in lieu of their Plex username (for users imported from Plex) or their email address (for manually-created local users).
+
+#### Display Language
+
+Users can override the [global display language](../settings/README.md#display-language) to use Overseerr in their preferred language.
+
+#### Discover Region & Discover Language
+
+Users can override the [global filter settings](../settings/README.md#discover-region-and-discover-language) to suit their own preferences.
+
+#### Movie Request Limit & Series Request Limit
+
+You can override the default settings and assign different request limits for specific users by checking the **Enable Override** box and selecting the desired request limit and time period.
+
+Unless an override is configured, users are granted the global request limits.
+
+Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users.
+
+Users are also unable to modify their own request limits.
+
+### Password
+
+All "local users" are assigned passwords upon creation, but users imported from Plex can also optionally configure passwords to enable sign-in using their email address.
+
+Passwords must be a minimum of 8 characters long.
+
+### Notifications
+
+Users can configure their personal notification settings here. Please see [Notifications](../notifications/README.md) for details on configuring and enabling notifications.
+
+### Permissions
+
+Users cannot modify their own permissions. Users with the **Manage Users** permission can manage permissions of other users, except those of users with the **Admin** permission.
+
+## Deleting Users
+
+When users are deleted, all of their data and request history is also cleared from the database.
diff --git a/next-env.d.ts b/next-env.d.ts
index 7b7aa2c77..9bc3dd46b 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,2 +1,6 @@
///
///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/next.config.js b/next.config.js
index 8c1766af8..f0a623d4e 100644
--- a/next.config.js
+++ b/next.config.js
@@ -2,12 +2,13 @@ module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
},
+ images: {
+ domains: ['image.tmdb.org'],
+ },
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
- issuer: {
- test: /\.(js|ts)x?$/,
- },
+ issuer: /\.(js|ts)x?$/,
use: ['@svgr/webpack'],
});
diff --git a/ormconfig.js b/ormconfig.js
index 070e0598c..4122f079e 100644
--- a/ormconfig.js
+++ b/ormconfig.js
@@ -6,6 +6,7 @@ const devConfig = {
synchronize: true,
migrationsRun: false,
logging: false,
+ enableWAL: true,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
@@ -22,6 +23,7 @@ const prodConfig = {
: 'config/db/db.sqlite3',
synchronize: false,
logging: false,
+ enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrationsRun: false,
diff --git a/overseerr-api.yml b/overseerr-api.yml
index 8379d122d..afb9922ec 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -52,6 +52,12 @@ components:
email:
type: string
example: 'hey@itsme.com'
+ readOnly: true
+ username:
+ type: string
+ plexUsername:
+ type: string
+ readOnly: true
plexToken:
type: string
readOnly: true
@@ -80,13 +86,6 @@ components:
type: number
example: 5
readOnly: true
- requests:
- type: array
- readOnly: true
- items:
- $ref: '#/components/schemas/MediaRequest'
- settings:
- $ref: '#/components/schemas/UserSettings'
required:
- id
- email
@@ -95,20 +94,21 @@ components:
UserSettings:
type: object
properties:
- enableNotifications:
- type: boolean
- default: true
- discordId:
+ locale:
+ type: string
+ region:
+ type: string
+ originalLanguage:
type: string
- required:
- - enableNotifications
MainSettings:
type: object
properties:
apiKey:
type: string
- example: 'anapikey'
readOnly: true
+ appLanguage:
+ type: string
+ example: en
applicationTitle:
type: string
example: Overseerr
@@ -124,12 +124,18 @@ components:
hideAvailable:
type: boolean
example: false
+ partialRequestsEnabled:
+ type: boolean
+ example: false
localLogin:
type: boolean
example: true
mediaServerType:
type: number
example: 1
+ newPlexLogin:
+ type: boolean
+ example: true
defaultPermissions:
type: number
example: 32
@@ -170,6 +176,9 @@ components:
readOnly: true
items:
$ref: '#/components/schemas/PlexLibrary'
+ webAppUrl:
+ type: string
+ example: 'https://app.plex.tv/desktop'
required:
- name
- machineId
@@ -199,9 +208,6 @@ components:
message:
type: string
example: 'OK'
- host:
- type: string
- example: '127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct'
required:
- protocol
- address
@@ -438,7 +444,6 @@ components:
activeLanguageProfileId:
type: number
example: 1
- nullable: true
activeAnimeProfileId:
type: number
nullable: true
@@ -448,6 +453,7 @@ components:
activeAnimeProfileName:
type: string
example: 720p/1080p
+ nullable: true
activeAnimeDirectory:
type: string
nullable: true
@@ -481,6 +487,15 @@ components:
- is4k
- enableSeasonFolders
- isDefault
+ ServarrTag:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ label:
+ type: string
+ example: A Label
PublicSettings:
type: object
properties:
@@ -621,6 +636,19 @@ components:
type: string
name:
type: string
+ Network:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ logoPath:
+ type: string
+ nullable: true
+ originCountry:
+ type: string
+ name:
+ type: string
RelatedVideo:
type: object
properties:
@@ -787,6 +815,10 @@ components:
$ref: '#/components/schemas/ExternalIds'
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
+ watchProviders:
+ type: array
+ items:
+ $ref: '#/components/schemas/WatchProviders'
Episode:
type: object
properties:
@@ -934,6 +966,8 @@ components:
$ref: '#/components/schemas/Season'
status:
type: string
+ tagline:
+ type: string
type:
type: string
voteAverage:
@@ -959,6 +993,10 @@ components:
$ref: '#/components/schemas/Keyword'
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
+ watchProviders:
+ type: array
+ items:
+ $ref: '#/components/schemas/WatchProviders'
MediaRequest:
type: object
properties:
@@ -969,7 +1007,7 @@ components:
status:
type: number
example: 0
- description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED, 4 = AVAILABLE
+ description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED
readOnly: true
media:
$ref: '#/components/schemas/MediaInfo'
@@ -1132,6 +1170,10 @@ components:
options:
type: object
properties:
+ botUsername:
+ type: string
+ botAvatarUrl:
+ type: string
webhookUrl:
type: string
SlackSettings:
@@ -1148,6 +1190,15 @@ components:
properties:
webhookUrl:
type: string
+ WebPushSettings:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ example: false
+ types:
+ type: number
+ example: 2
WebhookSettings:
type: object
properties:
@@ -1162,6 +1213,8 @@ components:
properties:
webhookUrl:
type: string
+ authHeader:
+ type: string
jsonPayload:
type: string
TelegramSettings:
@@ -1176,10 +1229,14 @@ components:
options:
type: object
properties:
+ botUsername:
+ type: string
botAPI:
type: string
chatId:
type: string
+ sendSilently:
+ type: boolean
PushbulletSettings:
type: object
properties:
@@ -1210,17 +1267,22 @@ components:
type: string
userToken:
type: string
- priority:
- type: number
- NotificationSettings:
+ LunaSeaSettings:
type: object
properties:
enabled:
- type: boolean
- example: true
- autoapprovalEnabled:
type: boolean
example: false
+ types:
+ type: number
+ example: 2
+ options:
+ type: object
+ properties:
+ webhookUrl:
+ type: string
+ profileName:
+ type: string
NotificationEmailSettings:
type: object
properties:
@@ -1248,6 +1310,12 @@ components:
secure:
type: boolean
example: false
+ ignoreTls:
+ type: boolean
+ example: false
+ requireTls:
+ type: boolean
+ example: false
authUser:
type: string
nullable: true
@@ -1573,14 +1641,78 @@ components:
UserSettingsNotifications:
type: object
properties:
- enableNotifications:
+ notificationTypes:
+ $ref: '#/components/schemas/NotificationAgentTypes'
+ emailEnabled:
type: boolean
- default: true
+ pgpKey:
+ type: string
+ nullable: true
+ discordEnabled:
+ type: boolean
+ discordEnabledTypes:
+ type: number
+ nullable: true
discordId:
type: string
nullable: true
- required:
- - enableNotifications
+ telegramEnabled:
+ type: boolean
+ telegramBotUsername:
+ type: string
+ nullable: true
+ telegramChatId:
+ type: string
+ nullable: true
+ telegramSendSilently:
+ type: boolean
+ nullable: true
+ NotificationAgentTypes:
+ type: object
+ properties:
+ discord:
+ type: number
+ email:
+ type: number
+ pushbullet:
+ type: number
+ pushover:
+ type: number
+ slack:
+ type: number
+ telegram:
+ type: number
+ webhook:
+ type: number
+ webpush:
+ type: number
+ WatchProviders:
+ type: array
+ items:
+ type: object
+ properties:
+ iso_3166_1:
+ type: string
+ link:
+ type: string
+ buy:
+ type: array
+ items:
+ $ref: '#/components/schemas/WatchProviderDetails'
+ flatrate:
+ items:
+ $ref: '#/components/schemas/WatchProviderDetails'
+ WatchProviderDetails:
+ type: object
+ properties:
+ displayPriority:
+ type: number
+ logoPath:
+ type: string
+ id:
+ type: number
+ name:
+ type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -1870,13 +2002,13 @@ paths:
$ref: '#/components/schemas/PlexLibrary'
/settings/plex/sync:
get:
- summary: Get status of full Plex library sync
- description: Returns sync progress in a JSON array.
+ summary: Get status of full Plex library scan
+ description: Returns scan progress in a JSON array.
tags:
- settings
responses:
'200':
- description: Status of Plex sync
+ description: Status of Plex scan
content:
application/json:
schema:
@@ -1898,8 +2030,8 @@ paths:
items:
$ref: '#/components/schemas/PlexLibrary'
post:
- summary: Start full Plex library sync
- description: Runs a full Plex library sync and returns the progress in a JSON array.
+ summary: Start full Plex library scan
+ description: Runs a full Plex library scan and returns the progress in a JSON array.
tags:
- settings
requestBody:
@@ -1916,7 +2048,7 @@ paths:
example: false
responses:
'200':
- description: Status of Plex sync
+ description: Status of Plex scan
content:
application/json:
schema:
@@ -1939,8 +2071,8 @@ paths:
$ref: '#/components/schemas/PlexLibrary'
/settings/plex/devices/servers:
get:
- summary: Gets the user's available plex servers
- description: Returns a list of available plex servers and their connectivity state
+ summary: Gets the user's available Plex servers
+ description: Returns a list of available Plex servers and their connectivity state
tags:
- settings
responses:
@@ -2396,37 +2528,54 @@ paths:
responses:
'204':
description: 'Flushed cache'
- /settings/notifications:
+ /settings/logs:
get:
- summary: Return notification settings
- description: Returns current notification settings in a JSON object.
+ summary: Returns logs
+ description: Returns list of all log items and details
tags:
- settings
+ parameters:
+ - in: query
+ name: take
+ schema:
+ type: number
+ nullable: true
+ example: 25
+ - in: query
+ name: skip
+ schema:
+ type: number
+ nullable: true
+ example: 0
+ - in: query
+ name: filter
+ schema:
+ type: string
+ nullable: true
+ enum: [debug, info, warn, error]
+ default: debug
responses:
'200':
- description: Returned settings
+ description: Server log returned
content:
application/json:
schema:
- $ref: '#/components/schemas/NotificationSettings'
- post:
- summary: Update notification settings
- description: Updates notification settings with the provided values.
- tags:
- - settings
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/NotificationSettings'
- responses:
- '200':
- description: 'Values were sucessfully updated'
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/NotificationSettings'
+ type: array
+ items:
+ type: object
+ properties:
+ label:
+ type: string
+ example: server
+ level:
+ type: string
+ example: info
+ message:
+ type: string
+ example: Server ready on port 5055
+ timestamp:
+ type: string
+ example: 2020-12-15T16:20:00.069Z
/settings/notifications/email:
get:
summary: Get email notification settings
@@ -2519,22 +2668,22 @@ paths:
responses:
'204':
description: Test notification attempted
- /settings/notifications/telegram:
+ /settings/notifications/lunasea:
get:
- summary: Get Telegram notification settings
- description: Returns current Telegram notification settings in a JSON object.
+ summary: Get LunaSea notification settings
+ description: Returns current LunaSea notification settings in a JSON object.
tags:
- settings
responses:
'200':
- description: Returned Telegram settings
+ description: Returned LunaSea settings
content:
application/json:
schema:
- $ref: '#/components/schemas/TelegramSettings'
+ $ref: '#/components/schemas/LunaSeaSettings'
post:
- summary: Update Telegram notification settings
- description: Update Telegram notification settings with the provided values.
+ summary: Update LunaSea notification settings
+ description: Updates LunaSea notification settings with the provided values.
tags:
- settings
requestBody:
@@ -2542,18 +2691,18 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/TelegramSettings'
+ $ref: '#/components/schemas/LunaSeaSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
- $ref: '#/components/schemas/TelegramSettings'
- /settings/notifications/telegram/test:
+ $ref: '#/components/schemas/LunaSeaSettings'
+ /settings/notifications/lunasea/test:
post:
- summary: Test Telegram settings
- description: Sends a test notification to the Telegram agent.
+ summary: Test LunaSea settings
+ description: Sends a test notification to the LunaSea agent.
tags:
- settings
requestBody:
@@ -2561,7 +2710,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/TelegramSettings'
+ $ref: '#/components/schemas/LunaSeaSettings'
responses:
'204':
description: Test notification attempted
@@ -2598,8 +2747,8 @@ paths:
$ref: '#/components/schemas/PushbulletSettings'
/settings/notifications/pushbullet/test:
post:
- summary: Test Pushover settings
- description: Sends a test notification to the Pushover agent.
+ summary: Test Pushbullet settings
+ description: Sends a test notification to the Pushbullet agent.
tags:
- settings
requestBody:
@@ -2607,7 +2756,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/PushoverSettings'
+ $ref: '#/components/schemas/PushbulletSettings'
responses:
'204':
description: Test notification attempted
@@ -2703,6 +2852,98 @@ paths:
responses:
'204':
description: Test notification attempted
+ /settings/notifications/telegram:
+ get:
+ summary: Get Telegram notification settings
+ description: Returns current Telegram notification settings in a JSON object.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: Returned Telegram settings
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TelegramSettings'
+ post:
+ summary: Update Telegram notification settings
+ description: Update Telegram notification settings with the provided values.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TelegramSettings'
+ responses:
+ '200':
+ description: 'Values were sucessfully updated'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TelegramSettings'
+ /settings/notifications/telegram/test:
+ post:
+ summary: Test Telegram settings
+ description: Sends a test notification to the Telegram agent.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TelegramSettings'
+ responses:
+ '204':
+ description: Test notification attempted
+ /settings/notifications/webpush:
+ get:
+ summary: Get Web Push notification settings
+ description: Returns current Web Push notification settings in a JSON object.
+ tags:
+ - settings
+ responses:
+ '200':
+ description: Returned web push settings
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebPushSettings'
+ post:
+ summary: Update Web Push notification settings
+ description: Updates Web Push notification settings with the provided values.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebPushSettings'
+ responses:
+ '200':
+ description: 'Values were sucessfully updated'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebPushSettings'
+ /settings/notifications/webpush/test:
+ post:
+ summary: Test Web Push settings
+ description: Sends a test notification to the Web Push agent.
+ tags:
+ - settings
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebPushSettings'
+ responses:
+ '204':
+ description: Test notification attempted
/settings/notifications/webhook:
get:
summary: Get webhook notification settings
@@ -2745,7 +2986,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/SlackSettings'
+ $ref: '#/components/schemas/WebhookSettings'
responses:
'204':
description: Test notification attempted
@@ -3000,7 +3241,15 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/User'
+ type: object
+ properties:
+ email:
+ type: string
+ example: 'hey@itsme.com'
+ username:
+ type: string
+ permissions:
+ type: number
responses:
'201':
description: The created user
@@ -3011,7 +3260,7 @@ paths:
put:
summary: Update batch of users
description: |
- Update users with given IDs with provided values in request `body.settings`. You cannot update users' plex tokens through this request.
+ Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request.
Requires the `MANAGE_USERS` permission.
tags:
@@ -3038,7 +3287,6 @@ paths:
type: array
items:
$ref: '#/components/schemas/User'
-
/user/import-from-plex:
post:
summary: Import all users from Plex
@@ -3057,11 +3305,37 @@ paths:
type: array
items:
$ref: '#/components/schemas/User'
+ /user/registerPushSubscription:
+ post:
+ summary: Register a web push /user/registerPushSubscription
+ description: Registers a web push subscription for the logged-in user
+ tags:
+ - users
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ endpoint:
+ type: string
+ auth:
+ type: string
+ p256dh:
+ type: string
+ required:
+ - endpoint
+ - auth
+ - p256dh
+ responses:
+ '204':
+ description: Successfully registered push subscription
/user/{userId}:
get:
summary: Get user by ID
description: |
- Retrieves user details in a JSON object.. Requires the `MANAGE_USERS` permission.
+ Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission.
tags:
- users
parameters:
@@ -3161,6 +3435,63 @@ paths:
type: array
items:
$ref: '#/components/schemas/MediaRequest'
+ /user/{userId}/quota:
+ get:
+ summary: Get quotas for a specific user
+ description: |
+ Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users.
+ tags:
+ - users
+ parameters:
+ - in: path
+ name: userId
+ required: true
+ schema:
+ type: number
+ responses:
+ '200':
+ description: User quota details in JSON
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ movie:
+ type: object
+ properties:
+ days:
+ type: number
+ example: 7
+ limit:
+ type: number
+ example: 10
+ used:
+ type: number
+ example: 6
+ remaining:
+ type: number
+ example: 4
+ restricted:
+ type: boolean
+ example: false
+ tv:
+ type: object
+ properties:
+ days:
+ type: number
+ example: 7
+ limit:
+ type: number
+ example: 10
+ used:
+ type: number
+ example: 6
+ remaining:
+ type: number
+ example: 4
+ restricted:
+ type: boolean
+ example: false
/user/{userId}/settings/main:
get:
summary: Get general settings for a user
@@ -3432,6 +3763,16 @@ paths:
schema:
type: string
example: en
+ - in: query
+ name: genre
+ schema:
+ type: number
+ example: 18
+ - in: query
+ name: studio
+ schema:
+ type: number
+ example: 1
responses:
'200':
description: Results
@@ -3453,6 +3794,147 @@ paths:
type: array
items:
$ref: '#/components/schemas/MovieResult'
+ /discover/movies/genre/{genreId}:
+ get:
+ summary: Discover movies by genre
+ description: Returns a list of movies based on the provided genre ID in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: genreId
+ required: true
+ schema:
+ type: string
+ example: '1'
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ genre:
+ $ref: '#/components/schemas/Genre'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/MovieResult'
+ /discover/movies/language/{language}:
+ get:
+ summary: Discover movies by original language
+ description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: language
+ required: true
+ schema:
+ type: string
+ example: en
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ language:
+ $ref: '#/components/schemas/SpokenLanguage'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/MovieResult'
+ /discover/movies/studio/{studioId}:
+ get:
+ summary: Discover movies by studio
+ description: Returns a list of movies based on the provided studio ID in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: studioId
+ required: true
+ schema:
+ type: string
+ example: '1'
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ studio:
+ $ref: '#/components/schemas/ProductionCompany'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/MovieResult'
/discover/movies/upcoming:
get:
summary: Upcoming movies
@@ -3510,6 +3992,16 @@ paths:
schema:
type: string
example: en
+ - in: query
+ name: genre
+ schema:
+ type: number
+ example: 18
+ - in: query
+ name: network
+ schema:
+ type: number
+ example: 1
responses:
'200':
description: Results
@@ -3531,6 +4023,147 @@ paths:
type: array
items:
$ref: '#/components/schemas/TvResult'
+ /discover/tv/language/{language}:
+ get:
+ summary: Discover TV shows by original language
+ description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: language
+ required: true
+ schema:
+ type: string
+ example: en
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ language:
+ $ref: '#/components/schemas/SpokenLanguage'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/TvResult'
+ /discover/tv/genre/{genreId}:
+ get:
+ summary: Discover TV shows by genre
+ description: Returns a list of TV shows based on the provided genre ID in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: genreId
+ required: true
+ schema:
+ type: string
+ example: '1'
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ genre:
+ $ref: '#/components/schemas/Genre'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/TvResult'
+ /discover/tv/network/{networkId}:
+ get:
+ summary: Discover TV shows by network
+ description: Returns a list of TV shows based on the provided network ID in a JSON object.
+ tags:
+ - search
+ parameters:
+ - in: path
+ name: networkId
+ required: true
+ schema:
+ type: string
+ example: '1'
+ - in: query
+ name: page
+ schema:
+ type: number
+ example: 1
+ default: 1
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ page:
+ type: number
+ example: 1
+ totalPages:
+ type: number
+ example: 20
+ totalResults:
+ type: number
+ example: 200
+ network:
+ $ref: '#/components/schemas/Network'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/TvResult'
/discover/tv/upcoming:
get:
summary: Discover Upcoming TV shows
@@ -3657,11 +4290,77 @@ paths:
type: array
items:
$ref: '#/components/schemas/MovieResult'
+ /discover/genreslider/movie:
+ get:
+ summary: Get genre slider data for movies
+ description: Returns a list of genres with backdrops attached
+ tags:
+ - search
+ parameters:
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Genre slider data returned
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ backdrops:
+ type: array
+ items:
+ type: string
+ name:
+ type: string
+ example: Genre Name
+ /discover/genreslider/tv:
+ get:
+ summary: Get genre slider data for TV series
+ description: Returns a list of genres with backdrops attached
+ tags:
+ - search
+ parameters:
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Genre slider data returned
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 1
+ backdrops:
+ type: array
+ items:
+ type: string
+ name:
+ type: string
+ example: Genre Name
/request:
get:
summary: Get all requests
description: |
Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned.
+
+ If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned.
tags:
- request
parameters:
@@ -3689,6 +4388,12 @@ paths:
type: string
enum: [added, modified]
default: added
+ - in: query
+ name: requestedBy
+ schema:
+ type: number
+ nullable: true
+ example: 1
responses:
'200':
description: Requests returned
@@ -3743,6 +4448,9 @@ paths:
type: string
languageProfileId:
type: number
+ userId:
+ type: number
+ nullable: true
required:
- mediaType
- mediaId
@@ -4100,7 +4808,7 @@ paths:
name: language
schema:
type: string
- example: en-US
+ example: en
responses:
'200':
description: TV details
@@ -4298,7 +5006,7 @@ paths:
type: number
/media:
get:
- summary: Return media
+ summary: Get media
description: Returns all media (can be filtered and limited) in a JSON object.
tags:
- media
@@ -4535,14 +5243,16 @@ paths:
content:
application/json:
schema:
- type: object
- properties:
- iso_3166_1:
- type: string
- example: US
- english_name:
- type: string
- example: United States of America
+ type: array
+ items:
+ type: object
+ properties:
+ iso_3166_1:
+ type: string
+ example: US
+ english_name:
+ type: string
+ example: United States of America
/languages:
get:
summary: Languages supported by TMDb
@@ -4555,17 +5265,115 @@ paths:
content:
application/json:
schema:
- type: object
- properties:
- iso_639_1:
- type: string
- example: en
- english_name:
- type: string
- example: English
- name:
- type: string
- example: English
+ type: array
+ items:
+ type: object
+ properties:
+ iso_639_1:
+ type: string
+ example: en
+ english_name:
+ type: string
+ example: English
+ name:
+ type: string
+ example: English
+ /studio/{studioId}:
+ get:
+ summary: Get movie studio details
+ description: Returns movie studio details in a JSON object.
+ tags:
+ - tmdb
+ parameters:
+ - in: path
+ name: studioId
+ required: true
+ schema:
+ type: number
+ example: 2
+ responses:
+ '200':
+ description: Movie studio details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ProductionCompany'
+ /network/{networkId}:
+ get:
+ summary: Get TV network details
+ description: Returns TV network details in a JSON object.
+ tags:
+ - tmdb
+ parameters:
+ - in: path
+ name: networkId
+ required: true
+ schema:
+ type: number
+ example: 1
+ responses:
+ '200':
+ description: TV network details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ProductionCompany'
+ /genres/movie:
+ get:
+ summary: Get list of official TMDb movie genres
+ description: Returns a list of genres in a JSON array.
+ tags:
+ - tmdb
+ parameters:
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 10751
+ name:
+ type: string
+ example: Family
+ /genres/tv:
+ get:
+ summary: Get list of official TMDb movie genres
+ description: Returns a list of genres in a JSON array.
+ tags:
+ - tmdb
+ parameters:
+ - in: query
+ name: language
+ schema:
+ type: string
+ example: en
+ responses:
+ '200':
+ description: Results
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: number
+ example: 18
+ name:
+ type: string
+ example: Drama
security:
- cookieAuth: []
diff --git a/package.json b/package.json
index fb91adba7..658e3ce45 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"build": "yarn build:next && yarn build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
"start": "NODE_ENV=production node dist/index.js",
- "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false \"./src/**/!(*.test).{ts,tsx}\"",
+ "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
@@ -17,118 +17,124 @@
},
"license": "MIT",
"dependencies": {
- "@headlessui/react": "^0.3.1",
+ "@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.1",
- "bcrypt": "^5.0.0",
- "body-parser": "^1.19.0",
+ "axios": "^0.21.4",
+ "bcrypt": "^5.0.1",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
- "country-code-emoji": "^2.2.0",
+ "copy-to-clipboard": "^3.3.1",
+ "country-flag-icons": "^1.4.10",
"csurf": "^1.11.0",
- "email-templates": "^8.0.3",
+ "email-templates": "^8.0.8",
"express": "^4.17.1",
- "express-openapi-validator": "^4.11.0",
- "express-session": "^1.17.1",
- "formik": "^2.2.6",
- "gravatar-url": "^3.1.0",
+ "express-openapi-validator": "^4.13.1",
+ "express-rate-limit": "^5.3.0",
+ "express-session": "^1.17.2",
+ "formik": "^2.2.9",
+ "gravatar-url": "3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
- "next": "10.0.3",
+ "next": "11.1.2",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
- "nodemailer": "^6.4.18",
- "nookies": "^2.5.2",
+ "nodemailer": "^6.6.3",
+ "openpgp": "^5.0.0-3",
"plex-api": "^5.3.1",
- "pug": "^3.0.0",
- "react": "17.0.1",
+ "pug": "^3.0.2",
+ "react": "17.0.2",
"react-ace": "^9.3.0",
"react-animate-height": "^2.0.23",
- "react-dom": "17.0.1",
- "react-intersection-observer": "^8.31.0",
- "react-intl": "^5.12.5",
- "react-markdown": "^5.0.3",
- "react-spring": "^8.0.27",
- "react-toast-notifications": "^2.4.3",
- "react-transition-group": "^4.4.1",
+ "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-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.2",
+ "secure-random-password": "^0.2.3",
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.1.6",
- "swr": "^0.4.2",
- "typeorm": "^0.2.31",
- "uuid": "^8.3.2",
+ "swr": "^0.5.6",
+ "typeorm": "0.2.37",
+ "web-push": "^3.4.5",
"winston": "^3.3.3",
- "winston-daily-rotate-file": "^4.5.0",
+ "winston-daily-rotate-file": "^4.5.5",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.9"
},
"devDependencies": {
- "@babel/cli": "^7.12.17",
- "@commitlint/cli": "^11.0.0",
- "@commitlint/config-conventional": "^11.0.0",
+ "@babel/cli": "^7.15.7",
+ "@commitlint/cli": "^13.1.0",
+ "@commitlint/config-conventional": "^13.1.0",
"@fullhuman/postcss-purgecss": "3.0.0",
"@semantic-release/changelog": "^5.0.1",
- "@semantic-release/commit-analyzer": "^8.0.1",
+ "@semantic-release/commit-analyzer": "^9.0.1",
"@semantic-release/exec": "^5.0.0",
- "@semantic-release/git": "^9.0.0",
- "@tailwindcss/aspect-ratio": "^0.2.0",
- "@tailwindcss/forms": "^0.2.1",
- "@tailwindcss/typography": "^0.4.0",
- "@types/bcrypt": "^3.0.0",
- "@types/body-parser": "^1.19.0",
+ "@semantic-release/git": "^9.0.1",
+ "@tailwindcss/aspect-ratio": "^0.2.1",
+ "@tailwindcss/forms": "^0.3.3",
+ "@tailwindcss/typography": "^0.4.1",
+ "@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.2",
- "@types/csurf": "^1.11.0",
- "@types/email-templates": "^8.0.2",
- "@types/express": "^4.17.11",
+ "@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.168",
- "@types/node": "^14.14.31",
- "@types/node-schedule": "^1.3.1",
- "@types/nodemailer": "^6.4.0",
- "@types/react": "^17.0.2",
- "@types/react-dom": "^17.0.1",
- "@types/react-toast-notifications": "^2.4.0",
- "@types/react-transition-group": "^4.4.1",
- "@types/secure-random-password": "^0.2.0",
- "@types/swagger-ui-express": "^4.1.2",
- "@types/uuid": "^8.3.0",
- "@types/xml2js": "^0.4.8",
+ "@types/lodash": "^4.14.173",
+ "@types/node": "^15.6.1",
+ "@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/secure-random-password": "^0.2.1",
+ "@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.11",
- "@typescript-eslint/eslint-plugin": "^4.15.1",
- "@typescript-eslint/parser": "^4.15.1",
- "autoprefixer": "^10.2.4",
+ "@types/yup": "^0.29.13",
+ "@typescript-eslint/eslint-plugin": "^4.31.1",
+ "@typescript-eslint/parser": "^4.31.1",
+ "autoprefixer": "^10.3.4",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
- "commitizen": "^4.2.3",
+ "commitizen": "^4.2.4",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
- "eslint": "^7.20.0",
- "eslint-config-prettier": "^7.2.0",
- "eslint-plugin-formatjs": "^2.12.4",
+ "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-plugin-prettier": "^3.3.1",
- "eslint-plugin-react": "^7.22.0",
+ "eslint-plugin-prettier": "^4.0.0",
+ "eslint-plugin-react": "^7.25.3",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "4.3.8",
- "lint-staged": "^10.5.4",
- "nodemon": "^2.0.7",
- "postcss": "^8.2.6",
- "postcss-preset-env": "^6.7.0",
- "prettier": "^2.2.1",
- "semantic-release": "^17.3.9",
- "semantic-release-docker": "^2.2.0",
- "tailwindcss": "npm:@tailwindcss/postcss7-compat",
- "ts-node": "^9.1.1",
- "typescript": "^4.1.5"
+ "lint-staged": "^11.1.2",
+ "nodemon": "^2.0.12",
+ "postcss": "^8.3.6",
+ "prettier": "^2.4.1",
+ "semantic-release": "^18.0.0",
+ "semantic-release-docker-buildx": "^1.0.1",
+ "tailwindcss": "^2.2.15",
+ "ts-node": "^10.2.1",
+ "typescript": "^4.4.3"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
@@ -148,12 +154,10 @@
"lint-staged": {
"**/*.{ts,tsx,js}": [
"prettier --write",
- "eslint",
- "git add"
+ "eslint"
],
"**/*.{json,md}": [
- "prettier --write",
- "git add"
+ "prettier --write"
]
},
"commitlint": {
@@ -186,13 +190,7 @@
"message": "chore(release): ${nextRelease.version}"
}
],
- [
- "@semantic-release/exec",
- {
- "prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
- }
- ],
- "semantic-release-docker",
+ "semantic-release-docker-buildx",
[
"@semantic-release/github",
{
@@ -206,8 +204,19 @@
"npmPublish": false,
"publish": [
{
- "path": "semantic-release-docker",
- "name": "sctx/overseerr"
+ "path": "semantic-release-docker-buildx",
+ "buildArgs": {
+ "COMMIT_TAG": "$GITHUB_SHA"
+ },
+ "imageNames": [
+ "sctx/overseerr",
+ "ghcr.io/sct/overseerr"
+ ],
+ "platforms": [
+ "linux/amd64",
+ "linux/arm64",
+ "linux/arm/v7"
+ ]
},
"@semantic-release/github"
]
diff --git a/postcss.config.js b/postcss.config.js
index 7129bb14f..12a703d90 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -1,3 +1,6 @@
module.exports = {
- plugins: ['tailwindcss', 'postcss-preset-env'],
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
};
diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png
index 692f01a85..d14b10cbb 100644
Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ
diff --git a/public/android-chrome-192x192_maskable.png b/public/android-chrome-192x192_maskable.png
new file mode 100644
index 000000000..2a6f70820
Binary files /dev/null and b/public/android-chrome-192x192_maskable.png differ
diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png
index 34d1f9e1e..9dfde238b 100644
Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ
diff --git a/public/android-chrome-512x512_maskable.png b/public/android-chrome-512x512_maskable.png
new file mode 100644
index 000000000..7313c4e4a
Binary files /dev/null and b/public/android-chrome-512x512_maskable.png differ
diff --git a/public/apple-splash-1125-2436.jpg b/public/apple-splash-1125-2436.jpg
new file mode 100644
index 000000000..775572220
Binary files /dev/null and b/public/apple-splash-1125-2436.jpg differ
diff --git a/public/apple-splash-1136-640.jpg b/public/apple-splash-1136-640.jpg
new file mode 100644
index 000000000..008490825
Binary files /dev/null and b/public/apple-splash-1136-640.jpg differ
diff --git a/public/apple-splash-1170-2532.jpg b/public/apple-splash-1170-2532.jpg
new file mode 100644
index 000000000..bef6cb45e
Binary files /dev/null and b/public/apple-splash-1170-2532.jpg differ
diff --git a/public/apple-splash-1242-2208.jpg b/public/apple-splash-1242-2208.jpg
new file mode 100644
index 000000000..15cf9c5be
Binary files /dev/null and b/public/apple-splash-1242-2208.jpg differ
diff --git a/public/apple-splash-1242-2688.jpg b/public/apple-splash-1242-2688.jpg
new file mode 100644
index 000000000..0b822f692
Binary files /dev/null and b/public/apple-splash-1242-2688.jpg differ
diff --git a/public/apple-splash-1284-2778.jpg b/public/apple-splash-1284-2778.jpg
new file mode 100644
index 000000000..41d3f3827
Binary files /dev/null and b/public/apple-splash-1284-2778.jpg differ
diff --git a/public/apple-splash-1334-750.jpg b/public/apple-splash-1334-750.jpg
new file mode 100644
index 000000000..29b88104b
Binary files /dev/null and b/public/apple-splash-1334-750.jpg differ
diff --git a/public/apple-splash-1536-2048.jpg b/public/apple-splash-1536-2048.jpg
new file mode 100644
index 000000000..04e839bc9
Binary files /dev/null and b/public/apple-splash-1536-2048.jpg differ
diff --git a/public/apple-splash-1620-2160.jpg b/public/apple-splash-1620-2160.jpg
new file mode 100644
index 000000000..31fb906fe
Binary files /dev/null and b/public/apple-splash-1620-2160.jpg differ
diff --git a/public/apple-splash-1668-2224.jpg b/public/apple-splash-1668-2224.jpg
new file mode 100644
index 000000000..c7eae485f
Binary files /dev/null and b/public/apple-splash-1668-2224.jpg differ
diff --git a/public/apple-splash-1668-2388.jpg b/public/apple-splash-1668-2388.jpg
new file mode 100644
index 000000000..be83d63cb
Binary files /dev/null and b/public/apple-splash-1668-2388.jpg differ
diff --git a/public/apple-splash-1792-828.jpg b/public/apple-splash-1792-828.jpg
new file mode 100644
index 000000000..fdd950f3e
Binary files /dev/null and b/public/apple-splash-1792-828.jpg differ
diff --git a/public/apple-splash-2048-1536.jpg b/public/apple-splash-2048-1536.jpg
new file mode 100644
index 000000000..a35fcadd6
Binary files /dev/null and b/public/apple-splash-2048-1536.jpg differ
diff --git a/public/apple-splash-2048-2732.jpg b/public/apple-splash-2048-2732.jpg
new file mode 100644
index 000000000..51ba30752
Binary files /dev/null and b/public/apple-splash-2048-2732.jpg differ
diff --git a/public/apple-splash-2160-1620.jpg b/public/apple-splash-2160-1620.jpg
new file mode 100644
index 000000000..f7bc1a1b4
Binary files /dev/null and b/public/apple-splash-2160-1620.jpg differ
diff --git a/public/apple-splash-2208-1242.jpg b/public/apple-splash-2208-1242.jpg
new file mode 100644
index 000000000..5485fa8f0
Binary files /dev/null and b/public/apple-splash-2208-1242.jpg differ
diff --git a/public/apple-splash-2224-1668.jpg b/public/apple-splash-2224-1668.jpg
new file mode 100644
index 000000000..0ccadef6e
Binary files /dev/null and b/public/apple-splash-2224-1668.jpg differ
diff --git a/public/apple-splash-2388-1668.jpg b/public/apple-splash-2388-1668.jpg
new file mode 100644
index 000000000..f9d3c2d12
Binary files /dev/null and b/public/apple-splash-2388-1668.jpg differ
diff --git a/public/apple-splash-2436-1125.jpg b/public/apple-splash-2436-1125.jpg
new file mode 100644
index 000000000..ee7916dba
Binary files /dev/null and b/public/apple-splash-2436-1125.jpg differ
diff --git a/public/apple-splash-2532-1170.jpg b/public/apple-splash-2532-1170.jpg
new file mode 100644
index 000000000..896f739ee
Binary files /dev/null and b/public/apple-splash-2532-1170.jpg differ
diff --git a/public/apple-splash-2688-1242.jpg b/public/apple-splash-2688-1242.jpg
new file mode 100644
index 000000000..0b5672183
Binary files /dev/null and b/public/apple-splash-2688-1242.jpg differ
diff --git a/public/apple-splash-2732-2048.jpg b/public/apple-splash-2732-2048.jpg
new file mode 100644
index 000000000..b6dac3e70
Binary files /dev/null and b/public/apple-splash-2732-2048.jpg differ
diff --git a/public/apple-splash-2778-1284.jpg b/public/apple-splash-2778-1284.jpg
new file mode 100644
index 000000000..25fdea363
Binary files /dev/null and b/public/apple-splash-2778-1284.jpg differ
diff --git a/public/apple-splash-640-1136.jpg b/public/apple-splash-640-1136.jpg
new file mode 100644
index 000000000..36b8d0b9a
Binary files /dev/null and b/public/apple-splash-640-1136.jpg differ
diff --git a/public/apple-splash-750-1334.jpg b/public/apple-splash-750-1334.jpg
new file mode 100644
index 000000000..0bab31b6f
Binary files /dev/null and b/public/apple-splash-750-1334.jpg differ
diff --git a/public/apple-splash-828-1792.jpg b/public/apple-splash-828-1792.jpg
new file mode 100644
index 000000000..bf48283bf
Binary files /dev/null and b/public/apple-splash-828-1792.jpg differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index 34c700e21..e3789e05d 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/badge-128x128.png b/public/badge-128x128.png
new file mode 100644
index 000000000..9f94a6377
Binary files /dev/null and b/public/badge-128x128.png differ
diff --git a/public/clock-icon-192x192.png b/public/clock-icon-192x192.png
new file mode 100644
index 000000000..a671e35c8
Binary files /dev/null and b/public/clock-icon-192x192.png differ
diff --git a/public/cog-icon-192x192.png b/public/cog-icon-192x192.png
new file mode 100644
index 000000000..a40a6a9de
Binary files /dev/null and b/public/cog-icon-192x192.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
index b632b9f19..4f85c3063 100644
Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
index 8cd58e542..f6e68a092 100644
Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index 7346f636d..c15cf8bf2 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/images/overseerr_poster_not_found.png b/public/images/overseerr_poster_not_found.png
index 2f5bc203d..e2bae10fd 100644
Binary files a/public/images/overseerr_poster_not_found.png and b/public/images/overseerr_poster_not_found.png differ
diff --git a/public/images/overseerr_poster_not_found_logo_center.png b/public/images/overseerr_poster_not_found_logo_center.png
index 2ecd84b0f..1a1e5752d 100644
Binary files a/public/images/overseerr_poster_not_found_logo_center.png and b/public/images/overseerr_poster_not_found_logo_center.png differ
diff --git a/public/images/overseerr_poster_not_found_logo_top.png b/public/images/overseerr_poster_not_found_logo_top.png
index a74b096e1..6e58dfdab 100644
Binary files a/public/images/overseerr_poster_not_found_logo_top.png and b/public/images/overseerr_poster_not_found_logo_top.png differ
diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg
deleted file mode 100644
index 3ccb70e93..000000000
--- a/public/images/radarr_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/public/images/sonarr_logo.svg b/public/images/sonarr_logo.svg
deleted file mode 100644
index f45e99270..000000000
--- a/public/images/sonarr_logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/public/logo.png b/public/logo.png
deleted file mode 100644
index 7b61ea02f..000000000
Binary files a/public/logo.png and /dev/null differ
diff --git a/public/logo_full.png b/public/logo_full.png
new file mode 100644
index 000000000..3ac4cdce1
Binary files /dev/null and b/public/logo_full.png differ
diff --git a/public/logo_full.svg b/public/logo_full.svg
new file mode 100644
index 000000000..6093c3d80
--- /dev/null
+++ b/public/logo_full.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/logo_stacked.svg b/public/logo_stacked.svg
new file mode 100644
index 000000000..946e79011
--- /dev/null
+++ b/public/logo_stacked.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/offline.html b/public/offline.html
new file mode 100644
index 000000000..12c6c29f5
--- /dev/null
+++ b/public/offline.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+ You are offline
+
+
+
+
+
+ You are offline
+
+ ↻ Reload
+
+
+
+
+
diff --git a/public/os_icon.svg b/public/os_icon.svg
new file mode 100644
index 000000000..a0e32e793
--- /dev/null
+++ b/public/os_icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/os_logo_filled.png b/public/os_logo_filled.png
new file mode 100644
index 000000000..0b473f188
Binary files /dev/null and b/public/os_logo_filled.png differ
diff --git a/public/os_logo_square.png b/public/os_logo_square.png
deleted file mode 100644
index 9b9d6c53f..000000000
Binary files a/public/os_logo_square.png and /dev/null differ
diff --git a/public/preview.jpg b/public/preview.jpg
index 8abdaa1ef..e30b98c2e 100644
Binary files a/public/preview.jpg and b/public/preview.jpg differ
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 45d6efa45..53e89cfc9 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -4,17 +4,77 @@
"start_url": "./",
"icons": [
{
- "src": "/android-chrome-192x192.png",
+ "src": "./android-chrome-192x192.png",
"sizes": "192x192",
- "type": "image/png"
+ "type": "image/png",
+ "purpose": "any"
},
{
- "src": "/android-chrome-512x512.png",
+ "src": "./android-chrome-192x192_maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "./android-chrome-512x512.png",
"sizes": "512x512",
- "type": "image/png"
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "./android-chrome-512x512_maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
}
],
- "theme_color": "#2d3748",
- "background_color": "#2d3748",
- "display": "standalone"
+ "theme_color": "#1f2937",
+ "background_color": "#1f2937",
+ "display": "standalone",
+ "shortcuts": [
+ {
+ "name": "Discover",
+ "url": "./",
+ "icons": [
+ {
+ "src": "./sparkles-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ]
+ },
+ {
+ "name": "Requests",
+ "url": "./requests",
+ "icons": [
+ {
+ "src": "./clock-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ]
+ },
+ {
+ "name": "Profile",
+ "url": "./profile",
+ "icons": [
+ {
+ "src": "./user-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ]
+ },
+ {
+ "name": "Settings",
+ "url": "./profile/settings",
+ "icons": [
+ {
+ "src": "./cog-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ]
+ }
+ ]
}
diff --git a/public/sparkles-icon-192x192.png b/public/sparkles-icon-192x192.png
new file mode 100644
index 000000000..71b32e120
Binary files /dev/null and b/public/sparkles-icon-192x192.png differ
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 000000000..a3c816e8f
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,139 @@
+/* eslint-disable no-undef */
+// Incrementing OFFLINE_VERSION will kick off the install event and force
+// previously cached resources to be updated from the network.
+// This variable is intentionally declared and unused.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const OFFLINE_VERSION = 3;
+const CACHE_NAME = "offline";
+// Customize this with a different URL if needed.
+const OFFLINE_URL = "/offline.html";
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await caches.open(CACHE_NAME);
+ // Setting {cache: 'reload'} in the new request will ensure that the
+ // response isn't fulfilled from the HTTP cache; i.e., it will be from
+ // the network.
+ await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
+ })()
+ );
+ // Force the waiting service worker to become the active service worker.
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ (async () => {
+ // Enable navigation preload if it's supported.
+ // See https://developers.google.com/web/updates/2017/02/navigation-preload
+ if ("navigationPreload" in self.registration) {
+ await self.registration.navigationPreload.enable();
+ }
+ })()
+ );
+
+ // Tell the active service worker to take control of the page immediately.
+ clients.claim();
+});
+
+self.addEventListener("fetch", (event) => {
+ // We only want to call event.respondWith() if this is a navigation request
+ // for an HTML page.
+ if (event.request.mode === "navigate") {
+ event.respondWith(
+ (async () => {
+ try {
+ // First, try to use the navigation preload response if it's supported.
+ const preloadResponse = await event.preloadResponse;
+ if (preloadResponse) {
+ return preloadResponse;
+ }
+
+ // Always try the network first.
+ const networkResponse = await fetch(event.request);
+ return networkResponse;
+ } catch (error) {
+ // catch is only triggered if an exception is thrown, which is likely
+ // due to a network error.
+ // If fetch() returns a valid HTTP response with a response code in
+ // the 4xx or 5xx range, the catch() will NOT be called.
+ // eslint-disable-next-line no-console
+ console.log("Fetch failed; returning offline page instead.", error);
+
+ const cache = await caches.open(CACHE_NAME);
+ const cachedResponse = await cache.match(OFFLINE_URL);
+ return cachedResponse;
+ }
+ })()
+ );
+ }
+});
+
+self.addEventListener('push', (event) => {
+ const payload = event.data ? event.data.json() : {};
+
+ const options = {
+ body: payload.message,
+ badge: 'badge-128x128.png',
+ icon: payload.image ? payload.image : 'android-chrome-192x192.png',
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ primaryKey: '2',
+ actionUrl: payload.actionUrl,
+ requestId: payload.requestId,
+ },
+ actions: [],
+ }
+
+ if (payload.actionUrl){
+ options.actions.push(
+ {
+ action: 'viewmedia',
+ title: 'View Media',
+ }
+ );
+ }
+
+ if (payload.notificationType === 'MEDIA_PENDING') {
+ options.actions.push(
+ {
+ action: 'approve',
+ title: 'Approve',
+ },
+ {
+ action: 'decline',
+ title: 'Decline',
+ }
+ );
+ }
+
+ event.waitUntil(
+ self.registration.showNotification(payload.subject, options)
+ );
+});
+
+self.addEventListener('notificationclick', (event) => {
+ const notificationData = event.notification.data;
+
+ event.notification.close();
+
+ if (event.action === 'viewmedia') {
+ clients.openWindow(notificationData.actionUrl);
+ } else if (event.action === 'approve') {
+ fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
+ method: 'POST',
+ });
+
+ clients.openWindow(notificationData.actionUrl);
+ } else if (event.action === 'decline') {
+ fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
+ method: 'POST',
+ });
+
+ clients.openWindow(notificationData.actionUrl);
+ } else if (notificationData.actionUrl) {
+ clients.openWindow(notificationData.actionUrl);
+ }
+}, false);
diff --git a/public/user-icon-192x192.png b/public/user-icon-192x192.png
new file mode 100644
index 000000000..a7b92cc3c
Binary files /dev/null and b/public/user-icon-192x192.png differ
diff --git a/server/api/github.ts b/server/api/github.ts
new file mode 100644
index 000000000..48b8854b1
--- /dev/null
+++ b/server/api/github.ts
@@ -0,0 +1,133 @@
+import cacheManager from '../lib/cache';
+import logger from '../logger';
+import ExternalAPI from './externalapi';
+
+interface GitHubRelease {
+ url: string;
+ assets_url: string;
+ upload_url: string;
+ html_url: string;
+ id: number;
+ node_id: string;
+ tag_name: string;
+ target_commitish: string;
+ name: string;
+ draft: boolean;
+ prerelease: boolean;
+ created_at: string;
+ published_at: string;
+ tarball_url: string;
+ zipball_url: string;
+ body: string;
+}
+
+interface GithubCommit {
+ sha: string;
+ node_id: string;
+ commit: {
+ author: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ committer: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ message: string;
+ tree: {
+ sha: string;
+ url: string;
+ };
+ url: string;
+ comment_count: number;
+ verification: {
+ verified: boolean;
+ reason: string;
+ signature: string;
+ payload: string;
+ };
+ };
+ url: string;
+ html_url: string;
+ comments_url: string;
+ parents: [
+ {
+ sha: string;
+ url: string;
+ html_url: string;
+ }
+ ];
+}
+
+class GithubAPI extends ExternalAPI {
+ constructor() {
+ super(
+ 'https://api.github.com',
+ {},
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ nodeCache: cacheManager.getCache('github').data,
+ }
+ );
+ }
+
+ public async getOverseerrReleases({
+ take = 20,
+ }: {
+ take?: number;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/repos/sct/overseerr/releases',
+ {
+ params: {
+ per_page: take,
+ },
+ }
+ );
+
+ return data;
+ } catch (e) {
+ logger.warn(
+ "Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
+ { label: 'GitHub API', errorMessage: e.message }
+ );
+ return [];
+ }
+ }
+
+ public async getOverseerrCommits({
+ take = 20,
+ branch = 'develop',
+ }: {
+ take?: number;
+ branch?: string;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/repos/sct/overseerr/commits',
+ {
+ params: {
+ per_page: take,
+ branch,
+ },
+ }
+ );
+
+ return data;
+ } catch (e) {
+ logger.warn(
+ "Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
+ { label: 'GitHub API', errorMessage: e.message }
+ );
+ return [];
+ }
+ }
+}
+
+export default GithubAPI;
diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts
index f920a5b3e..cee4a9cd1 100644
--- a/server/api/plexapi.ts
+++ b/server/api/plexapi.ts
@@ -1,5 +1,5 @@
import NodePlexAPI from 'plex-api';
-import { getSettings, PlexSettings } from '../lib/settings';
+import { getSettings, Library, PlexSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
@@ -11,11 +11,16 @@ export interface PlexLibraryItem {
grandparentGuid?: string;
addedAt: number;
updatedAt: number;
+ Guid?: {
+ id: string;
+ }[];
type: 'movie' | 'show' | 'season' | 'episode';
+ Media: Media[];
}
interface PlexLibraryResponse {
MediaContainer: {
+ totalSize: number;
Metadata: PlexLibraryItem[];
};
}
@@ -118,7 +123,7 @@ class PlexAPI {
options: {
identifier: settings.clientId,
product: 'Overseerr',
- deviceName: settings.main.applicationTitle,
+ deviceName: 'Overseerr',
platform: 'Overseerr',
},
});
@@ -137,12 +142,50 @@ class PlexAPI {
return response.MediaContainer.Directory;
}
- public async getLibraryContents(id: string): Promise {
- const response = await this.plexClient.query(
- `/library/sections/${id}/all`
- );
+ public async syncLibraries(): Promise {
+ const settings = getSettings();
- return response.MediaContainer.Metadata;
+ const libraries = await this.getLibraries();
+
+ const newLibraries: Library[] = libraries
+ // Remove libraries that are not movie or show
+ .filter((library) => library.type === 'movie' || library.type === 'show')
+ // Remove libraries that do not have a metadata agent set (usually personal video libraries)
+ .filter((library) => library.agent !== 'com.plexapp.agents.none')
+ .map((library) => {
+ const existing = settings.plex.libraries.find(
+ (l) => l.id === library.key && l.name === library.title
+ );
+
+ return {
+ id: library.key,
+ name: library.title,
+ enabled: existing?.enabled ?? false,
+ type: library.type,
+ lastScan: existing?.lastScan,
+ };
+ });
+
+ settings.plex.libraries = newLibraries;
+ settings.save();
+ }
+
+ public async getLibraryContents(
+ id: string,
+ { offset = 0, size = 50 }: { offset?: number; size?: number } = {}
+ ): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
+ const response = await this.plexClient.query({
+ uri: `/library/sections/${id}/all?includeGuids=1`,
+ extraHeaders: {
+ 'X-Plex-Container-Start': `${offset}`,
+ 'X-Plex-Container-Size': `${size}`,
+ },
+ });
+
+ return {
+ totalSize: response.MediaContainer.totalSize,
+ items: response.MediaContainer.Metadata ?? [],
+ };
}
public async getMetadata(
@@ -166,10 +209,17 @@ class PlexAPI {
return response.MediaContainer.Metadata;
}
- public async getRecentlyAdded(id: string): Promise {
- const response = await this.plexClient.query(
- `/library/sections/${id}/recentlyAdded`
- );
+ public async getRecentlyAdded(
+ id: string,
+ options: { addedAt: number } = {
+ addedAt: Date.now() - 1000 * 60 * 60,
+ }
+ ): Promise {
+ const response = await this.plexClient.query({
+ uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
+ options.addedAt / 1000
+ )}`,
+ });
return response.MediaContainer.Metadata;
}
diff --git a/server/api/plextv.ts b/server/api/plextv.ts
index 5d93f956e..9efcecc2b 100644
--- a/server/api/plextv.ts
+++ b/server/api/plextv.ts
@@ -91,7 +91,7 @@ interface FriendResponse {
email: string;
thumb: string;
};
- Server: ServerResponse[];
+ Server?: ServerResponse[];
}[];
};
}
@@ -232,7 +232,7 @@ class PlexTvAPI {
);
}
- return !!user.Server.find(
+ return !!user.Server?.find(
(server) => server.$.machineIdentifier === settings.plex.machineId
);
} catch (e) {
diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts
index e83d55723..b9b00e108 100644
--- a/server/api/rottentomatoes.ts
+++ b/server/api/rottentomatoes.ts
@@ -1,39 +1,28 @@
import cacheManager from '../lib/cache';
import ExternalAPI from './externalapi';
-interface RTMovieOldSearchResult {
- id: number;
- title: string;
- year: number;
- ratings: {
- critics_rating: 'Certified Fresh' | 'Fresh' | 'Rotten';
- critics_score: number;
- audience_rating: 'Upright' | 'Spilled';
- audience_score: number;
- };
- links: {
- self: string;
- alternate: string;
- };
-}
-
-interface RTTvSearchResult {
- title: string;
- meterClass: 'fresh' | 'rotten';
+interface RTSearchResult {
+ meterClass: 'certified_fresh' | 'fresh' | 'rotten';
meterScore: number;
url: string;
+}
+
+interface RTTvSearchResult extends RTSearchResult {
+ title: string;
startYear: number;
endYear: number;
}
-
-interface RTMovieSearchResponse {
- total: number;
- movies: RTMovieOldSearchResult[];
+interface RTMovieSearchResult extends RTSearchResult {
+ name: string;
+ url: string;
+ year: number;
}
interface RTMultiSearchResponse {
tvCount: number;
tvSeries: RTTvSearchResult[];
+ movieCount: number;
+ movies: RTMovieSearchResult[];
}
export interface RTRating {
@@ -88,19 +77,19 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise {
try {
- const data = await this.get('/v1.0/movies', {
- params: { q: name },
+ const data = await this.get('/v2.0/search/', {
+ params: { q: name, limit: 10 },
});
// First, attempt to match exact name and year
let movie = data.movies.find(
- (movie) => movie.year === year && movie.title === name
+ (movie) => movie.year === year && movie.name === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = data.movies.find(
- (movie) => movie.year === year && movie.title.includes(name)
+ (movie) => movie.year === year && movie.name.includes(name)
);
}
@@ -111,7 +100,7 @@ class RottenTomatoes extends ExternalAPI {
// One last try, try exact name match only
if (!movie) {
- movie = data.movies.find((movie) => movie.title === name);
+ movie = data.movies.find((movie) => movie.name === name);
}
if (!movie) {
@@ -119,12 +108,15 @@ class RottenTomatoes extends ExternalAPI {
}
return {
- title: movie.title,
- url: movie.links.alternate,
- criticsRating: movie.ratings.critics_rating,
- criticsScore: movie.ratings.critics_score,
- audienceRating: movie.ratings.audience_rating,
- audienceScore: movie.ratings.audience_score,
+ title: movie.name,
+ url: `https://www.rottentomatoes.com${movie.url}`,
+ criticsRating:
+ movie.meterClass === 'certified_fresh'
+ ? 'Certified Fresh'
+ : movie.meterClass === 'fresh'
+ ? 'Fresh'
+ : 'Rotten',
+ criticsScore: movie.meterScore,
year: movie.year,
};
} catch (e) {
diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts
new file mode 100644
index 000000000..75f138b57
--- /dev/null
+++ b/server/api/servarr/base.ts
@@ -0,0 +1,169 @@
+import cacheManager, { AvailableCacheIds } from '../../lib/cache';
+import { DVRSettings } from '../../lib/settings';
+import ExternalAPI from '../externalapi';
+
+export interface RootFolder {
+ id: number;
+ path: string;
+ freeSpace: number;
+ totalSpace: number;
+ unmappedFolders: {
+ name: string;
+ path: string;
+ }[];
+}
+
+export interface QualityProfile {
+ id: number;
+ name: string;
+}
+
+interface QueueItem {
+ size: number;
+ title: string;
+ sizeleft: number;
+ timeleft: string;
+ estimatedCompletionTime: string;
+ status: string;
+ trackedDownloadStatus: string;
+ trackedDownloadState: string;
+ downloadId: string;
+ protocol: string;
+ downloadClient: string;
+ indexer: string;
+ id: number;
+}
+
+export interface Tag {
+ id: number;
+ label: string;
+}
+
+interface QueueResponse {
+ page: number;
+ pageSize: number;
+ sortKey: string;
+ sortDirection: string;
+ totalRecords: number;
+ records: (QueueItem & QueueItemAppendT)[];
+}
+
+class ServarrBase extends ExternalAPI {
+ static buildUrl(settings: DVRSettings, path?: string): string {
+ return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
+ settings.port
+ }${settings.baseUrl ?? ''}${path}`;
+ }
+
+ protected apiName: string;
+
+ constructor({
+ url,
+ apiKey,
+ cacheName,
+ apiName,
+ }: {
+ url: string;
+ apiKey: string;
+ cacheName: AvailableCacheIds;
+ apiName: string;
+ }) {
+ super(
+ url,
+ {
+ apikey: apiKey,
+ },
+ {
+ nodeCache: cacheManager.getCache(cacheName).data,
+ }
+ );
+
+ this.apiName = apiName;
+ }
+
+ public getProfiles = async (): Promise => {
+ try {
+ const data = await this.getRolling(
+ `/qualityProfile`,
+ undefined,
+ 3600
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve profiles: ${e.message}`
+ );
+ }
+ };
+
+ public getRootFolders = async (): Promise => {
+ try {
+ const data = await this.getRolling(
+ `/rootfolder`,
+ undefined,
+ 3600
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve root folders: ${e.message}`
+ );
+ }
+ };
+
+ public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
+ try {
+ const response = await this.axios.get>(
+ `/queue`
+ );
+
+ return response.data.records;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve queue: ${e.message}`
+ );
+ }
+ };
+
+ public getTags = async (): Promise => {
+ try {
+ const response = await this.axios.get(`/tag`);
+
+ return response.data;
+ } catch (e) {
+ throw new Error(
+ `[${this.apiName}] Failed to retrieve tags: ${e.message}`
+ );
+ }
+ };
+
+ public createTag = async ({ label }: { label: string }): Promise => {
+ try {
+ const response = await this.axios.post(`/tag`, {
+ label,
+ });
+
+ return response.data;
+ } catch (e) {
+ throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
+ }
+ };
+
+ protected async runCommand(
+ commandName: string,
+ options: Record
+ ): Promise {
+ try {
+ await this.axios.post(`/command`, {
+ name: commandName,
+ ...options,
+ });
+ } catch (e) {
+ throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
+ }
+ }
+}
+
+export default ServarrBase;
diff --git a/server/api/radarr.ts b/server/api/servarr/radarr.ts
similarity index 67%
rename from server/api/radarr.ts
rename to server/api/servarr/radarr.ts
index 187a52ba0..0e0a41f18 100644
--- a/server/api/radarr.ts
+++ b/server/api/servarr/radarr.ts
@@ -1,12 +1,11 @@
-import cacheManager from '../lib/cache';
-import { RadarrSettings } from '../lib/settings';
-import logger from '../logger';
-import ExternalAPI from './externalapi';
+import logger from '../../logger';
+import ServarrBase from './base';
interface RadarrMovieOptions {
title: string;
qualityProfileId: number;
minimumAvailability: string;
+ tags: number[];
profileId: number;
year: number;
rootFolderPath: string;
@@ -32,65 +31,9 @@ export interface RadarrMovie {
hasFile: boolean;
}
-export interface RadarrRootFolder {
- id: number;
- path: string;
- freeSpace: number;
- totalSpace: number;
- unmappedFolders: {
- name: string;
- path: string;
- }[];
-}
-
-export interface RadarrProfile {
- id: number;
- name: string;
-}
-
-interface QueueItem {
- movieId: number;
- size: number;
- title: string;
- sizeleft: number;
- timeleft: string;
- estimatedCompletionTime: string;
- status: string;
- trackedDownloadStatus: string;
- trackedDownloadState: string;
- downloadId: string;
- protocol: string;
- downloadClient: string;
- indexer: string;
- id: number;
-}
-
-interface QueueResponse {
- page: number;
- pageSize: number;
- sortKey: string;
- sortDirection: string;
- totalRecords: number;
- records: QueueItem[];
-}
-
-class RadarrAPI extends ExternalAPI {
- static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
- return `${radarrSettings.useSsl ? 'https' : 'http'}://${
- radarrSettings.hostname
- }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
- }
-
+class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
- super(
- url,
- {
- apikey: apiKey,
- },
- {
- nodeCache: cacheManager.getCache('radarr').data,
- }
- );
+ super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
}
public getMovies = async (): Promise => {
@@ -129,7 +72,8 @@ class RadarrAPI extends ExternalAPI {
} catch (e) {
logger.error('Error retrieving movie by TMDb ID', {
label: 'Radarr API',
- message: e.message,
+ errorMessage: e.message,
+ tmdbId: id,
});
throw new Error('Movie not found');
}
@@ -146,12 +90,13 @@ class RadarrAPI extends ExternalAPI {
'Title already exists and is available. Skipping add and returning success',
{
label: 'Radarr',
+ movie,
}
);
return movie;
}
- // movie exists in radarr but is neither downloaded nor monitored
+ // movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put(`/movie`, {
...movie,
@@ -162,6 +107,7 @@ class RadarrAPI extends ExternalAPI {
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
+ tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
@@ -171,16 +117,25 @@ class RadarrAPI extends ExternalAPI {
if (response.data.monitored) {
logger.info(
- 'Found existing title in Radarr and set it to monitored. Returning success',
- { label: 'Radarr' }
+ 'Found existing title in Radarr and set it to monitored.',
+ {
+ label: 'Radarr',
+ movieId: response.data.id,
+ movieTitle: response.data.title,
+ }
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: response.data,
});
+
+ if (options.searchNow) {
+ this.searchMovie(response.data.id);
+ }
+
return response.data;
} else {
- logger.error('Failed to update existing movie in Radarr', {
+ logger.error('Failed to update existing movie in Radarr.', {
label: 'Radarr',
options,
});
@@ -206,6 +161,7 @@ class RadarrAPI extends ExternalAPI {
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
+ tags: options.tags,
addOptions: {
searchForMovie: options.searchNow,
},
@@ -239,43 +195,25 @@ class RadarrAPI extends ExternalAPI {
}
};
- public getProfiles = async (): Promise => {
+ public async searchMovie(movieId: number): Promise {
+ logger.info('Executing movie search command', {
+ label: 'Radarr API',
+ movieId,
+ });
+
try {
- const data = await this.getRolling(
- `/qualityProfile`,
- undefined,
- 3600
+ await this.runCommand('MoviesSearch', { movieIds: [movieId] });
+ } catch (e) {
+ logger.error(
+ 'Something went wrong while executing Radarr movie search.',
+ {
+ label: 'Radarr API',
+ errorMessage: e.message,
+ movieId,
+ }
);
-
- return data;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
- };
-
- public getRootFolders = async (): Promise => {
- try {
- const data = await this.getRolling(
- `/rootfolder`,
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
- }
- };
-
- public getQueue = async (): Promise => {
- try {
- const response = await this.axios.get(`/queue`);
-
- return response.data.records;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
- }
- };
+ }
}
export default RadarrAPI;
diff --git a/server/api/sonarr.ts b/server/api/servarr/sonarr.ts
similarity index 69%
rename from server/api/sonarr.ts
rename to server/api/servarr/sonarr.ts
index 7369b0b66..b6793ed3f 100644
--- a/server/api/sonarr.ts
+++ b/server/api/servarr/sonarr.ts
@@ -1,7 +1,5 @@
-import cacheManager from '../lib/cache';
-import { SonarrSettings } from '../lib/settings';
-import logger from '../logger';
-import ExternalAPI from './externalapi';
+import logger from '../../logger';
+import ServarrBase from './base';
interface SonarrSeason {
seasonNumber: number;
@@ -49,7 +47,7 @@ export interface SonarrSeries {
titleSlug: string;
certification: string;
genres: string[];
- tags: string[];
+ tags: number[];
added: string;
ratings: {
votes: number;
@@ -65,49 +63,6 @@ export interface SonarrSeries {
};
}
-interface QueueItem {
- seriesId: number;
- episodeId: number;
- size: number;
- title: string;
- sizeleft: number;
- timeleft: string;
- estimatedCompletionTime: string;
- status: string;
- trackedDownloadStatus: string;
- trackedDownloadState: string;
- downloadId: string;
- protocol: string;
- downloadClient: string;
- indexer: string;
- id: number;
-}
-
-interface QueueResponse {
- page: number;
- pageSize: number;
- sortKey: string;
- sortDirection: string;
- totalRecords: number;
- records: QueueItem[];
-}
-
-interface SonarrProfile {
- id: number;
- name: string;
-}
-
-interface SonarrRootFolder {
- id: number;
- path: string;
- freeSpace: number;
- totalSpace: number;
- unmappedFolders: {
- name: string;
- path: string;
- }[];
-}
-
interface AddSeriesOptions {
tvdbid: number;
title: string;
@@ -116,6 +71,7 @@ interface AddSeriesOptions {
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
+ tags?: number[];
seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
searchNow?: boolean;
@@ -126,23 +82,9 @@ export interface LanguageProfile {
name: string;
}
-class SonarrAPI extends ExternalAPI {
- static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
- return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
- sonarrSettings.hostname
- }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
- }
-
+class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
- super(
- url,
- {
- apikey: apiKey,
- },
- {
- nodeCache: cacheManager.getCache('sonarr').data,
- }
- );
+ super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
public async getSeries(): Promise {
@@ -151,7 +93,7 @@ class SonarrAPI extends ExternalAPI {
return response.data;
} catch (e) {
- throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
+ throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
}
}
@@ -171,7 +113,8 @@ class SonarrAPI extends ExternalAPI {
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
- message: e.message,
+ errorMessage: e.message,
+ title,
});
throw new Error('No series found');
}
@@ -193,7 +136,8 @@ class SonarrAPI extends ExternalAPI {
} catch (e) {
logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API',
- message: e.message,
+ errorMessage: e.message,
+ tvdbId: id,
});
throw new Error('Series not found');
}
@@ -205,26 +149,30 @@ class SonarrAPI extends ExternalAPI {
// If the series already exists, we will simply just update it
if (series.id) {
+ series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
- series.addOptions = {
- ignoreEpisodesWithFiles: true,
- searchForMissingEpisodes: options.searchNow,
- };
-
const newSeriesResponse = await this.axios.put(
'/series',
series
);
if (newSeriesResponse.data.id) {
- logger.info('Sonarr accepted request. Updated existing series', {
+ logger.info('Updated existing series in Sonarr.', {
label: 'Sonarr',
+ seriesId: newSeriesResponse.data.id,
+ seriesTitle: newSeriesResponse.data.title,
});
logger.debug('Sonarr update details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
+
+ if (options.searchNow) {
+ this.searchSeries(newSeriesResponse.data.id);
+ }
+
+ return newSeriesResponse.data;
} else {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
@@ -232,8 +180,6 @@ class SonarrAPI extends ExternalAPI {
});
throw new Error('Failed to update series in Sonarr');
}
-
- return newSeriesResponse.data;
}
const createdSeriesResponse = await this.axios.post(
@@ -251,6 +197,7 @@ class SonarrAPI extends ExternalAPI {
monitored: false,
}))
),
+ tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
@@ -281,53 +228,13 @@ class SonarrAPI extends ExternalAPI {
logger.error('Something went wrong while adding a series to Sonarr.', {
label: 'Sonarr API',
errorMessage: e.message,
- error: e,
+ options,
response: e?.response?.data,
});
throw new Error('Failed to add series');
}
}
- public async getProfiles(): Promise {
- try {
- const data = await this.getRolling(
- '/qualityProfile',
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- logger.error('Something went wrong while retrieving Sonarr profiles.', {
- label: 'Sonarr API',
- message: e.message,
- });
- throw new Error('Failed to get profiles');
- }
- }
-
- public async getRootFolders(): Promise {
- try {
- const data = await this.getRolling(
- '/rootfolder',
- undefined,
- 3600
- );
-
- return data;
- } catch (e) {
- logger.error(
- 'Something went wrong while retrieving Sonarr root folders.',
- {
- label: 'Sonarr API',
- message: e.message,
- }
- );
-
- throw new Error('Failed to get root folders');
- }
- }
-
public async getLanguageProfiles(): Promise {
try {
const data = await this.getRolling(
@@ -342,7 +249,7 @@ class SonarrAPI extends ExternalAPI {
'Something went wrong while retrieving Sonarr language profiles.',
{
label: 'Sonarr API',
- message: e.message,
+ errorMessage: e.message,
}
);
@@ -350,6 +257,26 @@ class SonarrAPI extends ExternalAPI {
}
}
+ public async searchSeries(seriesId: number): Promise {
+ logger.info('Executing series search command.', {
+ label: 'Sonarr API',
+ seriesId,
+ });
+
+ try {
+ await this.runCommand('SeriesSearch', { seriesId });
+ } catch (e) {
+ logger.error(
+ 'Something went wrong while executing Sonarr series search.',
+ {
+ label: 'Sonarr API',
+ errorMessage: e.message,
+ seriesId,
+ }
+ );
+ }
+ }
+
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]
@@ -374,16 +301,6 @@ class SonarrAPI extends ExternalAPI {
return newSeasons;
}
-
- public getQueue = async (): Promise => {
- try {
- const response = await this.axios.get(`/queue`);
-
- return response.data.records;
- } catch (e) {
- throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
- }
- };
}
export default SonarrAPI;
diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts
index b7bfeb92c..ddc180592 100644
--- a/server/api/themoviedb/index.ts
+++ b/server/api/themoviedb/index.ts
@@ -4,10 +4,14 @@ import ExternalAPI from '../externalapi';
import {
TmdbCollection,
TmdbExternalIdResponse,
+ TmdbGenre,
+ TmdbGenresResult,
TmdbLanguage,
TmdbMovieDetails,
+ TmdbNetwork,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
+ TmdbProductionCompany,
TmdbRegion,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
@@ -30,6 +34,9 @@ interface DiscoverMovieOptions {
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
+ originalLanguage?: string;
+ genre?: number;
+ studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
@@ -53,6 +60,9 @@ interface DiscoverTvOptions {
firstAirDateGte?: string;
firstAirDateLte?: string;
includeEmptyReleaseDate?: boolean;
+ originalLanguage?: string;
+ genre?: number;
+ network?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
@@ -120,7 +130,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
}
};
@@ -142,7 +152,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
- `[TMDB] Failed to fetch person combined credits: ${e.message}`
+ `[TMDb] Failed to fetch person combined credits: ${e.message}`
);
}
};
@@ -160,7 +170,8 @@ class TheMovieDb extends ExternalAPI {
{
params: {
language,
- append_to_response: 'credits,external_ids,videos,release_dates',
+ append_to_response:
+ 'credits,external_ids,videos,release_dates,watch/providers',
},
},
43200
@@ -168,7 +179,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
}
};
@@ -186,7 +197,7 @@ class TheMovieDb extends ExternalAPI {
params: {
language,
append_to_response:
- 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings',
+ 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
},
43200
@@ -194,7 +205,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -220,7 +231,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -246,7 +257,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -272,7 +283,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -298,7 +309,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
}
}
@@ -325,7 +336,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
- `[TMDB] Failed to fetch tv recommendations: ${e.message}`
+ `[TMDb] Failed to fetch TV recommendations: ${e.message}`
);
}
}
@@ -349,7 +360,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
}
}
@@ -360,6 +371,9 @@ class TheMovieDb extends ExternalAPI {
language = 'en',
primaryReleaseDateGte,
primaryReleaseDateLte,
+ originalLanguage,
+ genre,
+ studio,
}: DiscoverMovieOptions = {}): Promise => {
try {
const data = await this.get('/discover/movie', {
@@ -368,27 +382,31 @@ class TheMovieDb extends ExternalAPI {
page,
include_adult: includeAdult,
language,
- with_release_type: '3|2',
region: this.region,
- with_original_language: this.originalLanguage,
+ with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
+ with_genres: genre,
+ with_companies: studio,
},
});
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
- language = 'en-US',
+ language = 'en',
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
+ originalLanguage,
+ genre,
+ network,
}: DiscoverTvOptions = {}): Promise => {
try {
const data = await this.get('/discover/tv', {
@@ -399,14 +417,16 @@ class TheMovieDb extends ExternalAPI {
region: this.region,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
- with_original_language: this.originalLanguage,
+ with_original_language: originalLanguage ?? this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
+ with_genres: genre,
+ with_networks: network,
},
});
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
}
};
@@ -432,7 +452,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
}
};
@@ -459,7 +479,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -482,7 +502,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -505,7 +525,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -537,7 +557,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
+ throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
}
}
@@ -564,11 +584,11 @@ class TheMovieDb extends ExternalAPI {
}
throw new Error(
- '[TMDB] Failed to find a title with the provided IMDB id'
+ '[TMDb] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
- `[TMDB] Failed to get movie by external imdb ID: ${e.message}`
+ `[TMDb] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
@@ -595,12 +615,10 @@ class TheMovieDb extends ExternalAPI {
return tvshow;
}
- throw new Error(
- `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
- );
+ throw new Error(`No show returned from API for ID ${tvdbId}`);
} catch (e) {
throw new Error(
- `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
+ `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
@@ -624,7 +642,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
}
}
@@ -640,7 +658,7 @@ class TheMovieDb extends ExternalAPI {
return regions;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
}
}
@@ -656,7 +674,131 @@ class TheMovieDb extends ExternalAPI {
return languages;
} catch (e) {
- throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
+ throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
+ }
+ }
+
+ public async getStudio(studioId: number): Promise {
+ try {
+ const data = await this.get(
+ `/company/${studioId}`
+ );
+
+ return data;
+ } catch (e) {
+ throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
+ }
+ }
+
+ public async getNetwork(networkId: number): Promise {
+ try {
+ const data = await this.get(`/network/${networkId}`);
+
+ return data;
+ } catch (e) {
+ throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
+ }
+ }
+
+ public async getMovieGenres({
+ language = 'en',
+ }: {
+ language?: string;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/genre/movie/list',
+ {
+ params: {
+ language,
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ if (
+ !language.startsWith('en') &&
+ data.genres.some((genre) => !genre.name)
+ ) {
+ const englishData = await this.get(
+ '/genre/movie/list',
+ {
+ params: {
+ language: 'en',
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ data.genres
+ .filter((genre) => !genre.name)
+ .forEach((genre) => {
+ genre.name =
+ englishData.genres.find(
+ (englishGenre) => englishGenre.id === genre.id
+ )?.name ?? '';
+ });
+ }
+
+ const movieGenres = sortBy(
+ data.genres.filter((genre) => genre.name),
+ 'name'
+ );
+
+ return movieGenres;
+ } catch (e) {
+ throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
+ }
+ }
+
+ public async getTvGenres({
+ language = 'en',
+ }: {
+ language?: string;
+ } = {}): Promise {
+ try {
+ const data = await this.get(
+ '/genre/tv/list',
+ {
+ params: {
+ language,
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ if (
+ !language.startsWith('en') &&
+ data.genres.some((genre) => !genre.name)
+ ) {
+ const englishData = await this.get(
+ '/genre/tv/list',
+ {
+ params: {
+ language: 'en',
+ },
+ },
+ 86400 // 24 hours
+ );
+
+ data.genres
+ .filter((genre) => !genre.name)
+ .forEach((genre) => {
+ genre.name =
+ englishData.genres.find(
+ (englishGenre) => englishGenre.id === genre.id
+ )?.name ?? '';
+ });
+ }
+
+ const tvGenres = sortBy(
+ data.genres.filter((genre) => genre.name),
+ 'name'
+ );
+
+ return tvGenres;
+ } catch (e) {
+ throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
}
}
}
diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts
index 1b0da07ec..bd3c2d8be 100644
--- a/server/api/themoviedb/interfaces.ts
+++ b/server/api/themoviedb/interfaces.ts
@@ -109,6 +109,16 @@ export interface TmdbExternalIds {
twitter_id?: string;
}
+export interface TmdbProductionCompany {
+ id: number;
+ logo_path?: string;
+ name: string;
+ origin_country: string;
+ homepage?: string;
+ headquarters?: string;
+ description?: string;
+}
+
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
@@ -125,12 +135,7 @@ export interface TmdbMovieDetails {
original_title: string;
overview?: string;
popularity: number;
- production_companies: {
- id: number;
- name: string;
- logo_path?: string;
- origin_country: string;
- }[];
+ production_companies: TmdbProductionCompany[];
production_countries: {
iso_3166_1: string;
name: string;
@@ -161,6 +166,10 @@ export interface TmdbMovieDetails {
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
+ 'watch/providers'?: {
+ id: number;
+ results?: { [iso_3166_1: string]: TmdbWatchProviders };
+ };
}
export interface TmdbVideo {
@@ -227,12 +236,7 @@ export interface TmdbTvDetails {
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
- networks: {
- id: number;
- name: string;
- logo_path: string;
- origin_country: string;
- }[];
+ networks: TmdbNetwork[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
@@ -254,6 +258,7 @@ export interface TmdbTvDetails {
}[];
seasons: TmdbTvSeasonResult[];
status: string;
+ tagline?: string;
type: string;
vote_average: number;
vote_count: number;
@@ -268,6 +273,10 @@ export interface TmdbTvDetails {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
+ 'watch/providers'?: {
+ id: number;
+ results?: { [iso_3166_1: string]: TmdbWatchProviders };
+ };
}
export interface TmdbVideoResult {
@@ -305,6 +314,7 @@ export interface TmdbKeyword {
export interface TmdbPersonDetail {
id: number;
name: string;
+ birthday: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
@@ -381,3 +391,34 @@ export interface TmdbLanguage {
english_name: string;
name: string;
}
+
+export interface TmdbGenresResult {
+ genres: TmdbGenre[];
+}
+
+export interface TmdbGenre {
+ id: number;
+ name: string;
+}
+
+export interface TmdbNetwork {
+ id: number;
+ name: string;
+ headquarters?: string;
+ homepage?: string;
+ logo_path?: string;
+ origin_country?: string;
+}
+
+export interface TmdbWatchProviders {
+ link?: string;
+ buy?: TmdbWatchProviderDetails[];
+ flatrate?: TmdbWatchProviderDetails[];
+}
+
+export interface TmdbWatchProviderDetails {
+ display_priority?: number;
+ logo_path?: string;
+ provider_id: number;
+ provider_name: string;
+}
diff --git a/server/entity/Media.ts b/server/entity/Media.ts
index e08a2c4ba..8d36afa7a 100644
--- a/server/entity/Media.ts
+++ b/server/entity/Media.ts
@@ -1,24 +1,24 @@
import {
- Entity,
- PrimaryGeneratedColumn,
+ AfterLoad,
Column,
- Index,
- OneToMany,
CreateDateColumn,
- UpdateDateColumn,
+ Entity,
getRepository,
In,
- AfterLoad,
+ Index,
+ OneToMany,
+ PrimaryGeneratedColumn,
+ UpdateDateColumn,
} from 'typeorm';
-import { MediaRequest } from './MediaRequest';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
-import logger from '../logger';
-import Season from './Season';
-import { getSettings } from '../lib/settings';
-import RadarrAPI from '../api/radarr';
-import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
-import SonarrAPI from '../api/sonarr';
import { MediaServerType } from '../constants/server';
+import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import { MediaRequest } from './MediaRequest';
+import Season from './Season';
@Entity()
class Media {
@@ -164,10 +164,10 @@ class Media {
}
} else {
if (this.jellyfinMediaId) {
- this.mediaUrl = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
+ this.mediaUrl = `${settings.jellyfin.hostname}/web/index.html#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
}
if (this.jellyfinMediaId4k) {
- this.mediaUrl4k = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
+ this.mediaUrl4k = `${settings.jellyfin.hostname}/web/index.html#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
}
}
}
@@ -184,10 +184,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
- : RadarrAPI.buildRadarrUrl(
- server,
- `/movie/${this.externalServiceSlug}`
- );
+ : RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
@@ -200,7 +197,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
- : RadarrAPI.buildRadarrUrl(
+ : RadarrAPI.buildUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
@@ -218,10 +215,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
- : SonarrAPI.buildSonarrUrl(
- server,
- `/series/${this.externalServiceSlug}`
- );
+ : SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`);
}
}
@@ -234,7 +228,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
- : SonarrAPI.buildSonarrUrl(
+ : SonarrAPI.buildUrl(
server,
`/series/${this.externalServiceSlug4k}`
);
diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts
index 658aee679..a935b13f1 100644
--- a/server/entity/MediaRequest.ts
+++ b/server/entity/MediaRequest.ts
@@ -1,27 +1,29 @@
+import { isEqual, truncate } from 'lodash';
import {
- Entity,
- PrimaryGeneratedColumn,
- ManyToOne,
+ AfterInsert,
+ AfterRemove,
+ AfterUpdate,
Column,
CreateDateColumn,
- UpdateDateColumn,
- AfterUpdate,
- AfterInsert,
+ Entity,
getRepository,
+ ManyToOne,
OneToMany,
- AfterRemove,
+ PrimaryGeneratedColumn,
+ RelationCount,
+ UpdateDateColumn,
} from 'typeorm';
-import { User } from './User';
-import Media from './Media';
-import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
-import { getSettings } from '../lib/settings';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
-import RadarrAPI from '../api/radarr';
-import logger from '../logger';
-import SeasonRequest from './SeasonRequest';
-import SonarrAPI, { SonarrSeries } from '../api/sonarr';
+import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import notificationManager, { Notification } from '../lib/notifications';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import Media from './Media';
+import SeasonRequest from './SeasonRequest';
+import { User } from './User';
@Entity()
export class MediaRequest {
@@ -60,6 +62,9 @@ export class MediaRequest {
@Column({ type: 'varchar' })
public type: MediaType;
+ @RelationCount((request: MediaRequest) => request.seasons)
+ public seasonCount: number;
+
@OneToMany(() => SeasonRequest, (season) => season.request, {
eager: true,
cascade: true,
@@ -81,6 +86,37 @@ export class MediaRequest {
@Column({ nullable: true })
public languageProfileId: number;
+ @Column({
+ type: 'text',
+ nullable: true,
+ transformer: {
+ from: (value: string | null): number[] | null => {
+ if (value) {
+ if (value === 'none') {
+ return [];
+ }
+ return value.split(',').map((v) => Number(v));
+ }
+ return null;
+ },
+ to: (value: number[] | null): string | null => {
+ if (value) {
+ const finalValue = value.join(',');
+
+ // We want to keep the actual state of an "empty array" so we use
+ // the keyword "none" to track this.
+ if (!finalValue) {
+ return 'none';
+ }
+
+ return finalValue;
+ }
+ return null;
+ },
+ },
+ })
+ public tags?: number[];
+
constructor(init?: Partial) {
Object.assign(this, init);
}
@@ -106,10 +142,15 @@ export class MediaRequest {
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
- subject: movie.title,
- message: movie.overview,
+ 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}`,
- notifyUser: this.requestedBy,
media,
request: this,
});
@@ -118,10 +159,15 @@ export class MediaRequest {
if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
- subject: tv.name,
- message: tv.overview,
+ 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}`,
- notifyUser: this.requestedBy,
media,
extra: [
{
@@ -144,7 +190,7 @@ export class MediaRequest {
* auto approved content
*/
@AfterUpdate()
- public async notifyApprovedOrDeclined(): Promise {
+ public async notifyApprovedOrDeclined(autoApproved = false): Promise {
if (
this.status === MediaRequestStatus.APPROVED ||
this.status === MediaRequestStatus.DECLINED
@@ -171,13 +217,21 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
- ? Notification.MEDIA_APPROVED
+ ? autoApproved
+ ? Notification.MEDIA_AUTO_APPROVED
+ : Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
- subject: movie.title,
- message: movie.overview,
+ 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}`,
- notifyUser: this.requestedBy,
+ notifyUser: autoApproved ? undefined : this.requestedBy,
media,
request: this,
}
@@ -186,13 +240,21 @@ export class MediaRequest {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
- ? Notification.MEDIA_APPROVED
+ ? autoApproved
+ ? Notification.MEDIA_AUTO_APPROVED
+ : Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
- subject: tv.name,
- message: tv.overview,
+ 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}`,
- notifyUser: this.requestedBy,
+ notifyUser: autoApproved ? undefined : this.requestedBy,
media,
extra: [
{
@@ -211,13 +273,8 @@ export class MediaRequest {
@AfterInsert()
public async autoapprovalNotification(): Promise {
- const settings = getSettings().notifications;
-
- if (
- settings.autoapprovalEnabled &&
- this.status === MediaRequestStatus.APPROVED
- ) {
- this.notifyApprovedOrDeclined();
+ if (this.status === MediaRequestStatus.APPROVED) {
+ this.notifyApprovedOrDeclined(true);
}
}
@@ -241,11 +298,7 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
) {
- if (this.is4k) {
- media.status4k = MediaStatus.PROCESSING;
- } else {
- media.status = MediaStatus.PROCESSING;
- }
+ media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
@@ -253,11 +306,7 @@ export class MediaRequest {
media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
- if (this.is4k) {
- media.status4k = MediaStatus.UNKNOWN;
- } else {
- media.status = MediaStatus.UNKNOWN;
- }
+ media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
@@ -273,9 +322,9 @@ export class MediaRequest {
media.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING
).length === 0 &&
- media.status === MediaStatus.PENDING
+ media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
) {
- media.status = MediaStatus.UNKNOWN;
+ media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
@@ -326,7 +375,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
- 'Skipped radarr request as there is no radarr configured',
+ 'Skipped Radarr request as there is no Radarr server configured',
{ label: 'Media Request' }
);
return;
@@ -354,7 +403,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
- }radarr configured. Did you set any of your Radarr servers as default?`,
+ }Radarr server configured. Did you set any of your ${
+ this.is4k ? '4K ' : ''
+ }Radarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -362,6 +413,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
+ let tags = radarrSettings.tags;
if (
this.rootFolder &&
@@ -384,10 +436,18 @@ export class MediaRequest {
});
}
+ if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
+ tags = this.tags;
+ logger.info(`Request has override tags`, {
+ label: 'Media Request',
+ tagIds: tags,
+ });
+ }
+
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@@ -417,6 +477,7 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
+ tags,
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
@@ -437,7 +498,7 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
- media.status = MediaStatus.UNKNOWN;
+ 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',
@@ -445,15 +506,16 @@ export class MediaRequest {
label: 'Media Request',
}
);
- const userRepository = getRepository(User);
- const admin = await userRepository.findOneOrFail({
- select: ['id', 'plexToken'],
- order: { id: 'ASC' },
- });
+
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
- subject: movie.title,
- message: 'Movie failed to add to Radarr',
- notifyUser: admin,
+ 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,
@@ -461,7 +523,7 @@ export class MediaRequest {
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
- const errorMessage = `Request failed to send to radarr: ${e.message}`;
+ const errorMessage = `Request failed to send to Radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
@@ -481,7 +543,7 @@ export class MediaRequest {
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.info(
- 'Skipped sonarr request as there is no sonarr configured',
+ 'Skipped Sonarr request as there is no Sonarr server configured',
{ label: 'Media Request' }
);
return;
@@ -509,7 +571,9 @@ export class MediaRequest {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
- }sonarr configured. Did you set any of your Sonarr servers as default?`,
+ }Sonarr server configured. Did you set any of your ${
+ this.is4k ? '4K ' : ''
+ }Sonarr servers as default?`,
{ label: 'Media Request' }
);
return;
@@ -533,7 +597,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
- url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
+ url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
@@ -570,6 +634,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
+ let tags =
+ seriesType === 'anime'
+ ? sonarrSettings.animeTags
+ : sonarrSettings.tags;
+
if (
this.rootFolder &&
this.rootFolder !== '' &&
@@ -601,6 +670,14 @@ export class MediaRequest {
);
}
+ if (this.tags && !isEqual(this.tags, tags)) {
+ tags = this.tags;
+ logger.info(`Request has override tags`, {
+ label: 'Media Request',
+ tagIds: tags,
+ });
+ }
+
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
@@ -612,6 +689,7 @@ export class MediaRequest {
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
+ tags,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})
@@ -634,7 +712,7 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
- media.status = MediaStatus.UNKNOWN;
+ 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',
@@ -642,14 +720,18 @@ export class MediaRequest {
label: 'Media Request',
}
);
- const userRepository = getRepository(User);
- const admin = await userRepository.findOneOrFail({
- order: { id: 'ASC' },
- });
+
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
- subject: series.name,
- message: 'Series failed to add to Sonarr',
- notifyUser: admin,
+ 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: [
@@ -665,7 +747,7 @@ export class MediaRequest {
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
- const errorMessage = `Request failed to send to sonarr: ${e.message}`;
+ const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
diff --git a/server/entity/User.ts b/server/entity/User.ts
index aa8240bb2..04fbf3753 100644
--- a/server/entity/User.ts
+++ b/server/entity/User.ts
@@ -1,28 +1,35 @@
+import bcrypt from 'bcrypt';
+import { randomUUID } from 'crypto';
+import path from 'path';
+import { default as generatePassword } from 'secure-random-password';
import {
- Entity,
- PrimaryGeneratedColumn,
+ AfterLoad,
Column,
CreateDateColumn,
- UpdateDateColumn,
+ Entity,
+ getRepository,
+ MoreThan,
+ Not,
OneToMany,
- RelationCount,
- AfterLoad,
OneToOne,
+ PrimaryGeneratedColumn,
+ RelationCount,
+ UpdateDateColumn,
} from 'typeorm';
+import { MediaRequestStatus, MediaType } from '../constants/media';
+import { UserType } from '../constants/user';
+import { QuotaResponse } from '../interfaces/api/userInterfaces';
+import PreparedEmail from '../lib/email';
import {
- Permission,
hasPermission,
+ Permission,
PermissionCheckOptions,
} from '../lib/permissions';
-import { MediaRequest } from './MediaRequest';
-import bcrypt from 'bcrypt';
-import path from 'path';
-import PreparedEmail from '../lib/email';
-import logger from '../logger';
import { getSettings } from '../lib/settings';
-import { default as generatePassword } from 'secure-random-password';
-import { UserType } from '../constants/user';
-import { v4 as uuid } from 'uuid';
+import logger from '../logger';
+import { MediaRequest } from './MediaRequest';
+import SeasonRequest from './SeasonRequest';
+import { UserPushSubscription } from './UserPushSubscription';
import { UserSettings } from './UserSettings';
@Entity()
@@ -41,11 +48,17 @@ export class User {
@PrimaryGeneratedColumn()
public id: number;
- @Column({ unique: true })
+ @Column({
+ unique: true,
+ transformer: {
+ from: (value: string): string => (value ?? '').toLowerCase(),
+ to: (value: string): string => (value ?? '').toLowerCase(),
+ },
+ })
public email: string;
@Column({ nullable: true })
- public plexUsername: string;
+ public plexUsername?: string;
@Column({ nullable: true })
public jellyfinUsername: string;
@@ -92,6 +105,18 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[];
+ @Column({ nullable: true })
+ public movieQuotaLimit?: number;
+
+ @Column({ nullable: true })
+ public movieQuotaDays?: number;
+
+ @Column({ nullable: true })
+ public tvQuotaLimit?: number;
+
+ @Column({ nullable: true })
+ public tvQuotaDays?: number;
+
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@@ -99,6 +124,9 @@ export class User {
})
public settings?: UserSettings;
+ @OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
+ public pushSubscriptions: UserPushSubscription[];
+
@CreateDateColumn()
public createdAt: Date;
@@ -151,7 +179,8 @@ export class User {
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
- const email = new PreparedEmail();
+
+ const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
@@ -172,7 +201,7 @@ export class User {
}
public async resetPassword(): Promise {
- const guid = uuid();
+ const guid = randomUUID();
this.resetPasswordGuid = guid;
// 24 hours into the future
@@ -187,7 +216,7 @@ export class User {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
- const email = new PreparedEmail();
+ const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
@@ -195,7 +224,7 @@ export class User {
},
locals: {
resetPasswordLink,
- applicationUrl: resetPasswordLink,
+ applicationUrl,
applicationTitle,
},
});
@@ -211,5 +240,104 @@ export class User {
public setDisplayName(): void {
this.displayName =
this.username || this.plexUsername || this.jellyfinUsername;
+ this.displayName = this.username || this.plexUsername || this.email;
+ }
+
+ public async getQuota(): Promise {
+ const {
+ main: { defaultQuotas },
+ } = getSettings();
+ const requestRepository = getRepository(MediaRequest);
+ const canBypass = this.hasPermission([Permission.MANAGE_USERS], {
+ type: 'or',
+ });
+
+ const movieQuotaLimit = !canBypass
+ ? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
+ : 0;
+ const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
+
+ // Count movie requests made during quota period
+ const movieDate = new Date();
+ if (movieQuotaDays) {
+ movieDate.setDate(movieDate.getDate() - movieQuotaDays);
+ }
+ const movieQuotaStartDate = movieDate.toJSON();
+
+ const movieQuotaUsed = movieQuotaLimit
+ ? await requestRepository.count({
+ where: {
+ requestedBy: this,
+ createdAt: MoreThan(movieQuotaStartDate),
+ type: MediaType.MOVIE,
+ status: Not(MediaRequestStatus.DECLINED),
+ },
+ })
+ : 0;
+
+ const tvQuotaLimit = !canBypass
+ ? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
+ : 0;
+ const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;
+
+ // Count tv season requests made during quota period
+ const tvDate = new Date();
+ if (tvQuotaDays) {
+ tvDate.setDate(tvDate.getDate() - tvQuotaDays);
+ }
+ const tvQuotaStartDate = tvDate.toJSON();
+ const tvQuotaUsed = tvQuotaLimit
+ ? (
+ await requestRepository
+ .createQueryBuilder('request')
+ .leftJoin('request.seasons', 'seasons')
+ .leftJoin('request.requestedBy', 'requestedBy')
+ .where('request.type = :requestType', {
+ requestType: MediaType.TV,
+ })
+ .andWhere('requestedBy.id = :userId', {
+ userId: this.id,
+ })
+ .andWhere('request.createdAt > :date', {
+ date: tvQuotaStartDate,
+ })
+ .andWhere('request.status != :declinedStatus', {
+ declinedStatus: MediaRequestStatus.DECLINED,
+ })
+ .addSelect((subQuery) => {
+ return subQuery
+ .select('COUNT(season.id)', 'seasonCount')
+ .from(SeasonRequest, 'season')
+ .leftJoin('season.request', 'parentRequest')
+ .where('parentRequest.id = request.id');
+ }, 'seasonCount')
+ .getMany()
+ ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
+ : 0;
+
+ return {
+ movie: {
+ days: movieQuotaDays,
+ limit: movieQuotaLimit,
+ used: movieQuotaUsed,
+ remaining: movieQuotaLimit
+ ? Math.max(0, movieQuotaLimit - movieQuotaUsed)
+ : undefined,
+ restricted:
+ movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0
+ ? true
+ : false,
+ },
+ tv: {
+ days: tvQuotaDays,
+ limit: tvQuotaLimit,
+ used: tvQuotaUsed,
+ remaining: tvQuotaLimit
+ ? Math.max(0, tvQuotaLimit - tvQuotaUsed)
+ : undefined,
+ restricted:
+ tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
+ },
+ };
}
}
diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts
new file mode 100644
index 000000000..6389ea0b8
--- /dev/null
+++ b/server/entity/UserPushSubscription.ts
@@ -0,0 +1,27 @@
+import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { User } from './User';
+
+@Entity()
+export class UserPushSubscription {
+ @PrimaryGeneratedColumn()
+ public id: number;
+
+ @ManyToOne(() => User, (user) => user.pushSubscriptions, {
+ eager: true,
+ onDelete: 'CASCADE',
+ })
+ public user: User;
+
+ @Column()
+ public endpoint: string;
+
+ @Column()
+ public p256dh: string;
+
+ @Column({ unique: true })
+ public auth: string;
+
+ constructor(init?: Partial) {
+ Object.assign(this, init);
+ }
+}
diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts
index 163de1346..02f391112 100644
--- a/server/entity/UserSettings.ts
+++ b/server/entity/UserSettings.ts
@@ -5,8 +5,15 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
+import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
+import { hasNotificationType, Notification } from '../lib/notifications';
+import { NotificationAgentKey } from '../lib/settings';
import { User } from './User';
+export const ALL_NOTIFICATIONS = Object.values(Notification)
+ .filter((v) => !isNaN(Number(v)))
+ .reduce((a, v) => a + Number(v), 0);
+
@Entity()
export class UserSettings {
constructor(init?: Partial) {
@@ -20,15 +27,91 @@ export class UserSettings {
@JoinColumn()
public user: User;
- @Column({ default: true })
- public enableNotifications: boolean;
-
- @Column({ nullable: true })
- public discordId?: string;
+ @Column({ default: '' })
+ public locale?: string;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
+
+ @Column({ nullable: true })
+ public pgpKey?: string;
+
+ @Column({ nullable: true })
+ public discordId?: string;
+
+ @Column({ nullable: true })
+ public telegramChatId?: string;
+
+ @Column({ nullable: true })
+ public telegramSendSilently?: boolean;
+
+ @Column({
+ type: 'text',
+ nullable: true,
+ transformer: {
+ from: (value: string | null): Partial => {
+ const defaultTypes = {
+ email: ALL_NOTIFICATIONS,
+ discord: 0,
+ pushbullet: 0,
+ pushover: 0,
+ slack: 0,
+ telegram: 0,
+ webhook: 0,
+ webpush: ALL_NOTIFICATIONS,
+ };
+ if (!value) {
+ return defaultTypes;
+ }
+
+ const values = JSON.parse(value) as Partial;
+
+ // Something with the migration to this field has caused some issue where
+ // the value pre-populates with just a raw "2"? Here we check if that's the case
+ // and return the default notification types if so
+ if (typeof values !== 'object') {
+ return defaultTypes;
+ }
+
+ if (values.email == null) {
+ values.email = ALL_NOTIFICATIONS;
+ }
+
+ if (values.webpush == null) {
+ values.webpush = ALL_NOTIFICATIONS;
+ }
+
+ return values;
+ },
+ to: (value: Partial): string | null => {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const allowedKeys = Object.values(NotificationAgentKey);
+
+ // Remove any unknown notification agent keys before saving to db
+ (Object.keys(value) as (keyof NotificationAgentTypes)[]).forEach(
+ (key) => {
+ if (!allowedKeys.includes(key)) {
+ delete value[key];
+ }
+ }
+ );
+
+ return JSON.stringify(value);
+ },
+ },
+ })
+ public notificationTypes: Partial;
+
+ public hasNotificationType(
+ key: NotificationAgentKey,
+ type: Notification
+ ): boolean {
+ return hasNotificationType(type, this.notificationTypes[key] ?? 0);
+ }
}
diff --git a/server/index.ts b/server/index.ts
index 3cfd0dba3..f85b02752 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,30 +1,33 @@
-import express, { Request, Response, NextFunction } from 'express';
-import next from 'next';
-import path from 'path';
-import { createConnection, getRepository } from 'typeorm';
-import routes from './routes';
-import bodyParser from 'body-parser';
+import { getClientIp } from '@supercharge/request-ip';
+import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
-import session, { Store } from 'express-session';
-import { TypeormStore } from 'connect-typeorm/out';
-import YAML from 'yamljs';
-import swaggerUi from 'swagger-ui-express';
+import express, { NextFunction, Request, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
+import session, { Store } from 'express-session';
+import next from 'next';
+import path from 'path';
+import swaggerUi from 'swagger-ui-express';
+import { createConnection, getRepository } from 'typeorm';
+import YAML from 'yamljs';
+import PlexAPI from './api/plexapi';
import { Session } from './entity/Session';
-import { getSettings } from './lib/settings';
-import logger from './logger';
+import { User } from './entity/User';
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 TelegramAgent from './lib/notifications/agents/telegram';
-import { getAppVersion } from './utils/appVersion';
-import SlackAgent from './lib/notifications/agents/slack';
-import PushoverAgent from './lib/notifications/agents/pushover';
-import WebhookAgent from './lib/notifications/agents/webhook';
-import { getClientIp } from '@supercharge/request-ip';
+import LunaSeaAgent from './lib/notifications/agents/lunasea';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
+import PushoverAgent from './lib/notifications/agents/pushover';
+import SlackAgent from './lib/notifications/agents/slack';
+import TelegramAgent from './lib/notifications/agents/telegram';
+import WebhookAgent from './lib/notifications/agents/webhook';
+import WebPushAgent from './lib/notifications/agents/webpush';
+import { getSettings } from './lib/settings';
+import logger from './logger';
+import routes from './routes';
+import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -48,15 +51,37 @@ app
// Load Settings
const settings = getSettings().load();
+ // Migrate library types
+ if (
+ settings.plex.libraries.length > 1 &&
+ !settings.plex.libraries[0].type
+ ) {
+ const userRepository = getRepository(User);
+ const admin = await userRepository.findOne({
+ select: ['id', 'plexToken'],
+ order: { id: 'ASC' },
+ });
+
+ if (admin) {
+ const plexapi = new PlexAPI({ plexToken: admin.plexToken });
+ await plexapi.syncLibraries();
+ logger.info('Migrating libraries to include media type', {
+ label: 'Settings',
+ });
+ }
+ }
+
// Register Notification Agents
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
+ new LunaSeaAgent(),
new PushbulletAgent(),
new PushoverAgent(),
new SlackAgent(),
new TelegramAgent(),
new WebhookAgent(),
+ new WebPushAgent(),
]);
// Start Jobs
@@ -67,9 +92,9 @@ app
server.enable('trust proxy');
}
server.use(cookieParser());
- server.use(bodyParser.json());
- server.use(bodyParser.urlencoded({ extended: true }));
- server.use((req, res, next) => {
+ server.use(express.json());
+ server.use(express.urlencoded({ extended: true }));
+ server.use((req, _res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {
diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts
new file mode 100644
index 000000000..db90e55d2
--- /dev/null
+++ b/server/interfaces/api/discoverInterfaces.ts
@@ -0,0 +1,5 @@
+export interface GenreSliderItem {
+ id: number;
+ name: string;
+ backdrops: string[];
+}
diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts
index 42ec9cb4a..5373cb58a 100644
--- a/server/interfaces/api/plexInterfaces.ts
+++ b/server/interfaces/api/plexInterfaces.ts
@@ -14,7 +14,6 @@ export interface PlexConnection {
local: boolean;
status?: number;
message?: string;
- host?: string;
}
export interface PlexDevice {
diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts
index 3bfa289eb..1188f24c0 100644
--- a/server/interfaces/api/serviceInterfaces.ts
+++ b/server/interfaces/api/serviceInterfaces.ts
@@ -1,5 +1,5 @@
-import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
-import { LanguageProfile } from '../../api/sonarr';
+import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
+import { LanguageProfile } from '../../api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;
@@ -12,11 +12,14 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
+ activeTags: number[];
+ activeAnimeTags?: number[];
}
export interface ServiceCommonServerWithDetails {
server: ServiceCommonServer;
- profiles: RadarrProfile[];
- rootFolders: Partial[];
+ profiles: QualityProfile[];
+ rootFolders: Partial[];
languageProfiles?: LanguageProfile[];
+ tags: Tag[];
}
diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts
index 4efd6f6dd..0bb24780e 100644
--- a/server/interfaces/api/settingsInterfaces.ts
+++ b/server/interfaces/api/settingsInterfaces.ts
@@ -1,3 +1,17 @@
+import type { PaginatedResponse } from './common';
+
+export type LogMessage = {
+ timestamp: string;
+ level: string;
+ label: string;
+ message: string;
+ data?: Record;
+};
+
+export interface LogsResultsResponse extends PaginatedResponse {
+ results: LogMessage[];
+}
+
export interface SettingsAboutResponse {
version: string;
totalRequests: number;
@@ -10,6 +24,7 @@ export interface PublicSettingsResponse {
jellyfinServerName?: string;
initialized: boolean;
applicationTitle: string;
+ applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
@@ -17,6 +32,12 @@ export interface PublicSettingsResponse {
region: string;
originalLanguage: string;
mediaServerType: number;
+ partialRequestsEnabled: boolean;
+ cacheImages: boolean;
+ vapidPublic: string;
+ enablePushRegistration: boolean;
+ locale: string;
+ emailEnabled: boolean;
}
export interface CacheItem {
@@ -30,3 +51,10 @@ export interface CacheItem {
vsize: number;
};
}
+
+export interface StatusResponse {
+ version: string;
+ commitTag: string;
+ updateAvailable: boolean;
+ commitsBehind: number;
+}
diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts
index 259455dc9..facacd54c 100644
--- a/server/interfaces/api/userInterfaces.ts
+++ b/server/interfaces/api/userInterfaces.ts
@@ -1,5 +1,5 @@
-import type { User } from '../../entity/User';
import { MediaRequest } from '../../entity/MediaRequest';
+import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
@@ -9,3 +9,16 @@ export interface UserResultsResponse extends PaginatedResponse {
export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[];
}
+
+export interface QuotaStatus {
+ days?: number;
+ limit?: number;
+ used: number;
+ remaining?: number;
+ restricted: boolean;
+}
+
+export interface QuotaResponse {
+ movie: QuotaStatus;
+ tv: QuotaStatus;
+}
diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts
index 023b76315..18e3c7aba 100644
--- a/server/interfaces/api/userSettingsInterfaces.ts
+++ b/server/interfaces/api/userSettingsInterfaces.ts
@@ -1,10 +1,31 @@
+import { NotificationAgentKey } from '../../lib/settings';
+
export interface UserSettingsGeneralResponse {
username?: string;
+ locale?: string;
region?: string;
originalLanguage?: string;
+ movieQuotaLimit?: number;
+ movieQuotaDays?: number;
+ tvQuotaLimit?: number;
+ tvQuotaDays?: number;
+ globalMovieQuotaDays?: number;
+ globalMovieQuotaLimit?: number;
+ globalTvQuotaLimit?: number;
+ globalTvQuotaDays?: number;
}
+export type NotificationAgentTypes = Record;
export interface UserSettingsNotificationsResponse {
- enableNotifications: boolean;
+ emailEnabled?: boolean;
+ pgpKey?: string;
+ discordEnabled?: boolean;
+ discordEnabledTypes?: number;
discordId?: string;
+ telegramEnabled?: boolean;
+ telegramBotUsername?: string;
+ telegramChatId?: string;
+ telegramSendSilently?: boolean;
+ webPushEnabled?: boolean;
+ notificationTypes: Partial;
}
diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts
deleted file mode 100644
index d0ac1cc06..000000000
--- a/server/job/plexsync/index.ts
+++ /dev/null
@@ -1,924 +0,0 @@
-import { getRepository } from 'typeorm';
-import { User } from '../../entity/User';
-import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
-import TheMovieDb from '../../api/themoviedb';
-import {
- TmdbMovieDetails,
- TmdbTvDetails,
-} from '../../api/themoviedb/interfaces';
-import Media from '../../entity/Media';
-import { MediaStatus, MediaType } from '../../constants/media';
-import logger from '../../logger';
-import { getSettings, Library } from '../../lib/settings';
-import Season from '../../entity/Season';
-import { uniqWith } from 'lodash';
-import { v4 as uuid } from 'uuid';
-import animeList from '../../api/animelist';
-import AsyncLock from '../../utils/asyncLock';
-import { MediaServerType } from '../../constants/server';
-
-const BUNDLE_SIZE = 20;
-const UPDATE_RATE = 4 * 1000;
-
-const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
-const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
-const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
-const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
-const plexRegex = new RegExp(/plex:\/\//);
-// Hama agent uses ASS naming, see details here:
-// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
-const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
-const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
-const HAMA_AGENT = 'com.plexapp.agents.hama';
-
-interface SyncStatus {
- running: boolean;
- progress: number;
- total: number;
- currentLibrary: Library;
- libraries: Library[];
-}
-
-class JobPlexSync {
- private sessionId: string;
- private tmdb: TheMovieDb;
- private plexClient: PlexAPI;
- private items: PlexLibraryItem[] = [];
- private progress = 0;
- private libraries: Library[];
- private currentLibrary: Library;
- private running = false;
- private isRecentOnly = false;
- private enable4kMovie = false;
- private enable4kShow = false;
- private asyncLock = new AsyncLock();
-
- constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
- this.tmdb = new TheMovieDb();
- this.isRecentOnly = isRecentOnly ?? false;
- }
-
- private async getExisting(tmdbId: number, mediaType: MediaType) {
- const mediaRepository = getRepository(Media);
-
- const existing = await mediaRepository.findOne({
- where: { tmdbId: tmdbId, mediaType },
- });
-
- return existing;
- }
-
- private async processMovie(plexitem: PlexLibraryItem) {
- const mediaRepository = getRepository(Media);
-
- try {
- if (plexitem.guid.match(plexRegex)) {
- const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
- const newMedia = new Media();
-
- if (!metadata.Guid) {
- logger.debug('No Guid metadata for this title. Skipping', {
- label: 'Plex Sync',
- ratingKey: plexitem.ratingKey,
- });
- return;
- }
-
- metadata.Guid.forEach((ref) => {
- if (ref.id.match(imdbRegex)) {
- newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
- } else if (ref.id.match(tmdbRegex)) {
- const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
- newMedia.tmdbId = Number(tmdbMatch);
- }
- });
- if (newMedia.imdbId && !newMedia.tmdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
- imdbId: newMedia.imdbId,
- });
- newMedia.tmdbId = tmdbMovie.id;
- }
- if (!newMedia.tmdbId) {
- throw new Error('Unable to find TMDb ID');
- }
-
- const has4k = metadata.Media.some(
- (media) => media.videoResolution === '4k'
- );
- const hasOtherResolution = metadata.Media.some(
- (media) => media.videoResolution !== '4k'
- );
-
- await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
- const existing = await this.getExisting(
- newMedia.tmdbId,
- MediaType.MOVIE
- );
-
- if (existing) {
- let changedExisting = false;
-
- if (
- (hasOtherResolution || (!this.enable4kMovie && has4k)) &&
- existing.status !== MediaStatus.AVAILABLE
- ) {
- existing.status = MediaStatus.AVAILABLE;
- existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- changedExisting = true;
- }
-
- if (
- has4k &&
- this.enable4kMovie &&
- existing.status4k !== MediaStatus.AVAILABLE
- ) {
- existing.status4k = MediaStatus.AVAILABLE;
- changedExisting = true;
- }
-
- if (!existing.mediaAddedAt && !changedExisting) {
- existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- changedExisting = true;
- }
-
- if (
- (hasOtherResolution || (has4k && !this.enable4kMovie)) &&
- existing.ratingKey !== plexitem.ratingKey
- ) {
- existing.ratingKey = plexitem.ratingKey;
- changedExisting = true;
- }
-
- if (
- has4k &&
- this.enable4kMovie &&
- existing.ratingKey4k !== plexitem.ratingKey
- ) {
- existing.ratingKey4k = plexitem.ratingKey;
- changedExisting = true;
- }
-
- if (changedExisting) {
- await mediaRepository.save(existing);
- this.log(
- `Request for ${metadata.title} exists. New media types set to AVAILABLE`,
- 'info'
- );
- } else {
- this.log(
- `Title already exists and no new media types found ${metadata.title}`
- );
- }
- } else {
- newMedia.status =
- hasOtherResolution || (!this.enable4kMovie && has4k)
- ? MediaStatus.AVAILABLE
- : MediaStatus.UNKNOWN;
- newMedia.status4k =
- has4k && this.enable4kMovie
- ? MediaStatus.AVAILABLE
- : MediaStatus.UNKNOWN;
- newMedia.mediaType = MediaType.MOVIE;
- newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- newMedia.ratingKey =
- hasOtherResolution || (!this.enable4kMovie && has4k)
- ? plexitem.ratingKey
- : undefined;
- newMedia.ratingKey4k =
- has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
- await mediaRepository.save(newMedia);
- this.log(`Saved ${plexitem.title}`);
- }
- });
- } else {
- let tmdbMovieId: number | undefined;
- let tmdbMovie: TmdbMovieDetails | undefined;
-
- const imdbMatch = plexitem.guid.match(imdbRegex);
- const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
-
- if (imdbMatch) {
- tmdbMovie = await this.tmdb.getMovieByImdbId({
- imdbId: imdbMatch[1],
- });
- tmdbMovieId = tmdbMovie.id;
- } else if (tmdbMatch) {
- tmdbMovieId = Number(tmdbMatch[1]);
- }
-
- if (!tmdbMovieId) {
- throw new Error('Unable to find TMDb ID');
- }
-
- await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
- }
- } catch (e) {
- this.log(
- `Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`,
- 'error',
- {
- errorMessage: e.message,
- plexitem,
- }
- );
- }
- }
-
- private async processMovieWithId(
- plexitem: PlexLibraryItem,
- tmdbMovie: TmdbMovieDetails | undefined,
- tmdbMovieId: number
- ) {
- const mediaRepository = getRepository(Media);
-
- await this.asyncLock.dispatch(tmdbMovieId, async () => {
- const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
- const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
-
- const has4k = metadata.Media.some(
- (media) => media.videoResolution === '4k'
- );
- const hasOtherResolution = metadata.Media.some(
- (media) => media.videoResolution !== '4k'
- );
-
- if (existing) {
- let changedExisting = false;
-
- if (
- (hasOtherResolution || (!this.enable4kMovie && has4k)) &&
- existing.status !== MediaStatus.AVAILABLE
- ) {
- existing.status = MediaStatus.AVAILABLE;
- existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- changedExisting = true;
- }
-
- if (
- has4k &&
- this.enable4kMovie &&
- existing.status4k !== MediaStatus.AVAILABLE
- ) {
- existing.status4k = MediaStatus.AVAILABLE;
- changedExisting = true;
- }
-
- if (!existing.mediaAddedAt && !changedExisting) {
- existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- changedExisting = true;
- }
-
- if (
- (hasOtherResolution || (has4k && !this.enable4kMovie)) &&
- existing.ratingKey !== plexitem.ratingKey
- ) {
- existing.ratingKey = plexitem.ratingKey;
- changedExisting = true;
- }
-
- if (
- has4k &&
- this.enable4kMovie &&
- existing.ratingKey4k !== plexitem.ratingKey
- ) {
- existing.ratingKey4k = plexitem.ratingKey;
- changedExisting = true;
- }
-
- if (changedExisting) {
- await mediaRepository.save(existing);
- this.log(
- `Request for ${metadata.title} exists. New media types set to AVAILABLE`,
- 'info'
- );
- } else {
- this.log(
- `Title already exists and no new media types found ${metadata.title}`
- );
- }
- } else {
- // If we have a tmdb movie guid but it didn't already exist, only then
- // do we request the movie from tmdb (to reduce api requests)
- if (!tmdbMovie) {
- tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
- }
- const newMedia = new Media();
- newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
- newMedia.tmdbId = tmdbMovie.id;
- newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- newMedia.status =
- hasOtherResolution || (!this.enable4kMovie && has4k)
- ? MediaStatus.AVAILABLE
- : MediaStatus.UNKNOWN;
- newMedia.status4k =
- has4k && this.enable4kMovie
- ? MediaStatus.AVAILABLE
- : MediaStatus.UNKNOWN;
- newMedia.mediaType = MediaType.MOVIE;
- newMedia.ratingKey =
- hasOtherResolution || (!this.enable4kMovie && has4k)
- ? plexitem.ratingKey
- : undefined;
- newMedia.ratingKey4k =
- has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
- await mediaRepository.save(newMedia);
- this.log(`Saved ${tmdbMovie.title}`);
- }
- });
- }
-
- // this adds all movie episodes from specials season for Hama agent
- private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
- const specials = metadata.Children?.Metadata.find(
- (md) => Number(md.index) === 0
- );
- if (specials) {
- const episodes = await this.plexClient.getChildrenMetadata(
- specials.ratingKey
- );
- if (episodes) {
- for (const episode of episodes) {
- const special = animeList.getSpecialEpisode(tvdbId, episode.index);
- if (special) {
- if (special.tmdbId) {
- await this.processMovieWithId(episode, undefined, special.tmdbId);
- } else if (special.imdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
- imdbId: special.imdbId,
- });
- await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id);
- }
- }
- }
- }
- }
- }
-
- // movies with hama agent actually are tv shows with at least one episode in it
- // try to get first episode of any season - cannot hardcode season or episode number
- // because sometimes user can have it in other season/ep than s01e01
- private async processHamaMovie(
- metadata: PlexMetadata,
- tmdbMovie: TmdbMovieDetails | undefined,
- tmdbMovieId: number
- ) {
- const season = metadata.Children?.Metadata[0];
- if (season) {
- const episodes = await this.plexClient.getChildrenMetadata(
- season.ratingKey
- );
- if (episodes) {
- await this.processMovieWithId(episodes[0], tmdbMovie, tmdbMovieId);
- }
- }
- }
-
- private async processShow(plexitem: PlexLibraryItem) {
- const mediaRepository = getRepository(Media);
-
- let tvShow: TmdbTvDetails | null = null;
-
- try {
- const ratingKey =
- plexitem.grandparentRatingKey ??
- plexitem.parentRatingKey ??
- plexitem.ratingKey;
- const metadata = await this.plexClient.getMetadata(ratingKey, {
- includeChildren: true,
- });
-
- if (metadata.guid.match(tvdbRegex)) {
- const matchedtvdb = metadata.guid.match(tvdbRegex);
-
- // If we can find a tvdb Id, use it to get the full tmdb show details
- if (matchedtvdb?.[1]) {
- tvShow = await this.tmdb.getShowByTvdbId({
- tvdbId: Number(matchedtvdb[1]),
- });
- }
- } else if (metadata.guid.match(tmdbShowRegex)) {
- const matchedtmdb = metadata.guid.match(tmdbShowRegex);
-
- if (matchedtmdb?.[1]) {
- tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
- }
- } else if (metadata.guid.match(hamaTvdbRegex)) {
- const matched = metadata.guid.match(hamaTvdbRegex);
- const tvdbId = matched?.[1];
-
- if (tvdbId) {
- tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) });
- if (animeList.isLoaded()) {
- await this.processHamaSpecials(metadata, Number(tvdbId));
- } else {
- this.log(
- `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
- 'warn'
- );
- }
- }
- } else if (metadata.guid.match(hamaAnidbRegex)) {
- const matched = metadata.guid.match(hamaAnidbRegex);
-
- if (!animeList.isLoaded()) {
- this.log(
- `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
- 'warn'
- );
- } else if (matched?.[1]) {
- const anidbId = Number(matched[1]);
- const result = animeList.getFromAnidbId(anidbId);
-
- // first try to lookup tvshow by tvdbid
- if (result?.tvdbId) {
- const extResponse = await this.tmdb.getByExternalId({
- externalId: result.tvdbId,
- type: 'tvdb',
- });
- if (extResponse.tv_results[0]) {
- tvShow = await this.tmdb.getTvShow({
- tvId: extResponse.tv_results[0].id,
- });
- } else {
- this.log(
- `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
- );
- }
- await this.processHamaSpecials(metadata, result.tvdbId);
- }
-
- if (!tvShow) {
- // if lookup of tvshow above failed, then try movie with tmdbid/imdbid
- // note - some tv shows have imdbid set too, that's why this need to go second
- if (result?.tmdbId) {
- return await this.processHamaMovie(
- metadata,
- undefined,
- result.tmdbId
- );
- } else if (result?.imdbId) {
- const tmdbMovie = await this.tmdb.getMovieByImdbId({
- imdbId: result.imdbId,
- });
- return await this.processHamaMovie(
- metadata,
- tmdbMovie,
- tmdbMovie.id
- );
- }
- }
- }
- }
-
- if (tvShow) {
- await this.asyncLock.dispatch(tvShow.id, async () => {
- if (!tvShow) {
- // this will never execute, but typescript thinks somebody could reset tvShow from
- // outer scope back to null before this async gets called
- return;
- }
-
- // Lets get the available seasons from Plex
- const seasons = tvShow.seasons;
- const media = await this.getExisting(tvShow.id, MediaType.TV);
-
- const newSeasons: Season[] = [];
-
- const currentStandardSeasonAvailable = (
- media?.seasons.filter(
- (season) => season.status === MediaStatus.AVAILABLE
- ) ?? []
- ).length;
- const current4kSeasonAvailable = (
- media?.seasons.filter(
- (season) => season.status4k === MediaStatus.AVAILABLE
- ) ?? []
- ).length;
-
- for (const season of seasons) {
- const matchedPlexSeason = metadata.Children?.Metadata.find(
- (md) => Number(md.index) === season.season_number
- );
-
- const existingSeason = media?.seasons.find(
- (es) => es.seasonNumber === season.season_number
- );
-
- // Check if we found the matching season and it has all the available episodes
- if (matchedPlexSeason) {
- // If we have a matched Plex season, get its children metadata so we can check details
- const episodes = await this.plexClient.getChildrenMetadata(
- matchedPlexSeason.ratingKey
- );
- // Total episodes that are in standard definition (not 4k)
- const totalStandard = episodes.filter((episode) =>
- !this.enable4kShow
- ? true
- : episode.Media.some(
- (media) => media.videoResolution !== '4k'
- )
- ).length;
-
- // Total episodes that are in 4k
- const total4k = episodes.filter((episode) =>
- episode.Media.some((media) => media.videoResolution === '4k')
- ).length;
-
- if (
- media &&
- (totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
- media.ratingKey !== ratingKey
- ) {
- media.ratingKey = ratingKey;
- }
-
- if (
- media &&
- total4k > 0 &&
- this.enable4kShow &&
- media.ratingKey4k !== ratingKey
- ) {
- media.ratingKey4k = ratingKey;
- }
-
- if (existingSeason) {
- // These ternary statements look super confusing, but they are simply
- // setting the status to AVAILABLE if all of a type is there, partially if some,
- // and then not modifying the status if there are 0 items.
- // If the season was already available, we don't modify it as well.
- existingSeason.status =
- totalStandard === season.episode_count ||
- existingSeason.status === MediaStatus.AVAILABLE
- ? MediaStatus.AVAILABLE
- : totalStandard > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : existingSeason.status;
- existingSeason.status4k =
- (this.enable4kShow && total4k === season.episode_count) ||
- existingSeason.status4k === MediaStatus.AVAILABLE
- ? MediaStatus.AVAILABLE
- : this.enable4kShow && total4k > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : existingSeason.status4k;
- } else {
- newSeasons.push(
- new Season({
- seasonNumber: season.season_number,
- // This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
- // if we dont have any items for the season
- status:
- totalStandard === season.episode_count
- ? MediaStatus.AVAILABLE
- : totalStandard > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN,
- status4k:
- this.enable4kShow && total4k === season.episode_count
- ? MediaStatus.AVAILABLE
- : this.enable4kShow && total4k > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN,
- })
- );
- }
- }
- }
-
- // Remove extras season. We dont count it for determining availability
- const filteredSeasons = tvShow.seasons.filter(
- (season) => season.season_number !== 0
- );
-
- const isAllStandardSeasons =
- newSeasons.filter(
- (season) => season.status === MediaStatus.AVAILABLE
- ).length +
- (media?.seasons.filter(
- (season) => season.status === MediaStatus.AVAILABLE
- ).length ?? 0) >=
- filteredSeasons.length;
-
- const isAll4kSeasons =
- newSeasons.filter(
- (season) => season.status4k === MediaStatus.AVAILABLE
- ).length +
- (media?.seasons.filter(
- (season) => season.status4k === MediaStatus.AVAILABLE
- ).length ?? 0) >=
- filteredSeasons.length;
-
- if (media) {
- // Update existing
- media.seasons = [...media.seasons, ...newSeasons];
-
- const newStandardSeasonAvailable = (
- media.seasons.filter(
- (season) => season.status === MediaStatus.AVAILABLE
- ) ?? []
- ).length;
-
- const new4kSeasonAvailable = (
- media.seasons.filter(
- (season) => season.status4k === MediaStatus.AVAILABLE
- ) ?? []
- ).length;
-
- // If at least one new season has become available, update
- // the lastSeasonChange field so we can trigger notifications
- if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
- this.log(
- `Detected ${
- newStandardSeasonAvailable - currentStandardSeasonAvailable
- } new standard season(s) for ${tvShow.name}`,
- 'debug'
- );
- media.lastSeasonChange = new Date();
- media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- }
-
- if (new4kSeasonAvailable > current4kSeasonAvailable) {
- this.log(
- `Detected ${
- new4kSeasonAvailable - current4kSeasonAvailable
- } new 4K season(s) for ${tvShow.name}`,
- 'debug'
- );
- media.lastSeasonChange = new Date();
- }
-
- if (!media.mediaAddedAt) {
- media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
- }
-
- // If the show is already available, and there are no new seasons, dont adjust
- // the status
- const shouldStayAvailable =
- media.status === MediaStatus.AVAILABLE &&
- newSeasons.filter(
- (season) => season.status !== MediaStatus.UNKNOWN
- ).length === 0;
- const shouldStayAvailable4k =
- media.status4k === MediaStatus.AVAILABLE &&
- newSeasons.filter(
- (season) => season.status4k !== MediaStatus.UNKNOWN
- ).length === 0;
-
- media.status =
- isAllStandardSeasons || shouldStayAvailable
- ? MediaStatus.AVAILABLE
- : media.seasons.some(
- (season) => season.status !== MediaStatus.UNKNOWN
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN;
- media.status4k =
- (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
- ? MediaStatus.AVAILABLE
- : this.enable4kShow &&
- media.seasons.some(
- (season) => season.status4k !== MediaStatus.UNKNOWN
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN;
- await mediaRepository.save(media);
- this.log(`Updating existing title: ${tvShow.name}`);
- } else {
- const newMedia = new Media({
- mediaType: MediaType.TV,
- seasons: newSeasons,
- tmdbId: tvShow.id,
- tvdbId: tvShow.external_ids.tvdb_id,
- mediaAddedAt: new Date(plexitem.addedAt * 1000),
- status: isAllStandardSeasons
- ? MediaStatus.AVAILABLE
- : newSeasons.some(
- (season) => season.status !== MediaStatus.UNKNOWN
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN,
- status4k:
- isAll4kSeasons && this.enable4kShow
- ? MediaStatus.AVAILABLE
- : this.enable4kShow &&
- newSeasons.some(
- (season) => season.status4k !== MediaStatus.UNKNOWN
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : MediaStatus.UNKNOWN,
- });
- await mediaRepository.save(newMedia);
- this.log(`Saved ${tvShow.name}`);
- }
- });
- } else {
- this.log(`failed show: ${plexitem.guid}`);
- }
- } catch (e) {
- this.log(
- `Failed to process Plex item. ratingKey: ${
- plexitem.grandparentRatingKey ??
- plexitem.parentRatingKey ??
- plexitem.ratingKey
- }`,
- 'error',
- {
- errorMessage: e.message,
- plexitem,
- }
- );
- }
- }
-
- private async processItems(slicedItems: PlexLibraryItem[]) {
- await Promise.all(
- slicedItems.map(async (plexitem) => {
- if (plexitem.type === 'movie') {
- await this.processMovie(plexitem);
- } else if (
- plexitem.type === 'show' ||
- plexitem.type === 'episode' ||
- plexitem.type === 'season'
- ) {
- await this.processShow(plexitem);
- }
- })
- );
- }
-
- private async loop({
- start = 0,
- end = BUNDLE_SIZE,
- sessionId,
- }: {
- start?: number;
- end?: number;
- sessionId?: string;
- } = {}) {
- const slicedItems = this.items.slice(start, end);
-
- if (!this.running) {
- throw new Error('Sync was aborted.');
- }
-
- if (this.sessionId !== sessionId) {
- throw new Error('New session was started. Old session aborted.');
- }
-
- if (start < this.items.length) {
- this.progress = start;
- await this.processItems(slicedItems);
-
- await new Promise((resolve, reject) =>
- setTimeout(() => {
- this.loop({
- start: start + BUNDLE_SIZE,
- end: end + BUNDLE_SIZE,
- sessionId,
- })
- .then(() => resolve())
- .catch((e) => reject(new Error(e.message)));
- }, UPDATE_RATE)
- );
- }
- }
-
- private log(
- message: string,
- level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
- optional?: Record
- ): void {
- logger[level](message, { label: 'Plex Sync', ...optional });
- }
-
- // checks if any of this.libraries has Hama agent set in Plex
- private async hasHamaAgent() {
- const plexLibraries = await this.plexClient.getLibraries();
- return this.libraries.some((library) =>
- plexLibraries.some(
- (plexLibrary) =>
- plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
- )
- );
- }
-
- public async run(): Promise {
- const settings = getSettings();
-
- if (settings.main.mediaServerType != MediaServerType.PLEX) {
- return;
- }
-
- const sessionId = uuid();
- this.sessionId = sessionId;
- logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' });
- try {
- this.running = true;
- const userRepository = getRepository(User);
- const admin = await userRepository.findOne({
- select: ['id', 'plexToken'],
- order: { id: 'ASC' },
- });
-
- if (!admin) {
- return this.log('No admin configured. Plex sync skipped.', 'warn');
- }
-
- this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
-
- this.libraries = settings.plex.libraries.filter(
- (library) => library.enabled
- );
-
- this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
- if (this.enable4kMovie) {
- this.log(
- 'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
- 'info'
- );
- }
-
- this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
- if (this.enable4kShow) {
- this.log(
- 'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
- 'info'
- );
- }
-
- const hasHama = await this.hasHamaAgent();
- if (hasHama) {
- await animeList.sync();
- }
-
- if (this.isRecentOnly) {
- for (const library of this.libraries) {
- this.currentLibrary = library;
- this.log(
- `Beginning to process recently added for library: ${library.name}`,
- 'info'
- );
- const libraryItems = await this.plexClient.getRecentlyAdded(
- library.id
- );
-
- // Bundle items up by rating keys
- this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
- if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
- return (
- mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
- );
- }
-
- if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
- return mediaA.parentRatingKey === mediaB.parentRatingKey;
- }
-
- return mediaA.ratingKey === mediaB.ratingKey;
- });
-
- await this.loop({ sessionId });
- }
- } else {
- for (const library of this.libraries) {
- this.currentLibrary = library;
- this.log(`Beginning to process library: ${library.name}`, 'info');
- this.items = await this.plexClient.getLibraryContents(library.id);
- await this.loop({ sessionId });
- }
- }
- this.log(
- this.isRecentOnly
- ? 'Recently Added Scan Complete'
- : 'Full Scan Complete',
- 'info'
- );
- } catch (e) {
- logger.error('Sync interrupted', {
- label: 'Plex Sync',
- errorMessage: e.message,
- });
- } finally {
- // If a new scanning session hasnt started, set running back to false
- if (this.sessionId === sessionId) {
- this.running = false;
- }
- }
- }
-
- public status(): SyncStatus {
- return {
- running: this.running,
- progress: this.progress,
- total: this.items.length,
- currentLibrary: this.currentLibrary,
- libraries: this.libraries,
- };
- }
-
- public cancel(): void {
- this.running = false;
- }
-}
-
-export const jobPlexFullSync = new JobPlexSync();
-export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });
diff --git a/server/job/radarrsync/index.ts b/server/job/radarrsync/index.ts
deleted file mode 100644
index 57f88ee05..000000000
--- a/server/job/radarrsync/index.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import { uniqWith } from 'lodash';
-import { getRepository } from 'typeorm';
-import { v4 as uuid } from 'uuid';
-import RadarrAPI, { RadarrMovie } from '../../api/radarr';
-import { MediaStatus, MediaType } from '../../constants/media';
-import Media from '../../entity/Media';
-import { getSettings, RadarrSettings } from '../../lib/settings';
-import logger from '../../logger';
-
-const BUNDLE_SIZE = 50;
-const UPDATE_RATE = 4 * 1000;
-
-interface SyncStatus {
- running: boolean;
- progress: number;
- total: number;
- currentServer: RadarrSettings;
- servers: RadarrSettings[];
-}
-
-class JobRadarrSync {
- private running = false;
- private progress = 0;
- private enable4k = false;
- private sessionId: string;
- private servers: RadarrSettings[];
- private currentServer: RadarrSettings;
- private radarrApi: RadarrAPI;
- private items: RadarrMovie[] = [];
-
- public async run() {
- const settings = getSettings();
- const sessionId = uuid();
- this.sessionId = sessionId;
- this.log('Radarr sync starting', 'info', { sessionId });
-
- try {
- this.running = true;
-
- // Remove any duplicate Radarr servers and assign them to the servers field
- this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
- return (
- radarrA.hostname === radarrB.hostname &&
- radarrA.port === radarrB.port &&
- radarrA.baseUrl === radarrB.baseUrl
- );
- });
-
- this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
- if (this.enable4k) {
- this.log(
- 'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
- 'info'
- );
- }
-
- for (const server of this.servers) {
- this.currentServer = server;
- if (server.syncEnabled) {
- this.log(
- `Beginning to process Radarr server: ${server.name}`,
- 'info'
- );
-
- this.radarrApi = new RadarrAPI({
- apiKey: server.apiKey,
- url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
- });
-
- this.items = await this.radarrApi.getMovies();
-
- await this.loop({ sessionId });
- } else {
- this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
- }
- }
-
- this.log('Radarr sync complete', 'info');
- } catch (e) {
- this.log('Something went wrong.', 'error', { errorMessage: e.message });
- } finally {
- // If a new scanning session hasnt started, set running back to false
- if (this.sessionId === sessionId) {
- this.running = false;
- }
- }
- }
-
- public status(): SyncStatus {
- return {
- running: this.running,
- progress: this.progress,
- total: this.items.length,
- currentServer: this.currentServer,
- servers: this.servers,
- };
- }
-
- public cancel(): void {
- this.running = false;
- }
-
- private async processRadarrMovie(radarrMovie: RadarrMovie) {
- const mediaRepository = getRepository(Media);
- const server4k = this.enable4k && this.currentServer.is4k;
-
- const media = await mediaRepository.findOne({
- where: { tmdbId: radarrMovie.tmdbId },
- });
-
- if (media) {
- let isChanged = false;
- if (media.status === MediaStatus.AVAILABLE) {
- this.log(`Movie already available: ${radarrMovie.title}`);
- } else {
- media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
- ? MediaStatus.AVAILABLE
- : MediaStatus.PROCESSING;
- this.log(
- `Updated existing ${server4k ? '4K ' : ''}movie ${
- radarrMovie.title
- } to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
- );
- isChanged = true;
- }
-
- if (
- media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
- ) {
- media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
- this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
- isChanged = true;
- }
-
- if (
- media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
- radarrMovie.id
- ) {
- media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
- radarrMovie.id;
- this.log(
- `Updated external service ID for media entity: ${radarrMovie.title}`
- );
- isChanged = true;
- }
-
- if (
- media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
- radarrMovie.titleSlug
- ) {
- media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
- radarrMovie.titleSlug;
- this.log(
- `Updated external service slug for media entity: ${radarrMovie.title}`
- );
- isChanged = true;
- }
-
- if (isChanged) {
- await mediaRepository.save(media);
- }
- } else {
- const newMedia = new Media({
- tmdbId: radarrMovie.tmdbId,
- imdbId: radarrMovie.imdbId,
- mediaType: MediaType.MOVIE,
- serviceId: !server4k ? this.currentServer.id : undefined,
- serviceId4k: server4k ? this.currentServer.id : undefined,
- externalServiceId: !server4k ? radarrMovie.id : undefined,
- externalServiceId4k: server4k ? radarrMovie.id : undefined,
- status:
- !server4k && radarrMovie.downloaded
- ? MediaStatus.AVAILABLE
- : !server4k
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- status4k:
- server4k && radarrMovie.downloaded
- ? MediaStatus.AVAILABLE
- : server4k
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- });
-
- this.log(
- `Added media for movie ${radarrMovie.title} and set status to ${
- MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
- }`
- );
- await mediaRepository.save(newMedia);
- }
- }
-
- private async processItems(items: RadarrMovie[]) {
- await Promise.all(
- items.map(async (radarrMovie) => {
- await this.processRadarrMovie(radarrMovie);
- })
- );
- }
-
- private async loop({
- start = 0,
- end = BUNDLE_SIZE,
- sessionId,
- }: {
- start?: number;
- end?: number;
- sessionId?: string;
- } = {}) {
- const slicedItems = this.items.slice(start, end);
-
- if (!this.running) {
- throw new Error('Sync was aborted.');
- }
-
- if (this.sessionId !== sessionId) {
- throw new Error('New session was started. Old session aborted.');
- }
-
- if (start < this.items.length) {
- this.progress = start;
- await this.processItems(slicedItems);
-
- await new Promise((resolve, reject) =>
- setTimeout(() => {
- this.loop({
- start: start + BUNDLE_SIZE,
- end: end + BUNDLE_SIZE,
- sessionId,
- })
- .then(() => resolve())
- .catch((e) => reject(new Error(e.message)));
- }, UPDATE_RATE)
- );
- }
- }
-
- private log(
- message: string,
- level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
- optional?: Record
- ): void {
- logger[level](message, { label: 'Radarr Sync', ...optional });
- }
-}
-
-export const jobRadarrSync = new JobRadarrSync();
diff --git a/server/job/schedule.ts b/server/job/schedule.ts
index edac379a6..5aabd5036 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -1,10 +1,10 @@
import schedule from 'node-schedule';
-import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
-import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
-import logger from '../logger';
-import { jobRadarrSync } from './radarrsync';
-import { jobSonarrSync } from './sonarrsync';
import downloadTracker from '../lib/downloadtracker';
+import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
+import { radarrScanner } from '../lib/scanners/radarr';
+import { sonarrScanner } from '../lib/scanners/sonarr';
+import logger from '../logger';
+import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob {
id: string;
@@ -18,32 +18,34 @@ interface ScheduledJob {
export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
- // Run recently added plex sync every 5 minutes
+ // Run recently added plex scan every 5 minutes
scheduledJobs.push({
- id: 'plex-recently-added-sync',
- name: 'Plex Recently Added Sync',
+ id: 'plex-recently-added-scan',
+ name: 'Plex Recently Added Scan',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
- logger.info('Starting scheduled job: Plex Recently Added Sync', {
+ logger.info('Starting scheduled job: Plex Recently Added Scan', {
label: 'Jobs',
});
- jobPlexRecentSync.run();
+ plexRecentScanner.run();
}),
- running: () => jobPlexRecentSync.status().running,
- cancelFn: () => jobPlexRecentSync.cancel(),
+ running: () => plexRecentScanner.status().running,
+ cancelFn: () => plexRecentScanner.cancel(),
});
- // Run full plex sync every 24 hours
+ // Run full plex scan every 24 hours
scheduledJobs.push({
- id: 'plex-full-sync',
- name: 'Plex Full Library Sync',
+ id: 'plex-full-scan',
+ name: 'Plex Full Library Scan',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
- logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
- jobPlexFullSync.run();
+ logger.info('Starting scheduled job: Plex Full Library Scan', {
+ label: 'Jobs',
+ });
+ plexFullScanner.run();
}),
- running: () => jobPlexFullSync.status().running,
- cancelFn: () => jobPlexFullSync.cancel(),
+ running: () => plexFullScanner.status().running,
+ cancelFn: () => plexFullScanner.cancel(),
});
// Run recently added jellyfin sync every 5 minutes
@@ -76,30 +78,30 @@ export const startJobs = (): void => {
cancelFn: () => jobJellyfinFullSync.cancel(),
});
- // Run full radarr sync every 24 hours
+ // Run full radarr scan every 24 hours
scheduledJobs.push({
- id: 'radarr-sync',
- name: 'Radarr Sync',
+ id: 'radarr-scan',
+ name: 'Radarr Scan',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
- logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
- jobRadarrSync.run();
+ logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
+ radarrScanner.run();
}),
- running: () => jobRadarrSync.status().running,
- cancelFn: () => jobRadarrSync.cancel(),
+ running: () => radarrScanner.status().running,
+ cancelFn: () => radarrScanner.cancel(),
});
- // Run full sonarr sync every 24 hours
+ // Run full sonarr scan every 24 hours
scheduledJobs.push({
- id: 'sonarr-sync',
- name: 'Sonarr Sync',
+ id: 'sonarr-scan',
+ name: 'Sonarr Scan',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
- logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
- jobSonarrSync.run();
+ logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
+ sonarrScanner.run();
}),
- running: () => jobSonarrSync.status().running,
- cancelFn: () => jobSonarrSync.cancel(),
+ running: () => sonarrScanner.status().running,
+ cancelFn: () => sonarrScanner.cancel(),
});
// Run download sync
diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts
deleted file mode 100644
index 3685af484..000000000
--- a/server/job/sonarrsync/index.ts
+++ /dev/null
@@ -1,381 +0,0 @@
-import { uniqWith } from 'lodash';
-import { getRepository } from 'typeorm';
-import { v4 as uuid } from 'uuid';
-import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
-import TheMovieDb from '../../api/themoviedb';
-import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
-import { MediaStatus, MediaType } from '../../constants/media';
-import Media from '../../entity/Media';
-import Season from '../../entity/Season';
-import { getSettings, SonarrSettings } from '../../lib/settings';
-import logger from '../../logger';
-
-const BUNDLE_SIZE = 50;
-const UPDATE_RATE = 4 * 1000;
-
-interface SyncStatus {
- running: boolean;
- progress: number;
- total: number;
- currentServer: SonarrSettings;
- servers: SonarrSettings[];
-}
-
-class JobSonarrSync {
- private running = false;
- private progress = 0;
- private enable4k = false;
- private sessionId: string;
- private servers: SonarrSettings[];
- private currentServer: SonarrSettings;
- private sonarrApi: SonarrAPI;
- private items: SonarrSeries[] = [];
-
- public async run() {
- const settings = getSettings();
- const sessionId = uuid();
- this.sessionId = sessionId;
- this.log('Sonarr sync starting', 'info', { sessionId });
-
- try {
- this.running = true;
-
- // Remove any duplicate Sonarr servers and assign them to the servers field
- this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
- return (
- sonarrA.hostname === sonarrB.hostname &&
- sonarrA.port === sonarrB.port &&
- sonarrA.baseUrl === sonarrB.baseUrl
- );
- });
-
- this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
- if (this.enable4k) {
- this.log(
- 'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
- 'info'
- );
- }
-
- for (const server of this.servers) {
- this.currentServer = server;
- if (server.syncEnabled) {
- this.log(
- `Beginning to process Sonarr server: ${server.name}`,
- 'info'
- );
-
- this.sonarrApi = new SonarrAPI({
- apiKey: server.apiKey,
- url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
- });
-
- this.items = await this.sonarrApi.getSeries();
-
- await this.loop({ sessionId });
- } else {
- this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
- }
- }
-
- this.log('Sonarr sync complete', 'info');
- } catch (e) {
- this.log('Something went wrong.', 'error', { errorMessage: e.message });
- } finally {
- // If a new scanning session hasnt started, set running back to false
- if (this.sessionId === sessionId) {
- this.running = false;
- }
- }
- }
-
- public status(): SyncStatus {
- return {
- running: this.running,
- progress: this.progress,
- total: this.items.length,
- currentServer: this.currentServer,
- servers: this.servers,
- };
- }
-
- public cancel(): void {
- this.running = false;
- }
-
- private async processSonarrSeries(sonarrSeries: SonarrSeries) {
- const mediaRepository = getRepository(Media);
- const server4k = this.enable4k && this.currentServer.is4k;
-
- const media = await mediaRepository.findOne({
- where: { tvdbId: sonarrSeries.tvdbId },
- });
-
- const currentSeasonsAvailable = (media?.seasons ?? []).filter(
- (season) =>
- season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
- ).length;
-
- const newSeasons: Season[] = [];
-
- for (const season of sonarrSeries.seasons) {
- const existingSeason = media?.seasons.find(
- (es) => es.seasonNumber === season.seasonNumber
- );
-
- // We are already tracking this season so we can work on it directly
- if (existingSeason) {
- if (
- existingSeason[server4k ? 'status4k' : 'status'] !==
- MediaStatus.AVAILABLE &&
- season.statistics
- ) {
- existingSeason[server4k ? 'status4k' : 'status'] =
- season.statistics.episodeFileCount ===
- season.statistics.totalEpisodeCount
- ? MediaStatus.AVAILABLE
- : season.statistics.episodeFileCount > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : season.monitored
- ? MediaStatus.PROCESSING
- : existingSeason[server4k ? 'status4k' : 'status'];
- }
- } else {
- if (season.statistics && season.seasonNumber !== 0) {
- const allEpisodes =
- season.statistics.episodeFileCount ===
- season.statistics.totalEpisodeCount;
- newSeasons.push(
- new Season({
- seasonNumber: season.seasonNumber,
- status:
- !server4k && allEpisodes
- ? MediaStatus.AVAILABLE
- : !server4k && season.statistics.episodeFileCount > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : !server4k && season.monitored
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- status4k:
- server4k && allEpisodes
- ? MediaStatus.AVAILABLE
- : server4k && season.statistics.episodeFileCount > 0
- ? MediaStatus.PARTIALLY_AVAILABLE
- : !server4k && season.monitored
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- })
- );
- }
- }
- }
-
- const filteredSeasons = sonarrSeries.seasons.filter(
- (s) => s.seasonNumber !== 0
- );
-
- const isAllSeasons =
- (media?.seasons ?? []).filter(
- (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
- ).length +
- newSeasons.filter(
- (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
- ).length >=
- filteredSeasons.length && filteredSeasons.length > 0;
-
- if (media) {
- media.seasons = [...media.seasons, ...newSeasons];
-
- const newSeasonsAvailable = (media?.seasons ?? []).filter(
- (season) =>
- season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
- ).length;
-
- if (newSeasonsAvailable > currentSeasonsAvailable) {
- this.log(
- `Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
- server4k ? '4K ' : ''
- }season(s) for ${sonarrSeries.title}`,
- 'debug'
- );
- media.lastSeasonChange = new Date();
- }
-
- if (
- media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
- ) {
- media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
- this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
- }
-
- if (
- media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
- sonarrSeries.id
- ) {
- media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
- sonarrSeries.id;
- this.log(
- `Updated external service ID for media entity: ${sonarrSeries.title}`
- );
- }
-
- if (
- media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
- sonarrSeries.titleSlug
- ) {
- media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
- sonarrSeries.titleSlug;
- this.log(
- `Updated external service slug for media entity: ${sonarrSeries.title}`
- );
- }
-
- // If the show is already available, and there are no new seasons, dont adjust
- // the status
- const shouldStayAvailable =
- media.status === MediaStatus.AVAILABLE &&
- newSeasons.filter(
- (season) =>
- season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN
- ).length === 0;
-
- media[server4k ? 'status4k' : 'status'] =
- isAllSeasons || shouldStayAvailable
- ? MediaStatus.AVAILABLE
- : media.seasons.some(
- (season) =>
- season[server4k ? 'status4k' : 'status'] ===
- MediaStatus.AVAILABLE ||
- season[server4k ? 'status4k' : 'status'] ===
- MediaStatus.PARTIALLY_AVAILABLE
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : media.seasons.some(
- (season) =>
- season[server4k ? 'status4k' : 'status'] ===
- MediaStatus.PROCESSING
- )
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN;
-
- await mediaRepository.save(media);
- } else {
- const tmdb = new TheMovieDb();
- let tvShow: TmdbTvDetails;
-
- try {
- tvShow = await tmdb.getShowByTvdbId({
- tvdbId: sonarrSeries.tvdbId,
- });
- } catch (e) {
- this.log(
- 'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
- 'warn',
- { sonarrSeries, errorMessage: e.message }
- );
- return;
- }
-
- const newMedia = new Media({
- tmdbId: tvShow.id,
- tvdbId: sonarrSeries.tvdbId,
- mediaType: MediaType.TV,
- serviceId: !server4k ? this.currentServer.id : undefined,
- serviceId4k: server4k ? this.currentServer.id : undefined,
- externalServiceId: !server4k ? sonarrSeries.id : undefined,
- externalServiceId4k: server4k ? sonarrSeries.id : undefined,
- externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
- externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
- seasons: newSeasons,
- status:
- !server4k && isAllSeasons
- ? MediaStatus.AVAILABLE
- : !server4k &&
- newSeasons.some(
- (s) =>
- s.status === MediaStatus.PARTIALLY_AVAILABLE ||
- s.status === MediaStatus.AVAILABLE
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : !server4k
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- status4k:
- server4k && isAllSeasons
- ? MediaStatus.AVAILABLE
- : server4k &&
- newSeasons.some(
- (s) =>
- s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
- s.status4k === MediaStatus.AVAILABLE
- )
- ? MediaStatus.PARTIALLY_AVAILABLE
- : server4k
- ? MediaStatus.PROCESSING
- : MediaStatus.UNKNOWN,
- });
-
- this.log(
- `Added media for series ${sonarrSeries.title} and set status to ${
- MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
- }`
- );
- await mediaRepository.save(newMedia);
- }
- }
-
- private async processItems(items: SonarrSeries[]) {
- await Promise.all(
- items.map(async (sonarrSeries) => {
- await this.processSonarrSeries(sonarrSeries);
- })
- );
- }
-
- private async loop({
- start = 0,
- end = BUNDLE_SIZE,
- sessionId,
- }: {
- start?: number;
- end?: number;
- sessionId?: string;
- } = {}) {
- const slicedItems = this.items.slice(start, end);
-
- if (!this.running) {
- throw new Error('Sync was aborted.');
- }
-
- if (this.sessionId !== sessionId) {
- throw new Error('New session was started. Old session aborted.');
- }
-
- if (start < this.items.length) {
- this.progress = start;
- await this.processItems(slicedItems);
-
- await new Promise((resolve, reject) =>
- setTimeout(() => {
- this.loop({
- start: start + BUNDLE_SIZE,
- end: end + BUNDLE_SIZE,
- sessionId,
- })
- .then(() => resolve())
- .catch((e) => reject(new Error(e.message)));
- }, UPDATE_RATE)
- );
- }
- }
-
- private log(
- message: string,
- level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
- optional?: Record
- ): void {
- logger[level](message, { label: 'Sonarr Sync', ...optional });
- }
-}
-
-export const jobSonarrSync = new JobSonarrSync();
diff --git a/server/lib/cache.ts b/server/lib/cache.ts
index aaf3bd44b..fa03783c8 100644
--- a/server/lib/cache.ts
+++ b/server/lib/cache.ts
@@ -1,6 +1,12 @@
import NodeCache from 'node-cache';
-export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
+export type AvailableCacheIds =
+ | 'tmdb'
+ | 'radarr'
+ | 'sonarr'
+ | 'rt'
+ | 'github'
+ | 'plexguid';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -44,6 +50,14 @@ class CacheManager {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
+ github: new Cache('github', 'GitHub API', {
+ stdTtl: 21600,
+ checkPeriod: 60 * 30,
+ }),
+ plexguid: new Cache('plexguid', 'Plex GUID Cache', {
+ stdTtl: 86400 * 7, // 1 week cache
+ checkPeriod: 60 * 30,
+ }),
};
public getCache(id: AvailableCacheIds): Cache {
diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts
index 9faf411a8..33282285e 100644
--- a/server/lib/downloadtracker.ts
+++ b/server/lib/downloadtracker.ts
@@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
-import RadarrAPI from '../api/radarr';
-import SonarrAPI from '../api/sonarr';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
@@ -73,7 +73,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
- url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
+ url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
@@ -140,7 +140,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
- url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
+ url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts
index c4c6e61a0..1274d6a8b 100644
--- a/server/lib/email/index.ts
+++ b/server/lib/email/index.ts
@@ -1,14 +1,20 @@
-import nodemailer from 'nodemailer';
import Email from 'email-templates';
-import { getSettings } from '../settings';
+import nodemailer from 'nodemailer';
+import { URL } from 'url';
+import { getSettings, NotificationAgentEmail } from '../settings';
+import { openpgpEncrypt } from './openpgpEncrypt';
+
class PreparedEmail extends Email {
- public constructor() {
- const settings = getSettings().notifications.agents.email;
+ public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
+ const { applicationUrl } = getSettings().main;
const transport = nodemailer.createTransport({
+ name: applicationUrl ? new URL(applicationUrl).hostname : undefined,
host: settings.options.smtpHost,
port: settings.options.smtpPort,
secure: settings.options.secure,
+ ignoreTLS: settings.options.ignoreTls,
+ requireTLS: settings.options.requireTls,
tls: settings.options.allowSelfSigned
? {
rejectUnauthorized: false,
@@ -22,6 +28,18 @@ class PreparedEmail extends Email {
}
: undefined,
});
+
+ if (pgpKey) {
+ transport.use(
+ 'stream',
+ openpgpEncrypt({
+ signingKey: settings.options.pgpPrivateKey,
+ password: settings.options.pgpPassword,
+ encryptionKeys: [pgpKey],
+ })
+ );
+ }
+
super({
message: {
from: {
diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts
new file mode 100644
index 000000000..263f2b1f2
--- /dev/null
+++ b/server/lib/email/openpgpEncrypt.ts
@@ -0,0 +1,183 @@
+import { randomBytes } from 'crypto';
+import * as openpgp from 'openpgp';
+import { Transform, TransformCallback } from 'stream';
+
+interface EncryptorOptions {
+ signingKey?: string;
+ password?: string;
+ encryptionKeys: string[];
+}
+
+class PGPEncryptor extends Transform {
+ private _messageChunks: Uint8Array[] = [];
+ private _messageLength = 0;
+ private _signingKey?: string;
+ private _password?: string;
+
+ private _encryptionKeys: string[];
+
+ constructor(options: EncryptorOptions) {
+ super();
+ this._signingKey = options.signingKey;
+ this._password = options.password;
+ this._encryptionKeys = options.encryptionKeys;
+ }
+
+ // just save the whole message
+ _transform = (
+ chunk: any,
+ _encoding: BufferEncoding,
+ callback: TransformCallback
+ ): void => {
+ this._messageChunks.push(chunk);
+ this._messageLength += chunk.length;
+ callback();
+ };
+
+ // 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();
+ }
+
+ // Only sign the message if private key and password exist
+ if (this._signingKey && this._password) {
+ privateKey = await openpgp.readPrivateKey({
+ armoredKey: this._signingKey,
+ });
+
+ await openpgp.decryptKey({ privateKey, 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),
+ }),
+ 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();
+ };
+}
+
+export const openpgpEncrypt = (options: EncryptorOptions) => {
+ return function (mail: any, callback: () => unknown): void {
+ if (!options.encryptionKeys.length) {
+ setImmediate(callback);
+ }
+ mail.message.transform(
+ () =>
+ new PGPEncryptor({
+ signingKey: options.signingKey,
+ password: options.password,
+ encryptionKeys: options.encryptionKeys,
+ })
+ );
+ setImmediate(callback);
+ };
+};
diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts
index 4db8966a0..66c52a16e 100644
--- a/server/lib/notifications/agents/agent.ts
+++ b/server/lib/notifications/agents/agent.ts
@@ -6,7 +6,7 @@ import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
subject: string;
- notifyUser: User;
+ notifyUser?: User;
media?: Media;
image?: string;
message?: string;
@@ -24,6 +24,6 @@ export abstract class BaseAgent {
}
export interface NotificationAgent {
- shouldSend(type: Notification, payload: NotificationPayload): boolean;
+ shouldSend(): boolean;
send(type: Notification, payload: NotificationPayload): Promise;
}
diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts
index fc6e5bbbf..bce1a2815 100644
--- a/server/lib/notifications/agents/discord.ts
+++ b/server/lib/notifications/agents/discord.ts
@@ -1,7 +1,14 @@
import axios from 'axios';
+import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
+import { User } from '../../../entity/User';
import logger from '../../../logger';
-import { getSettings, NotificationAgentDiscord } from '../../settings';
+import { Permission } from '../../permissions';
+import {
+ getSettings,
+ NotificationAgentDiscord,
+ NotificationAgentKey,
+} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
@@ -71,7 +78,7 @@ interface DiscordRichEmbed {
interface DiscordWebhookPayload {
embeds: DiscordRichEmbed[];
- username: string;
+ username?: string;
avatar_url?: string;
tts: boolean;
content?: string;
@@ -107,7 +114,7 @@ class DiscordAgent
if (payload.request) {
fields.push({
name: 'Requested By',
- value: payload.notifyUser.displayName ?? '',
+ value: payload.request.requestedBy.displayName,
inline: true,
});
}
@@ -122,6 +129,7 @@ class DiscordAgent
});
break;
case Notification.MEDIA_APPROVED:
+ case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
@@ -155,15 +163,14 @@ class DiscordAgent
break;
}
- if (settings.main.applicationUrl && payload.media) {
- fields.push({
- name: `Open in ${settings.main.applicationTitle}`,
- value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
- });
- }
+ const url =
+ settings.main.applicationUrl && payload.media
+ ? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
+ : undefined;
return {
title: payload.subject,
+ url,
description: payload.message,
color,
timestamp: new Date().toISOString(),
@@ -185,12 +192,10 @@ class DiscordAgent
};
}
- public shouldSend(type: Notification): boolean {
- if (
- this.getSettings().enabled &&
- this.getSettings().options.webhookUrl &&
- hasNotificationType(type, this.getSettings().types)
- ) {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -201,42 +206,72 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending discord notification', { label: 'Notifications' });
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending Discord notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ let content = undefined;
+
try {
- const settings = getSettings();
- const webhookUrl = this.getSettings().options.webhookUrl;
+ if (payload.notifyUser) {
+ // Mention user who submitted the request
+ if (
+ payload.notifyUser.settings?.hasNotificationType(
+ NotificationAgentKey.DISCORD,
+ type
+ ) &&
+ payload.notifyUser.settings?.discordId
+ ) {
+ content = `<@${payload.notifyUser.settings.discordId}>`;
+ }
+ } else {
+ // Mention all users with the Manage Requests permission
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
- if (!webhookUrl) {
- return false;
+ content = users
+ .filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ user.settings?.hasNotificationType(
+ NotificationAgentKey.DISCORD,
+ type
+ ) &&
+ user.settings?.discordId &&
+ // Check if it's the user's own auto-approved request
+ (type !== Notification.MEDIA_AUTO_APPROVED ||
+ user.id !== payload.request?.requestedBy.id)
+ )
+ .map((user) => `<@${user.settings?.discordId}>`)
+ .join(' ');
}
- const mentionedUsers: string[] = [];
- let content = undefined;
-
- if (
- payload.notifyUser.settings?.enableNotifications &&
- payload.notifyUser.settings?.discordId
- ) {
- mentionedUsers.push(payload.notifyUser.settings.discordId);
- content = `<@${payload.notifyUser.settings.discordId}>`;
- }
-
- await axios.post(webhookUrl, {
- username: settings.main.applicationTitle,
+ await axios.post(settings.options.webhookUrl, {
+ username: settings.options.botUsername,
+ avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
- allowed_mentions: {
- users: mentionedUsers,
- },
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
- message: e.message,
- response: e.response.data,
+ mentions: content,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts
index 750aaf685..895590028 100644
--- a/server/lib/notifications/agents/email.ts
+++ b/server/lib/notifications/agents/email.ts
@@ -1,12 +1,18 @@
-import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
-import { hasNotificationType, Notification } from '..';
+import { EmailOptions } from 'email-templates';
import path from 'path';
-import { getSettings, NotificationAgentEmail } from '../../settings';
-import logger from '../../../logger';
import { getRepository } from 'typeorm';
+import { Notification } from '..';
+import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
-import { Permission } from '../../permissions';
+import logger from '../../../logger';
import PreparedEmail from '../../email';
+import { Permission } from '../../permissions';
+import {
+ getSettings,
+ NotificationAgentEmail,
+ NotificationAgentKey,
+} from '../../settings';
+import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent
@@ -21,13 +27,14 @@ class EmailAgent
return settings.notifications.agents.email;
}
- public shouldSend(type: Notification, payload: NotificationPayload): boolean {
+ public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
- hasNotificationType(type, this.getSettings().types) &&
- (payload.notifyUser.settings?.enableNotifications ?? true)
+ settings.options.emailFrom &&
+ settings.options.smtpHost &&
+ settings.options.smtpPort
) {
return true;
}
@@ -35,265 +42,205 @@ class EmailAgent
return false;
}
- private async sendMediaRequestEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
+ private buildMessage(
+ type: Notification,
+ payload: NotificationPayload,
+ toEmail: string
+ ): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
- // Send to all users with the manage requests permission (or admins)
- users
- .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
- .forEach((user) => {
- const email = new PreparedEmail();
-
- email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: user.email,
- },
- locals: {
- body: 'A user has requested new media!',
- mediaName: payload.subject,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.notifyUser.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: 'New Request',
- },
- });
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaFailedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const userRepository = getRepository(User);
- const users = await userRepository.find();
-
- // Send to all users with the manage requests permission (or admins)
- users
- .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
- .forEach((user) => {
- const email = new PreparedEmail();
-
- email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: user.email,
- },
- locals: {
- body:
- "A user's new request has failed to add to Sonarr or Radarr",
- mediaName: payload.subject,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.notifyUser.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: 'Failed Request',
- },
- });
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaApprovedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const email = new PreparedEmail();
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: 'Your request for the following media has been approved:',
- mediaName: payload.subject,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.notifyUser.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: 'Request Approved',
- },
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaDeclinedEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const email = new PreparedEmail();
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: 'Your request for the following media was declined:',
- mediaName: payload.subject,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.notifyUser.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: 'Request Declined',
- },
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendMediaAvailableEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const email = new PreparedEmail();
-
- await email.send({
- template: path.join(
- __dirname,
- '../../../templates/email/media-request'
- ),
- message: {
- to: payload.notifyUser.email,
- },
- locals: {
- body: 'Your requested media is now available!',
- mediaName: payload.subject,
- imageUrl: payload.image,
- timestamp: new Date().toTimeString(),
- requestedBy: payload.notifyUser.displayName,
- actionUrl: applicationUrl
- ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
- : undefined,
- applicationUrl,
- applicationTitle,
- requestType: 'Now Available',
- },
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
- }
- }
-
- private async sendTestEmail(payload: NotificationPayload) {
- // This is getting main settings for the whole app
- const { applicationUrl, applicationTitle } = getSettings().main;
- try {
- const email = new PreparedEmail();
-
- await email.send({
+ if (type === Notification.TEST_NOTIFICATION) {
+ return {
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
- to: payload.notifyUser.email,
+ to: toEmail,
},
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
- });
- return true;
- } catch (e) {
- logger.error('Mail notification failed to send', {
- label: 'Notifications',
- message: e.message,
- });
- return false;
+ };
}
+
+ if (payload.media) {
+ let requestType = '';
+ let body = '';
+
+ switch (type) {
+ case Notification.MEDIA_PENDING:
+ requestType = `New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
+ body = `A user has requested a new ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ }!`;
+ break;
+ case Notification.MEDIA_APPROVED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved`;
+ body = `Your request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } has been approved:`;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved`;
+ body = `A new request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } has been automatically approved:`;
+ break;
+ case Notification.MEDIA_AVAILABLE:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available`;
+ body = `The following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } you requested is now available!`;
+ break;
+ case Notification.MEDIA_DECLINED:
+ requestType = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined`;
+ body = `Your request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } was declined:`;
+ break;
+ case Notification.MEDIA_FAILED:
+ requestType = `Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
+ body = `A new request for the following ${
+ payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
+ } could not be added to ${
+ payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
+ }:`;
+ break;
+ }
+
+ return {
+ template: path.join(
+ __dirname,
+ '../../../templates/email/media-request'
+ ),
+ message: {
+ to: toEmail,
+ },
+ locals: {
+ requestType,
+ body,
+ mediaName: payload.subject,
+ mediaPlot: payload.message,
+ mediaExtra: payload.extra ?? [],
+ imageUrl: payload.image,
+ timestamp: new Date().toTimeString(),
+ requestedBy: payload.request?.requestedBy.displayName,
+ actionUrl: applicationUrl
+ ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
+ : undefined,
+ applicationUrl,
+ applicationTitle,
+ },
+ };
+ }
+
+ return undefined;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending email notification', { label: 'Notifications' });
+ if (payload.notifyUser) {
+ // Send notification to the user who submitted the request
+ if (
+ !payload.notifyUser.settings ||
+ // Check if user has email notifications enabled and fallback to true if undefined
+ // since email should default to true
+ (payload.notifyUser.settings.hasNotificationType(
+ NotificationAgentKey.EMAIL,
+ type
+ ) ??
+ true)
+ ) {
+ logger.debug('Sending email notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
- switch (type) {
- case Notification.MEDIA_PENDING:
- this.sendMediaRequestEmail(payload);
- break;
- case Notification.MEDIA_APPROVED:
- this.sendMediaApprovedEmail(payload);
- break;
- case Notification.MEDIA_DECLINED:
- this.sendMediaDeclinedEmail(payload);
- break;
- case Notification.MEDIA_AVAILABLE:
- this.sendMediaAvailableEmail(payload);
- break;
- case Notification.MEDIA_FAILED:
- this.sendMediaFailedEmail(payload);
- break;
- case Notification.TEST_NOTIFICATION:
- this.sendTestEmail(payload);
- break;
+ try {
+ const email = new PreparedEmail(
+ this.getSettings(),
+ payload.notifyUser.settings?.pgpKey
+ );
+ await email.send(
+ this.buildMessage(type, payload, payload.notifyUser.email)
+ );
+ } catch (e) {
+ logger.error('Error sending email notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ });
+
+ return false;
+ }
+ }
+ } else {
+ // Send notifications to all users with the Manage Requests permission
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
+
+ await Promise.all(
+ users
+ .filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ (!user.settings ||
+ // Check if user has email notifications enabled and fallback to true if undefined
+ // since email should default to true
+ (user.settings.hasNotificationType(
+ NotificationAgentKey.EMAIL,
+ type
+ ) ??
+ true)) &&
+ // Check if it's the user's own auto-approved request
+ (type !== Notification.MEDIA_AUTO_APPROVED ||
+ user.id !== payload.request?.requestedBy.id)
+ )
+ .map(async (user) => {
+ logger.debug('Sending email notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ const email = new PreparedEmail(
+ this.getSettings(),
+ user.settings?.pgpKey
+ );
+ await email.send(this.buildMessage(type, payload, user.email));
+ } catch (e) {
+ logger.error('Error sending email notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ });
+
+ return false;
+ }
+ })
+ );
}
return true;
diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts
new file mode 100644
index 000000000..cf1f8fe46
--- /dev/null
+++ b/server/lib/notifications/agents/lunasea.ts
@@ -0,0 +1,108 @@
+import axios from 'axios';
+import { hasNotificationType, Notification } from '..';
+import { MediaStatus } from '../../../constants/media';
+import logger from '../../../logger';
+import { getSettings, NotificationAgentLunaSea } from '../../settings';
+import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
+
+class LunaSeaAgent
+ extends BaseAgent
+ implements NotificationAgent {
+ protected getSettings(): NotificationAgentLunaSea {
+ if (this.settings) {
+ return this.settings;
+ }
+
+ const settings = getSettings();
+
+ return settings.notifications.agents.lunasea;
+ }
+
+ private buildPayload(type: Notification, payload: NotificationPayload) {
+ return {
+ notification_type: Notification[type],
+ subject: payload.subject,
+ message: payload.message,
+ image: payload.image ?? null,
+ email: payload.notifyUser?.email,
+ username: payload.notifyUser?.username,
+ avatar: payload.notifyUser?.avatar,
+ media: payload.media
+ ? {
+ media_type: payload.media.mediaType,
+ tmdbId: payload.media.tmdbId,
+ imdbId: payload.media.imdbId,
+ tvdbId: payload.media.tvdbId,
+ status: MediaStatus[payload.media.status],
+ status4k: MediaStatus[payload.media.status4k],
+ }
+ : null,
+ extra: payload.extra ?? [],
+ request: payload.request
+ ? {
+ request_id: payload.request.id,
+ requestedBy_email: payload.request.requestedBy.email,
+ requestedBy_username: payload.request.requestedBy.displayName,
+ requestedBy_avatar: payload.request.requestedBy.avatar,
+ }
+ : null,
+ };
+ }
+
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.webhookUrl) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public async send(
+ type: Notification,
+ payload: NotificationPayload
+ ): Promise {
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending LunaSea notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ await axios.post(
+ settings.options.webhookUrl,
+ this.buildPayload(type, payload),
+ settings.options.profileName
+ ? {
+ headers: {
+ Authorization: `Basic ${Buffer.from(
+ `${settings.options.profileName}:`
+ ).toString('base64')}`,
+ },
+ }
+ : undefined
+ );
+
+ return true;
+ } catch (e) {
+ logger.error('Error sending LunaSea notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
+ });
+
+ return false;
+ }
+ }
+}
+
+export default LunaSeaAgent;
diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts
index c7becfab0..474442e2c 100644
--- a/server/lib/notifications/agents/pushbullet.ts
+++ b/server/lib/notifications/agents/pushbullet.ts
@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -22,12 +23,10 @@ class PushbulletAgent
return settings.notifications.agents.pushbullet;
}
- public shouldSend(type: Notification): boolean {
- if (
- this.getSettings().enabled &&
- this.getSettings().options.accessToken &&
- hasNotificationType(type, this.getSettings().types)
- ) {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.accessToken) {
return true;
}
@@ -46,11 +45,13 @@ class PushbulletAgent
const title = payload.subject;
const plot = payload.message;
- const username = payload.notifyUser.displayName;
+ const username = payload.request?.requestedBy.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
- messageTitle = 'New Request';
+ messageTitle = `New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -59,7 +60,20 @@ class PushbulletAgent
message += `\nStatus: Pending Approval`;
break;
case Notification.MEDIA_APPROVED:
- messageTitle = 'Request Approved';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved`;
+ message += `${title}`;
+ if (plot) {
+ message += `\n\n${plot}`;
+ }
+ message += `\n\nRequested By: ${username}`;
+ message += `\nStatus: Processing`;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -68,7 +82,9 @@ class PushbulletAgent
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
- messageTitle = 'Now Available';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -77,7 +93,9 @@ class PushbulletAgent
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
- messageTitle = 'Request Declined';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -86,7 +104,9 @@ class PushbulletAgent
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
- messageTitle = 'Failed Request';
+ messageTitle = `Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
@@ -100,6 +120,10 @@ class PushbulletAgent
break;
}
+ for (const extra of payload.extra ?? []) {
+ message += `\n${extra.name}: ${extra.value}`;
+ }
+
return {
title: messageTitle,
body: message,
@@ -110,16 +134,23 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending Pushbullet notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
try {
- const endpoint = 'https://api.pushbullet.com/v2/pushes';
-
- const { accessToken } = this.getSettings().options;
-
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
- endpoint,
+ 'https://api.pushbullet.com/v2/pushes',
{
type: 'note',
title: title,
@@ -127,7 +158,7 @@ class PushbulletAgent
} as PushbulletPayload,
{
headers: {
- 'Access-Token': accessToken,
+ 'Access-Token': settings.options.accessToken,
},
}
);
@@ -136,8 +167,12 @@ class PushbulletAgent
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts
index 19c6d6d91..e190ff89f 100644
--- a/server/lib/notifications/agents/pushover.ts
+++ b/server/lib/notifications/agents/pushover.ts
@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -28,12 +29,13 @@ class PushoverAgent
return settings.notifications.agents.pushover;
}
- public shouldSend(type: Notification): boolean {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
if (
- this.getSettings().enabled &&
- this.getSettings().options.accessToken &&
- this.getSettings().options.userToken &&
- hasNotificationType(type, this.getSettings().types)
+ settings.enabled &&
+ settings.options.accessToken &&
+ settings.options.userToken
) {
return true;
}
@@ -60,62 +62,87 @@ class PushoverAgent
const title = payload.subject;
const plot = payload.message;
- const username = payload.notifyUser.displayName;
+ const username = payload.request?.requestedBy.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
- messageTitle = 'New Request';
+ messageTitle = `New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
message += `${title} `;
if (plot) {
- message += `\n${plot}`;
+ message += `\n${plot} `;
}
- message += `\n\nRequested By \n${username}`;
- message += `\n\nStatus \nPending Approval`;
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nPending Approval `;
break;
case Notification.MEDIA_APPROVED:
- messageTitle = 'Request Approved';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved`;
message += `${title} `;
if (plot) {
- message += `\n${plot}`;
+ message += `\n${plot} `;
}
- message += `\n\nRequested By \n${username}`;
- message += `\n\nStatus \nProcessing`;
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nProcessing `;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved`;
+ message += `${title} `;
+ if (plot) {
+ message += `\n${plot} `;
+ }
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nProcessing `;
break;
case Notification.MEDIA_AVAILABLE:
- messageTitle = 'Now Available';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available`;
message += `${title} `;
if (plot) {
- message += `\n${plot}`;
+ message += `\n${plot} `;
}
- message += `\n\nRequested By \n${username}`;
- message += `\n\nStatus \nAvailable`;
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nAvailable `;
break;
case Notification.MEDIA_DECLINED:
- messageTitle = 'Request Declined';
+ messageTitle = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined`;
message += `${title} `;
if (plot) {
- message += `\n${plot}`;
+ message += `\n${plot} `;
}
- message += `\n\nRequested By \n${username}`;
- message += `\n\nStatus \nDeclined`;
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nDeclined `;
priority = 1;
break;
case Notification.MEDIA_FAILED:
- messageTitle = 'Failed Request';
+ messageTitle = `Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
message += `${title} `;
if (plot) {
- message += `\n${plot}`;
+ message += `\n${plot} `;
}
- message += `\n\nRequested By \n${username}`;
- message += `\n\nStatus \nFailed`;
+ message += `\n\nRequested By \n${username} `;
+ message += `\n\nStatus \nFailed `;
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
- message += `${plot}`;
+ message += `${plot} `;
break;
}
+ for (const extra of payload.extra ?? []) {
+ message += `\n\n${extra.name} \n${extra.value} `;
+ }
+
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
@@ -134,12 +161,20 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending Pushover notification', { label: 'Notifications' });
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending Pushover notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
- const { accessToken, userToken } = this.getSettings().options;
-
const {
title,
message,
@@ -149,8 +184,8 @@ class PushoverAgent
} = this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
- token: accessToken,
- user: userToken,
+ token: settings.options.accessToken,
+ user: settings.options.userToken,
title: title,
message: message,
url: url,
@@ -163,8 +198,12 @@ class PushoverAgent
} catch (e) {
logger.error('Error sending Pushover notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts
index 70a527f19..d6f2289f9 100644
--- a/server/lib/notifications/agents/slack.ts
+++ b/server/lib/notifications/agents/slack.ts
@@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -66,41 +67,60 @@ class SlackAgent
if (payload.request) {
fields.push({
type: 'mrkdwn',
- text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
+ text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
- header = 'New Request';
+ header = `New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
break;
case Notification.MEDIA_APPROVED:
- header = 'Request Approved';
+ header = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved`;
+ fields.push({
+ type: 'mrkdwn',
+ text: '*Status*\nProcessing',
+ });
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ header = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AVAILABLE:
- header = 'Now Available';
+ header = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
break;
case Notification.MEDIA_DECLINED:
- header = 'Request Declined';
+ header = `${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
break;
case Notification.MEDIA_FAILED:
- header = 'Failed Request';
+ header = `Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nFailed',
@@ -111,6 +131,13 @@ class SlackAgent
break;
}
+ for (const extra of payload.extra ?? []) {
+ fields.push({
+ type: 'mrkdwn',
+ text: `*${extra.name}*\n${extra.value}`,
+ });
+ }
+
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
@@ -190,12 +217,10 @@ class SlackAgent
};
}
- public shouldSend(type: Notification): boolean {
- if (
- this.getSettings().enabled &&
- this.getSettings().options.webhookUrl &&
- hasNotificationType(type, this.getSettings().types)
- ) {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -206,22 +231,33 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending slack notification', { label: 'Notifications' });
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending Slack notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
try {
- const webhookUrl = this.getSettings().options.webhookUrl;
-
- if (!webhookUrl) {
- return false;
- }
-
- await axios.post(webhookUrl, this.buildEmbed(type, payload));
+ await axios.post(
+ settings.options.webhookUrl,
+ this.buildEmbed(type, payload)
+ );
return true;
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts
index fd3b4dd9f..c48f637b0 100644
--- a/server/lib/notifications/agents/telegram.ts
+++ b/server/lib/notifications/agents/telegram.ts
@@ -1,16 +1,32 @@
import axios from 'axios';
+import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
+import { MediaType } from '../../../constants/media';
+import { User } from '../../../entity/User';
import logger from '../../../logger';
-import { getSettings, NotificationAgentTelegram } from '../../settings';
+import { Permission } from '../../permissions';
+import {
+ getSettings,
+ NotificationAgentKey,
+ NotificationAgentTelegram,
+} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
-interface TelegramPayload {
+interface TelegramMessagePayload {
text: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
+interface TelegramPhotoPayload {
+ photo: string;
+ caption: string;
+ parse_mode: string;
+ chat_id: string;
+ disable_notification: boolean;
+}
+
class TelegramAgent
extends BaseAgent
implements NotificationAgent {
@@ -26,12 +42,13 @@ class TelegramAgent
return settings.notifications.agents.telegram;
}
- public shouldSend(type: Notification): boolean {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
if (
- this.getSettings().enabled &&
- this.getSettings().options.botAPI &&
- this.getSettings().options.chatId &&
- hasNotificationType(type, this.getSettings().types)
+ settings.enabled &&
+ settings.options.botAPI &&
+ settings.options.chatId
) {
return true;
}
@@ -45,20 +62,24 @@ class TelegramAgent
private buildMessage(
type: Notification,
- payload: NotificationPayload
- ): string {
+ payload: NotificationPayload,
+ chatId: string,
+ sendSilently: boolean
+ ): TelegramMessagePayload | TelegramPhotoPayload {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
- const user = this.escapeText(payload.notifyUser.displayName);
+ const user = this.escapeText(payload.request?.requestedBy.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
- message += `\*New Request\*`;
+ message += `\*New ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -67,7 +88,20 @@ class TelegramAgent
message += `\n\n\*Status\*\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
- message += `\*Request Approved\*`;
+ message += `\*${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Approved\*`;
+ message += `\n\n\*${title}\*`;
+ if (plot) {
+ message += `\n${plot}`;
+ }
+ message += `\n\n\*Requested By\*\n${user}`;
+ message += `\n\n\*Status\*\nProcessing`;
+ break;
+ case Notification.MEDIA_AUTO_APPROVED:
+ message += `\*${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -76,7 +110,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
- message += `\*Now Available\*`;
+ message += `\*${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -85,7 +121,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
- message += `\*Request Declined\*`;
+ message += `\*${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -94,7 +132,9 @@ class TelegramAgent
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
- message += `\*Failed Request\*`;
+ message += `\*Failed ${
+ payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
+ } Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
@@ -108,40 +148,171 @@ class TelegramAgent
break;
}
+ for (const extra of payload.extra ?? []) {
+ message += `\n\n\*${extra.name}\*\n${extra.value}`;
+ }
+
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */
- return message;
+ return payload.image
+ ? ({
+ photo: payload.image,
+ caption: message,
+ parse_mode: 'MarkdownV2',
+ chat_id: chatId,
+ disable_notification: !!sendSilently,
+ } as TelegramPhotoPayload)
+ : ({
+ text: message,
+ parse_mode: 'MarkdownV2',
+ chat_id: chatId,
+ disable_notification: !!sendSilently,
+ } as TelegramMessagePayload);
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending telegram notification', { label: 'Notifications' });
- try {
- const endpoint = `${this.baseUrl}bot${
- this.getSettings().options.botAPI
- }/sendMessage`;
+ const settings = this.getSettings();
- await axios.post(endpoint, {
- text: this.buildMessage(type, payload),
- parse_mode: 'MarkdownV2',
- chat_id: `${this.getSettings().options.chatId}`,
- disable_notification: this.getSettings().options.sendSilently,
- } as TelegramPayload);
+ const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
+ payload.image ? 'sendPhoto' : 'sendMessage'
+ }`;
- return true;
- } catch (e) {
- logger.error('Error sending Telegram notification', {
+ // Send system notification
+ if (hasNotificationType(type, settings.types ?? 0)) {
+ logger.debug('Sending Telegram notification', {
label: 'Notifications',
- message: e.message,
+ type: Notification[type],
+ subject: payload.subject,
});
- return false;
+
+ try {
+ await axios.post(
+ endpoint,
+ this.buildMessage(
+ type,
+ payload,
+ settings.options.chatId,
+ settings.options.sendSilently
+ )
+ );
+ } catch (e) {
+ logger.error('Error sending Telegram notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
+ });
+
+ return false;
+ }
}
+
+ if (payload.notifyUser) {
+ // Send notification to the user who submitted the request
+ if (
+ payload.notifyUser.settings?.hasNotificationType(
+ NotificationAgentKey.TELEGRAM,
+ type
+ ) &&
+ payload.notifyUser.settings?.telegramChatId &&
+ payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
+ ) {
+ logger.debug('Sending Telegram notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ await axios.post(
+ endpoint,
+ this.buildMessage(
+ type,
+ payload,
+ payload.notifyUser.settings.telegramChatId,
+ !!payload.notifyUser.settings.telegramSendSilently
+ )
+ );
+ } catch (e) {
+ logger.error('Error sending Telegram notification', {
+ label: 'Notifications',
+ recipient: payload.notifyUser.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
+ });
+
+ return false;
+ }
+ }
+ } else {
+ // Send notifications to all users with the Manage Requests permission
+ const userRepository = getRepository(User);
+ const users = await userRepository.find();
+
+ await Promise.all(
+ users
+ .filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ user.settings?.hasNotificationType(
+ NotificationAgentKey.TELEGRAM,
+ type
+ ) &&
+ // Check if it's the user's own auto-approved request
+ (type !== Notification.MEDIA_AUTO_APPROVED ||
+ user.id !== payload.request?.requestedBy.id)
+ )
+ .map(async (user) => {
+ if (
+ user.settings?.telegramChatId &&
+ user.settings.telegramChatId !== settings.options.chatId
+ ) {
+ logger.debug('Sending Telegram notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ await axios.post(
+ endpoint,
+ this.buildMessage(
+ type,
+ payload,
+ user.settings.telegramChatId,
+ !!user.settings?.telegramSendSilently
+ )
+ );
+ } catch (e) {
+ logger.error('Error sending Telegram notification', {
+ label: 'Notifications',
+ recipient: user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ response: e.response?.data,
+ });
+
+ return false;
+ }
+ }
+ })
+ );
+ }
+
+ return true;
}
}
diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts
index 6186be49e..f5d48dd2c 100644
--- a/server/lib/notifications/agents/webhook.ts
+++ b/server/lib/notifications/agents/webhook.ts
@@ -20,6 +20,7 @@ const KeyMap: Record = {
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
+ notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
@@ -29,6 +30,12 @@ const KeyMap: Record = {
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
request_id: 'request.id',
+ requestedBy_username: 'request.requestedBy.displayName',
+ requestedBy_email: 'request.requestedBy.email',
+ requestedBy_avatar: 'request.requestedBy.avatar',
+ requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
+ requestedBy_settings_telegramChatId:
+ 'request.requestedBy.settings.telegramChatId',
};
class WebhookAgent
@@ -105,12 +112,10 @@ class WebhookAgent
return this.parseKeys(parsedJSON, payload, type);
}
- public shouldSend(type: Notification): boolean {
- if (
- this.getSettings().enabled &&
- this.getSettings().options.webhookUrl &&
- hasNotificationType(type, this.getSettings().types)
- ) {
+ public shouldSend(): boolean {
+ const settings = this.getSettings();
+
+ if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@@ -121,26 +126,41 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise {
- logger.debug('Sending webhook notification', { label: 'Notifications' });
+ const settings = this.getSettings();
+
+ if (!hasNotificationType(type, settings.types ?? 0)) {
+ return true;
+ }
+
+ logger.debug('Sending webhook notification', {
+ label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
try {
- const { webhookUrl, authHeader } = this.getSettings().options;
-
- if (!webhookUrl) {
- return false;
- }
-
- await axios.post(webhookUrl, this.buildPayload(type, payload), {
- headers: {
- Authorization: authHeader,
- },
- });
+ await axios.post(
+ settings.options.webhookUrl,
+ this.buildPayload(type, payload),
+ settings.options.authHeader
+ ? {
+ headers: {
+ Authorization: settings.options.authHeader,
+ },
+ }
+ : undefined
+ );
return true;
} catch (e) {
- logger.error('Error sending Webhook notification', {
+ logger.error('Error sending webhook notification', {
label: 'Notifications',
+ type: Notification[type],
+ subject: payload.subject,
errorMessage: e.message,
+ response: e.response?.data,
});
+
return false;
}
}
diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts
new file mode 100644
index 000000000..18728813c
--- /dev/null
+++ b/server/lib/notifications/agents/webpush.ts
@@ -0,0 +1,254 @@
+import { getRepository } from 'typeorm';
+import webpush from 'web-push';
+import { Notification } from '..';
+import { MediaType } from '../../../constants/media';
+import { User } from '../../../entity/User';
+import { UserPushSubscription } from '../../../entity/UserPushSubscription';
+import logger from '../../../logger';
+import { Permission } from '../../permissions';
+import {
+ getSettings,
+ NotificationAgentConfig,
+ NotificationAgentKey,
+} from '../../settings';
+import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
+
+interface PushNotificationPayload {
+ notificationType: string;
+ mediaType?: 'movie' | 'tv';
+ tmdbId?: number;
+ subject: string;
+ message?: string;
+ image?: string;
+ actionUrl?: string;
+ requestId?: number;
+}
+
+class WebPushAgent
+ extends BaseAgent
+ implements NotificationAgent {
+ protected getSettings(): NotificationAgentConfig {
+ if (this.settings) {
+ return this.settings;
+ }
+
+ const settings = getSettings();
+
+ return settings.notifications.agents.webpush;
+ }
+
+ private getNotificationPayload(
+ type: Notification,
+ payload: NotificationPayload
+ ): PushNotificationPayload {
+ switch (type) {
+ case Notification.NONE:
+ return {
+ notificationType: Notification[type],
+ subject: 'Unknown',
+ };
+ case Notification.TEST_NOTIFICATION:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: payload.message,
+ };
+ case Notification.MEDIA_APPROVED:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Your ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request has been approved.`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ case Notification.MEDIA_AUTO_APPROVED:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Automatically approved a new ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request from ${payload.request?.requestedBy.displayName}.`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ case Notification.MEDIA_AVAILABLE:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Your ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request is now available!`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ case Notification.MEDIA_DECLINED:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Your ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request was declined.`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ case Notification.MEDIA_FAILED:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Failed to process ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request.`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ case Notification.MEDIA_PENDING:
+ return {
+ notificationType: Notification[type],
+ subject: payload.subject,
+ message: `Approval required for new ${
+ payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
+ } request from ${payload.request?.requestedBy.displayName}.`,
+ image: payload.image,
+ mediaType: payload.media?.mediaType,
+ tmdbId: payload.media?.tmdbId,
+ requestId: payload.request?.id,
+ actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
+ };
+ }
+ }
+
+ public shouldSend(): boolean {
+ if (this.getSettings().enabled) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public async send(
+ type: Notification,
+ payload: NotificationPayload
+ ): Promise {
+ const userRepository = getRepository(User);
+ const userPushSubRepository = getRepository(UserPushSubscription);
+ const settings = getSettings();
+
+ let pushSubs: UserPushSubscription[] = [];
+
+ const mainUser = await userRepository.findOne({ where: { id: 1 } });
+
+ if (
+ payload.notifyUser &&
+ // Check if user has webpush notifications enabled and fallback to true if undefined
+ // since web push should default to true
+ (payload.notifyUser.settings?.hasNotificationType(
+ NotificationAgentKey.WEBPUSH,
+ type
+ ) ??
+ true)
+ ) {
+ const notifySubs = await userPushSubRepository.find({
+ where: { user: payload.notifyUser.id },
+ });
+
+ pushSubs = notifySubs;
+ } else if (!payload.notifyUser) {
+ const users = await userRepository.find();
+
+ const manageUsers = users.filter(
+ (user) =>
+ user.hasPermission(Permission.MANAGE_REQUESTS) &&
+ // Check if user has webpush notifications enabled and fallback to true if undefined
+ // since web push should default to true
+ (user.settings?.hasNotificationType(
+ NotificationAgentKey.WEBPUSH,
+ type
+ ) ??
+ true) &&
+ // Check if it's the user's own auto-approved request
+ (type !== Notification.MEDIA_AUTO_APPROVED ||
+ user.id !== payload.request?.requestedBy.id)
+ );
+
+ const allSubs = await userPushSubRepository
+ .createQueryBuilder('pushSub')
+ .leftJoinAndSelect('pushSub.user', 'user')
+ .where('pushSub.userId IN (:users)', {
+ users: manageUsers.map((user) => user.id),
+ })
+ .getMany();
+
+ pushSubs = allSubs;
+ }
+
+ if (mainUser && pushSubs.length > 0) {
+ webpush.setVapidDetails(
+ `mailto:${mainUser.email}`,
+ settings.vapidPublic,
+ settings.vapidPrivate
+ );
+
+ await Promise.all(
+ pushSubs.map(async (sub) => {
+ logger.debug('Sending web push notification', {
+ label: 'Notifications',
+ recipient: sub.user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ });
+
+ try {
+ await webpush.sendNotification(
+ {
+ endpoint: sub.endpoint,
+ keys: {
+ auth: sub.auth,
+ p256dh: sub.p256dh,
+ },
+ },
+ Buffer.from(
+ JSON.stringify(this.getNotificationPayload(type, payload)),
+ 'utf-8'
+ )
+ );
+ } catch (e) {
+ logger.error(
+ 'Error sending web push notification; removing subscription',
+ {
+ label: 'Notifications',
+ recipient: sub.user.displayName,
+ type: Notification[type],
+ subject: payload.subject,
+ errorMessage: e.message,
+ }
+ );
+
+ // Failed to send notification so we need to remove the subscription
+ userPushSubRepository.remove(sub);
+ }
+ })
+ );
+ }
+
+ return true;
+ }
+}
+
+export default WebPushAgent;
diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts
index a50a2932c..a2eb01419 100644
--- a/server/lib/notifications/index.ts
+++ b/server/lib/notifications/index.ts
@@ -1,14 +1,15 @@
import logger from '../../logger';
-import { getSettings } from '../settings';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
+ NONE = 0,
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
+ MEDIA_AUTO_APPROVED = 128,
}
export const hasNotificationType = (
@@ -29,6 +30,11 @@ export const hasNotificationType = (
total = types;
}
+ // Test notifications don't need to be enabled
+ if (!(value & Notification.TEST_NOTIFICATION)) {
+ value += Notification.TEST_NOTIFICATION;
+ }
+
return !!(value & total);
};
@@ -37,19 +43,20 @@ class NotificationManager {
public registerAgents = (agents: NotificationAgent[]): void => {
this.activeAgents = [...this.activeAgents, ...agents];
- logger.info('Registered Notification Agents', { label: 'Notifications' });
+ logger.info('Registered notification agents', { label: 'Notifications' });
};
public sendNotification(
type: Notification,
payload: NotificationPayload
): void {
- const settings = getSettings().notifications;
- logger.info(`Sending notification for ${Notification[type]}`, {
+ logger.info(`Sending notification(s) for ${Notification[type]}`, {
label: 'Notifications',
+ subject: payload.subject,
});
+
this.activeAgents.forEach((agent) => {
- if (settings.enabled && agent.shouldSend(type, payload)) {
+ if (agent.shouldSend()) {
agent.send(type, payload);
}
});
diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts
index 5006a0045..fbf36e6b8 100644
--- a/server/lib/permissions.ts
+++ b/server/lib/permissions.ts
@@ -17,6 +17,8 @@ export enum Permission {
AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072,
+ REQUEST_MOVIE = 262144,
+ REQUEST_TV = 524288,
}
export interface PermissionCheckOptions {
diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts
new file mode 100644
index 000000000..01710a1e5
--- /dev/null
+++ b/server/lib/scanners/baseScanner.ts
@@ -0,0 +1,627 @@
+import { randomUUID } from 'crypto';
+import { getRepository } from 'typeorm';
+import TheMovieDb from '../../api/themoviedb';
+import { MediaStatus, MediaType } from '../../constants/media';
+import Media from '../../entity/Media';
+import Season from '../../entity/Season';
+import logger from '../../logger';
+import AsyncLock from '../../utils/asyncLock';
+import { getSettings } from '../settings';
+
+// Default scan rates (can be overidden)
+const BUNDLE_SIZE = 20;
+const UPDATE_RATE = 4 * 1000;
+
+export type StatusBase = {
+ running: boolean;
+ progress: number;
+ total: number;
+};
+
+export interface RunnableScanner {
+ run: () => Promise;
+ status: () => T & StatusBase;
+}
+
+export interface MediaIds {
+ tmdbId: number;
+ imdbId?: string;
+ tvdbId?: number;
+ isHama?: boolean;
+}
+
+interface ProcessOptions {
+ is4k?: boolean;
+ mediaAddedAt?: Date;
+ ratingKey?: string;
+ serviceId?: number;
+ externalServiceId?: number;
+ externalServiceSlug?: string;
+ title?: string;
+ processing?: boolean;
+}
+
+export interface ProcessableSeason {
+ seasonNumber: number;
+ totalEpisodes: number;
+ episodes: number;
+ episodes4k: number;
+ is4kOverride?: boolean;
+ processing?: boolean;
+}
+
+class BaseScanner {
+ private bundleSize;
+ private updateRate;
+ protected progress = 0;
+ protected items: T[] = [];
+ protected totalSize?: number = 0;
+ protected scannerName: string;
+ protected enable4kMovie = false;
+ protected enable4kShow = false;
+ protected sessionId: string;
+ protected running = false;
+ readonly asyncLock = new AsyncLock();
+ readonly tmdb = new TheMovieDb();
+
+ protected constructor(
+ scannerName: string,
+ {
+ updateRate,
+ bundleSize,
+ }: {
+ updateRate?: number;
+ bundleSize?: number;
+ } = {}
+ ) {
+ this.scannerName = scannerName;
+ this.bundleSize = bundleSize ?? BUNDLE_SIZE;
+ this.updateRate = updateRate ?? UPDATE_RATE;
+ }
+
+ private async getExisting(tmdbId: number, mediaType: MediaType) {
+ const mediaRepository = getRepository(Media);
+
+ const existing = await mediaRepository.findOne({
+ where: { tmdbId: tmdbId, mediaType },
+ });
+
+ return existing;
+ }
+
+ protected async processMovie(
+ tmdbId: number,
+ {
+ is4k = false,
+ mediaAddedAt,
+ ratingKey,
+ serviceId,
+ externalServiceId,
+ externalServiceSlug,
+ processing = false,
+ title = 'Unknown Title',
+ }: ProcessOptions = {}
+ ): Promise {
+ const mediaRepository = getRepository(Media);
+
+ await this.asyncLock.dispatch(tmdbId, async () => {
+ const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
+
+ if (existing) {
+ let changedExisting = false;
+
+ if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
+ existing[is4k ? 'status4k' : 'status'] = processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.AVAILABLE;
+ if (mediaAddedAt) {
+ existing.mediaAddedAt = mediaAddedAt;
+ }
+ changedExisting = true;
+ }
+
+ if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
+ existing.mediaAddedAt = mediaAddedAt;
+ changedExisting = true;
+ }
+
+ if (
+ ratingKey &&
+ existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
+ ) {
+ existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
+ changedExisting = true;
+ }
+
+ if (
+ serviceId !== undefined &&
+ existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
+ ) {
+ existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
+ changedExisting = true;
+ }
+
+ if (
+ externalServiceId !== undefined &&
+ existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
+ externalServiceId
+ ) {
+ existing[
+ is4k ? 'externalServiceId4k' : 'externalServiceId'
+ ] = externalServiceId;
+ changedExisting = true;
+ }
+
+ if (
+ externalServiceSlug !== undefined &&
+ existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
+ externalServiceSlug
+ ) {
+ existing[
+ is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
+ ] = externalServiceSlug;
+ changedExisting = true;
+ }
+
+ if (changedExisting) {
+ await mediaRepository.save(existing);
+ this.log(
+ `Media for ${title} exists. Changes were detected and the title will be updated.`,
+ 'info'
+ );
+ } else {
+ this.log(`Title already exists and no changes detected for ${title}`);
+ }
+ } else {
+ const newMedia = new Media();
+ newMedia.tmdbId = tmdbId;
+
+ newMedia.status =
+ !is4k && !processing
+ ? MediaStatus.AVAILABLE
+ : !is4k && processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ newMedia.status4k =
+ is4k && this.enable4kMovie && !processing
+ ? MediaStatus.AVAILABLE
+ : is4k && this.enable4kMovie && processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ newMedia.mediaType = MediaType.MOVIE;
+ newMedia.serviceId = !is4k ? serviceId : undefined;
+ newMedia.serviceId4k = is4k ? serviceId : undefined;
+ newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
+ newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
+ newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
+ newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
+
+ if (mediaAddedAt) {
+ newMedia.mediaAddedAt = mediaAddedAt;
+ }
+
+ if (ratingKey) {
+ newMedia.ratingKey = !is4k ? ratingKey : undefined;
+ newMedia.ratingKey4k =
+ is4k && this.enable4kMovie ? ratingKey : undefined;
+ }
+ await mediaRepository.save(newMedia);
+ this.log(`Saved new media: ${title}`);
+ }
+ });
+ }
+
+ /**
+ * processShow takes a TMDb ID and an array of ProcessableSeasons, which
+ * should include the total episodes a sesaon has + the total available
+ * episodes that each season currently has. Unlike processMovie, this method
+ * does not take an `is4k` option. We handle both the 4k _and_ non 4k status
+ * in one method.
+ *
+ * Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
+ * into the normal episodes properties and avoid using the 4k properties.
+ */
+ protected async processShow(
+ tmdbId: number,
+ tvdbId: number,
+ seasons: ProcessableSeason[],
+ {
+ mediaAddedAt,
+ ratingKey,
+ serviceId,
+ externalServiceId,
+ externalServiceSlug,
+ is4k = false,
+ title = 'Unknown Title',
+ }: ProcessOptions = {}
+ ): Promise {
+ const mediaRepository = getRepository(Media);
+
+ await this.asyncLock.dispatch(tmdbId, async () => {
+ const media = await this.getExisting(tmdbId, MediaType.TV);
+
+ const newSeasons: Season[] = [];
+
+ const currentStandardSeasonsAvailable = (
+ media?.seasons.filter(
+ (season) => season.status === MediaStatus.AVAILABLE
+ ) ?? []
+ ).length;
+
+ const current4kSeasonsAvailable = (
+ media?.seasons.filter(
+ (season) => season.status4k === MediaStatus.AVAILABLE
+ ) ?? []
+ ).length;
+
+ for (const season of seasons) {
+ const existingSeason = media?.seasons.find(
+ (es) => es.seasonNumber === season.seasonNumber
+ );
+
+ // We update the rating keys in the seasons loop because we need episode counts
+ if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
+ media.ratingKey = ratingKey;
+ }
+
+ if (
+ media &&
+ season.episodes4k > 0 &&
+ this.enable4kShow &&
+ media.ratingKey4k !== ratingKey
+ ) {
+ media.ratingKey4k = ratingKey;
+ }
+
+ if (existingSeason) {
+ // Here we update seasons if they already exist.
+ // If the season is already marked as available, we
+ // force it to stay available (to avoid competing scanners)
+ existingSeason.status =
+ (season.totalEpisodes === season.episodes && season.episodes > 0) ||
+ existingSeason.status === MediaStatus.AVAILABLE
+ ? MediaStatus.AVAILABLE
+ : season.episodes > 0
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : !season.is4kOverride && season.processing
+ ? MediaStatus.PROCESSING
+ : existingSeason.status;
+
+ // Same thing here, except we only do updates if 4k is enabled
+ existingSeason.status4k =
+ (this.enable4kShow &&
+ season.episodes4k === season.totalEpisodes &&
+ season.episodes4k > 0) ||
+ existingSeason.status4k === MediaStatus.AVAILABLE
+ ? MediaStatus.AVAILABLE
+ : this.enable4kShow && season.episodes4k > 0
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : season.is4kOverride && season.processing
+ ? MediaStatus.PROCESSING
+ : existingSeason.status4k;
+ } else {
+ newSeasons.push(
+ new Season({
+ seasonNumber: season.seasonNumber,
+ status:
+ season.totalEpisodes === season.episodes && season.episodes > 0
+ ? MediaStatus.AVAILABLE
+ : season.episodes > 0
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : !season.is4kOverride && season.processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN,
+ status4k:
+ this.enable4kShow &&
+ season.totalEpisodes === season.episodes4k &&
+ season.episodes4k > 0
+ ? MediaStatus.AVAILABLE
+ : this.enable4kShow && season.episodes4k > 0
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : season.is4kOverride && season.processing
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN,
+ })
+ );
+ }
+ }
+
+ const isAllStandardSeasons =
+ seasons.length &&
+ seasons.every(
+ (season) =>
+ season.episodes === season.totalEpisodes && season.episodes > 0
+ );
+
+ const isAll4kSeasons =
+ seasons.length &&
+ seasons.every(
+ (season) =>
+ season.episodes4k === season.totalEpisodes && season.episodes4k > 0
+ );
+
+ if (media) {
+ media.seasons = [...media.seasons, ...newSeasons];
+
+ const newStandardSeasonsAvailable = (
+ media.seasons.filter(
+ (season) => season.status === MediaStatus.AVAILABLE
+ ) ?? []
+ ).length;
+
+ const new4kSeasonsAvailable = (
+ media.seasons.filter(
+ (season) => season.status4k === MediaStatus.AVAILABLE
+ ) ?? []
+ ).length;
+
+ // If at least one new season has become available, update
+ // the lastSeasonChange field so we can trigger notifications
+ if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
+ this.log(
+ `Detected ${
+ newStandardSeasonsAvailable - currentStandardSeasonsAvailable
+ } new standard season(s) for ${title}`,
+ 'debug'
+ );
+ media.lastSeasonChange = new Date();
+
+ if (mediaAddedAt) {
+ media.mediaAddedAt = mediaAddedAt;
+ }
+ }
+
+ if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
+ this.log(
+ `Detected ${
+ new4kSeasonsAvailable - current4kSeasonsAvailable
+ } new 4K season(s) for ${title}`,
+ 'debug'
+ );
+ media.lastSeasonChange = new Date();
+ }
+
+ if (!media.mediaAddedAt && mediaAddedAt) {
+ media.mediaAddedAt = mediaAddedAt;
+ }
+
+ if (serviceId !== undefined) {
+ media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
+ }
+
+ if (externalServiceId !== undefined) {
+ media[
+ is4k ? 'externalServiceId4k' : 'externalServiceId'
+ ] = externalServiceId;
+ }
+
+ if (externalServiceSlug !== undefined) {
+ media[
+ is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
+ ] = externalServiceSlug;
+ }
+
+ // If the show is already available, and there are no new seasons, dont adjust
+ // the status
+ const shouldStayAvailable =
+ media.status === MediaStatus.AVAILABLE &&
+ newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
+ .length === 0;
+ const shouldStayAvailable4k =
+ media.status4k === MediaStatus.AVAILABLE &&
+ newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
+ .length === 0;
+
+ media.status =
+ isAllStandardSeasons || shouldStayAvailable
+ ? MediaStatus.AVAILABLE
+ : media.seasons.some(
+ (season) =>
+ season.status === MediaStatus.PARTIALLY_AVAILABLE ||
+ season.status === MediaStatus.AVAILABLE
+ )
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : !seasons.length ||
+ media.seasons.some(
+ (season) => season.status === MediaStatus.PROCESSING
+ )
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ media.status4k =
+ (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
+ ? MediaStatus.AVAILABLE
+ : this.enable4kShow &&
+ media.seasons.some(
+ (season) =>
+ season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
+ season.status4k === MediaStatus.AVAILABLE
+ )
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : !seasons.length ||
+ media.seasons.some(
+ (season) => season.status4k === MediaStatus.PROCESSING
+ )
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN;
+ await mediaRepository.save(media);
+ this.log(`Updating existing title: ${title}`);
+ } else {
+ const newMedia = new Media({
+ mediaType: MediaType.TV,
+ seasons: newSeasons,
+ tmdbId,
+ tvdbId,
+ mediaAddedAt,
+ serviceId: !is4k ? serviceId : undefined,
+ serviceId4k: is4k ? serviceId : undefined,
+ externalServiceId: !is4k ? externalServiceId : undefined,
+ externalServiceId4k: is4k ? externalServiceId : undefined,
+ externalServiceSlug: !is4k ? externalServiceSlug : undefined,
+ externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
+ ratingKey: newSeasons.some(
+ (sn) =>
+ sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
+ sn.status === MediaStatus.AVAILABLE
+ )
+ ? ratingKey
+ : undefined,
+ ratingKey4k:
+ this.enable4kShow &&
+ newSeasons.some(
+ (sn) =>
+ sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
+ sn.status4k === MediaStatus.AVAILABLE
+ )
+ ? ratingKey
+ : undefined,
+ status: isAllStandardSeasons
+ ? MediaStatus.AVAILABLE
+ : newSeasons.some(
+ (season) =>
+ season.status === MediaStatus.PARTIALLY_AVAILABLE ||
+ season.status === MediaStatus.AVAILABLE
+ )
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : newSeasons.some(
+ (season) => season.status === MediaStatus.PROCESSING
+ )
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN,
+ status4k:
+ isAll4kSeasons && this.enable4kShow
+ ? MediaStatus.AVAILABLE
+ : this.enable4kShow &&
+ newSeasons.some(
+ (season) =>
+ season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
+ season.status4k === MediaStatus.AVAILABLE
+ )
+ ? MediaStatus.PARTIALLY_AVAILABLE
+ : newSeasons.some(
+ (season) => season.status4k === MediaStatus.PROCESSING
+ )
+ ? MediaStatus.PROCESSING
+ : MediaStatus.UNKNOWN,
+ });
+ await mediaRepository.save(newMedia);
+ this.log(`Saved ${title}`);
+ }
+ });
+ }
+
+ /**
+ * Call startRun from child class whenever a run is starting to
+ * ensure required values are set
+ *
+ * Returns the session ID which is requried for the cleanup method
+ */
+ protected startRun(): string {
+ const settings = getSettings();
+ const sessionId = randomUUID();
+ this.sessionId = sessionId;
+
+ this.log('Scan starting', 'info', { sessionId });
+
+ this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
+ if (this.enable4kMovie) {
+ this.log(
+ 'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
+ 'info'
+ );
+ }
+
+ this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
+ if (this.enable4kShow) {
+ this.log(
+ 'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
+ 'info'
+ );
+ }
+
+ this.running = true;
+
+ return sessionId;
+ }
+
+ /**
+ * Call at end of run loop to perform cleanup
+ */
+ protected endRun(sessionId: string): void {
+ if (this.sessionId === sessionId) {
+ this.running = false;
+ }
+ }
+
+ public cancel(): void {
+ this.running = false;
+ }
+
+ protected async loop(
+ processFn: (item: T) => Promise,
+ {
+ start = 0,
+ end = this.bundleSize,
+ sessionId,
+ }: {
+ start?: number;
+ end?: number;
+ sessionId?: string;
+ } = {}
+ ): Promise {
+ const slicedItems = this.items.slice(start, end);
+
+ if (!this.running) {
+ throw new Error('Sync was aborted.');
+ }
+
+ if (this.sessionId !== sessionId) {
+ throw new Error('New session was started. Old session aborted.');
+ }
+
+ if (start < this.items.length) {
+ this.progress = start;
+ await this.processItems(processFn, slicedItems);
+
+ await new Promise((resolve, reject) =>
+ setTimeout(() => {
+ this.loop(processFn, {
+ start: start + this.bundleSize,
+ end: end + this.bundleSize,
+ sessionId,
+ })
+ .then(() => resolve())
+ .catch((e) => reject(new Error(e.message)));
+ }, this.updateRate)
+ );
+ }
+ }
+
+ private async processItems(
+ processFn: (items: T) => Promise,
+ items: T[]
+ ) {
+ await Promise.all(
+ items.map(async (item) => {
+ await processFn(item);
+ })
+ );
+ }
+
+ protected log(
+ message: string,
+ level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
+ optional?: Record
+ ): void {
+ logger[level](message, { label: this.scannerName, ...optional });
+ }
+
+ get protectedUpdateRate(): number {
+ return this.updateRate;
+ }
+
+ get protectedBundleSize(): number {
+ return this.bundleSize;
+ }
+}
+
+export default BaseScanner;
diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts
new file mode 100644
index 000000000..6c43dcf67
--- /dev/null
+++ b/server/lib/scanners/plex/index.ts
@@ -0,0 +1,548 @@
+import { uniqWith } from 'lodash';
+import { getRepository } from 'typeorm';
+import animeList from '../../../api/animelist';
+import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
+import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
+import { User } from '../../../entity/User';
+import cacheManager from '../../cache';
+import { getSettings, Library } from '../../settings';
+import BaseScanner, {
+ MediaIds,
+ ProcessableSeason,
+ RunnableScanner,
+ StatusBase,
+} from '../baseScanner';
+
+const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
+const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
+const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
+const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
+const plexRegex = new RegExp(/plex:\/\//);
+// Hama agent uses ASS naming, see details here:
+// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
+const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
+const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
+const HAMA_AGENT = 'com.plexapp.agents.hama';
+
+type SyncStatus = StatusBase & {
+ currentLibrary: Library;
+ libraries: Library[];
+};
+
+class PlexScanner
+ extends BaseScanner
+ implements RunnableScanner {
+ private plexClient: PlexAPI;
+ private libraries: Library[];
+ private currentLibrary: Library;
+ private isRecentOnly = false;
+
+ public constructor(isRecentOnly = false) {
+ super('Plex Scan', { bundleSize: 50 });
+ this.isRecentOnly = isRecentOnly;
+ }
+
+ public status(): SyncStatus {
+ return {
+ running: this.running,
+ progress: this.progress,
+ total: this.totalSize ?? 0,
+ currentLibrary: this.currentLibrary,
+ libraries: this.libraries,
+ };
+ }
+
+ public async run(): Promise {
+ const settings = getSettings();
+ const sessionId = this.startRun();
+ try {
+ const userRepository = getRepository(User);
+ const admin = await userRepository.findOne({
+ select: ['id', 'plexToken'],
+ order: { id: 'ASC' },
+ });
+
+ if (!admin) {
+ return this.log('No admin configured. Plex scan skipped.', 'warn');
+ }
+
+ this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
+
+ this.libraries = settings.plex.libraries.filter(
+ (library) => library.enabled
+ );
+
+ const hasHama = await this.hasHamaAgent();
+ if (hasHama) {
+ await animeList.sync();
+ }
+
+ if (this.isRecentOnly) {
+ for (const library of this.libraries) {
+ this.currentLibrary = library;
+ this.log(
+ `Beginning to process recently added for library: ${library.name}`,
+ 'info',
+ { lastScan: library.lastScan }
+ );
+ const libraryItems = await this.plexClient.getRecentlyAdded(
+ library.id,
+ library.lastScan
+ ? {
+ // We remove 10 minutes from the last scan as a buffer
+ addedAt: library.lastScan - 1000 * 60 * 10,
+ }
+ : undefined
+ );
+
+ // Bundle items up by rating keys
+ this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
+ if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
+ return (
+ mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
+ );
+ }
+
+ if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
+ return mediaA.parentRatingKey === mediaB.parentRatingKey;
+ }
+
+ return mediaA.ratingKey === mediaB.ratingKey;
+ });
+
+ await this.loop(this.processItem.bind(this), { sessionId });
+
+ // After run completes, update last scan time
+ const newLibraries = settings.plex.libraries.map((lib) => {
+ if (lib.id === library.id) {
+ return {
+ ...lib,
+ lastScan: Date.now(),
+ };
+ }
+ return lib;
+ });
+
+ settings.plex.libraries = newLibraries;
+ settings.save();
+ }
+ } else {
+ for (const library of this.libraries) {
+ this.currentLibrary = library;
+ this.log(`Beginning to process library: ${library.name}`, 'info');
+ await this.paginateLibrary(library, { sessionId });
+ }
+ }
+ this.log(
+ this.isRecentOnly
+ ? 'Recently Added Scan Complete'
+ : 'Full Scan Complete',
+ 'info'
+ );
+ } catch (e) {
+ this.log('Scan interrupted', 'error', { errorMessage: e.message });
+ } finally {
+ this.endRun(sessionId);
+ }
+ }
+
+ private async paginateLibrary(
+ library: Library,
+ { start = 0, sessionId }: { start?: number; sessionId: string }
+ ) {
+ if (!this.running) {
+ throw new Error('Sync was aborted.');
+ }
+
+ if (this.sessionId !== sessionId) {
+ throw new Error('New session was started. Old session aborted.');
+ }
+
+ const response = await this.plexClient.getLibraryContents(library.id, {
+ size: this.protectedBundleSize,
+ offset: start,
+ });
+
+ this.progress = start;
+ this.totalSize = response.totalSize;
+
+ if (response.items.length === 0) {
+ return;
+ }
+
+ await Promise.all(
+ response.items.map(async (item) => {
+ await this.processItem(item);
+ })
+ );
+
+ if (response.items.length < this.protectedBundleSize) {
+ return;
+ }
+
+ await new Promise((resolve, reject) =>
+ setTimeout(() => {
+ this.paginateLibrary(library, {
+ start: start + this.protectedBundleSize,
+ sessionId,
+ })
+ .then(() => resolve())
+ .catch((e) => reject(new Error(e.message)));
+ }, this.protectedUpdateRate)
+ );
+ }
+
+ private async processItem(plexitem: PlexLibraryItem) {
+ try {
+ if (plexitem.type === 'movie') {
+ await this.processPlexMovie(plexitem);
+ } else if (
+ plexitem.type === 'show' ||
+ plexitem.type === 'episode' ||
+ plexitem.type === 'season'
+ ) {
+ await this.processPlexShow(plexitem);
+ }
+ } catch (e) {
+ this.log('Failed to process Plex media', 'error', {
+ errorMessage: e.message,
+ title: plexitem.title,
+ });
+ }
+ }
+
+ private async processPlexMovie(plexitem: PlexLibraryItem) {
+ const mediaIds = await this.getMediaIds(plexitem);
+
+ const has4k = plexitem.Media.some(
+ (media) => media.videoResolution === '4k'
+ );
+
+ await this.processMovie(mediaIds.tmdbId, {
+ is4k: has4k && this.enable4kMovie,
+ mediaAddedAt: new Date(plexitem.addedAt * 1000),
+ ratingKey: plexitem.ratingKey,
+ title: plexitem.title,
+ });
+ }
+
+ private async processPlexMovieByTmdbId(
+ plexitem: PlexMetadata,
+ tmdbId: number
+ ) {
+ const has4k = plexitem.Media.some(
+ (media) => media.videoResolution === '4k'
+ );
+
+ await this.processMovie(tmdbId, {
+ is4k: has4k && this.enable4kMovie,
+ mediaAddedAt: new Date(plexitem.addedAt * 1000),
+ ratingKey: plexitem.ratingKey,
+ title: plexitem.title,
+ });
+ }
+
+ private async processPlexShow(plexitem: PlexLibraryItem) {
+ const ratingKey =
+ plexitem.grandparentRatingKey ??
+ plexitem.parentRatingKey ??
+ plexitem.ratingKey;
+ const metadata = await this.plexClient.getMetadata(ratingKey, {
+ includeChildren: true,
+ });
+
+ const mediaIds = await this.getMediaIds(metadata);
+
+ // If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
+ // as a special HAMA movie
+ if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
+ this.processHamaMovie(metadata, mediaIds.tmdbId);
+ return;
+ }
+
+ // If the media is from HAMA and we have a TVDb ID, we will attempt
+ // to process any specials that may exist
+ if (mediaIds.tvdbId && mediaIds.isHama) {
+ await this.processHamaSpecials(metadata, mediaIds.tvdbId);
+ }
+
+ const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
+
+ const seasons = tvShow.seasons;
+ const processableSeasons: ProcessableSeason[] = [];
+
+ const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
+
+ for (const season of filteredSeasons) {
+ const matchedPlexSeason = metadata.Children?.Metadata.find(
+ (md) => Number(md.index) === season.season_number
+ );
+
+ if (matchedPlexSeason) {
+ // If we have a matched Plex season, get its children metadata so we can check details
+ const episodes = await this.plexClient.getChildrenMetadata(
+ matchedPlexSeason.ratingKey
+ );
+ // Total episodes that are in standard definition (not 4k)
+ const totalStandard = episodes.filter((episode) =>
+ !this.enable4kShow
+ ? true
+ : episode.Media.some((media) => media.videoResolution !== '4k')
+ ).length;
+
+ // Total episodes that are in 4k
+ const total4k = this.enable4kShow
+ ? episodes.filter((episode) =>
+ episode.Media.some((media) => media.videoResolution === '4k')
+ ).length
+ : 0;
+
+ processableSeasons.push({
+ seasonNumber: season.season_number,
+ episodes: totalStandard,
+ episodes4k: total4k,
+ totalEpisodes: season.episode_count,
+ });
+ } else {
+ processableSeasons.push({
+ seasonNumber: season.season_number,
+ episodes: 0,
+ episodes4k: 0,
+ totalEpisodes: season.episode_count,
+ });
+ }
+ }
+
+ if (mediaIds.tvdbId) {
+ await this.processShow(
+ mediaIds.tmdbId,
+ mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
+ processableSeasons,
+ {
+ mediaAddedAt: new Date(metadata.addedAt * 1000),
+ ratingKey: ratingKey,
+ title: metadata.title,
+ }
+ );
+ }
+ }
+
+ private async getMediaIds(plexitem: PlexLibraryItem): Promise {
+ let mediaIds: Partial = {};
+ // Check if item is using new plex movie/tv agent
+ if (plexitem.guid.match(plexRegex)) {
+ const guidCache = cacheManager.getCache('plexguid');
+
+ const cachedGuids = guidCache.data.get(plexitem.ratingKey);
+
+ if (cachedGuids) {
+ this.log('GUIDs are cached. Skipping metadata request.', 'debug', {
+ mediaIds: cachedGuids,
+ title: plexitem.title,
+ });
+ mediaIds = cachedGuids;
+ }
+
+ const metadata =
+ plexitem.Guid && plexitem.Guid.length > 0
+ ? plexitem
+ : await this.plexClient.getMetadata(plexitem.ratingKey);
+
+ // If there is no Guid field at all, then we bail
+ if (!metadata.Guid) {
+ throw new Error(
+ 'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
+ );
+ }
+
+ // Map all IDs to MediaId object
+ metadata.Guid.forEach((ref) => {
+ if (ref.id.match(imdbRegex)) {
+ mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
+ } else if (ref.id.match(tmdbRegex)) {
+ const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
+ mediaIds.tmdbId = Number(tmdbMatch);
+ } else if (ref.id.match(tvdbRegex)) {
+ const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
+ mediaIds.tvdbId = Number(tvdbMatch);
+ }
+ });
+
+ // 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({
+ imdbId: mediaIds.imdbId,
+ });
+ mediaIds.tmdbId = tmdbMovie.id;
+ }
+
+ // Cache GUIDs
+ guidCache.data.set(plexitem.ratingKey, mediaIds);
+
+ // Check if the agent is IMDb
+ } else if (plexitem.guid.match(imdbRegex)) {
+ const imdbMatch = plexitem.guid.match(imdbRegex);
+ if (imdbMatch) {
+ mediaIds.imdbId = imdbMatch[1];
+ const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ imdbId: mediaIds.imdbId,
+ });
+ mediaIds.tmdbId = tmdbMovie.id;
+ }
+ // Check if the agent is TMDb
+ } else if (plexitem.guid.match(tmdbRegex)) {
+ const tmdbMatch = plexitem.guid.match(tmdbRegex);
+ if (tmdbMatch) {
+ mediaIds.tmdbId = Number(tmdbMatch[1]);
+ }
+ // Check if the agent is TVDb
+ } else if (plexitem.guid.match(tvdbRegex)) {
+ const matchedtvdb = plexitem.guid.match(tvdbRegex);
+
+ // If we can find a tvdb Id, use it to get the full tmdb show details
+ if (matchedtvdb) {
+ const show = await this.tmdb.getShowByTvdbId({
+ tvdbId: Number(matchedtvdb[1]),
+ });
+
+ mediaIds.tvdbId = Number(matchedtvdb[1]);
+ mediaIds.tmdbId = show.id;
+ }
+ // Check if the agent (for shows) is TMDb
+ } else if (plexitem.guid.match(tmdbShowRegex)) {
+ const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
+ if (matchedtmdb) {
+ mediaIds.tmdbId = Number(matchedtmdb[1]);
+ }
+ // Check for HAMA (with TVDb guid)
+ } else if (plexitem.guid.match(hamaTvdbRegex)) {
+ const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
+
+ if (matchedtvdb) {
+ const show = await this.tmdb.getShowByTvdbId({
+ tvdbId: Number(matchedtvdb[1]),
+ });
+
+ mediaIds.tvdbId = Number(matchedtvdb[1]);
+ mediaIds.tmdbId = show.id;
+ // Set isHama to true, so we can know to add special processing to this item
+ mediaIds.isHama = true;
+ }
+ // Check for HAMA (with anidb guid)
+ } else if (plexitem.guid.match(hamaAnidbRegex)) {
+ const matchedhama = plexitem.guid.match(hamaAnidbRegex);
+
+ if (!animeList.isLoaded()) {
+ this.log(
+ `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
+ 'warn',
+ { title: plexitem.title }
+ );
+ } else if (matchedhama) {
+ const anidbId = Number(matchedhama[1]);
+ const result = animeList.getFromAnidbId(anidbId);
+ let tvShow: TmdbTvDetails | null = null;
+
+ // Set isHama to true, so we can know to add special processing to this item
+ mediaIds.isHama = true;
+
+ // First try to lookup the show by TVDb ID
+ if (result?.tvdbId) {
+ const extResponse = await this.tmdb.getByExternalId({
+ externalId: result.tvdbId,
+ type: 'tvdb',
+ });
+ if (extResponse.tv_results[0]) {
+ tvShow = await this.tmdb.getTvShow({
+ tvId: extResponse.tv_results[0].id,
+ });
+ mediaIds.tvdbId = result.tvdbId;
+ mediaIds.tmdbId = tvShow.id;
+ } else {
+ this.log(
+ `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
+ );
+ }
+ }
+
+ if (!tvShow) {
+ // if lookup of tvshow above failed, then try movie with tmdbid/imdbid
+ // note - some tv shows have imdbid set too, that's why this need to go second
+ if (result?.tmdbId) {
+ mediaIds.tmdbId = result.tmdbId;
+ mediaIds.imdbId = result?.imdbId;
+ } else if (result?.imdbId) {
+ const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ imdbId: result.imdbId,
+ });
+ mediaIds.tmdbId = tmdbMovie.id;
+ mediaIds.imdbId = result.imdbId;
+ }
+ }
+ }
+ }
+
+ if (!mediaIds.tmdbId) {
+ throw new Error('Unable to find TMDb ID');
+ }
+
+ // We check above if we have the TMDb ID, so we can safely assert the type below
+ return mediaIds as MediaIds;
+ }
+
+ // movies with hama agent actually are tv shows with at least one episode in it
+ // try to get first episode of any season - cannot hardcode season or episode number
+ // because sometimes user can have it in other season/ep than s01e01
+ private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
+ const season = metadata.Children?.Metadata[0];
+ if (season) {
+ const episodes = await this.plexClient.getChildrenMetadata(
+ season.ratingKey
+ );
+ if (episodes) {
+ await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
+ }
+ }
+ }
+
+ // this adds all movie episodes from specials season for Hama agent
+ private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
+ const specials = metadata.Children?.Metadata.find(
+ (md) => Number(md.index) === 0
+ );
+ if (specials) {
+ const episodes = await this.plexClient.getChildrenMetadata(
+ specials.ratingKey
+ );
+ if (episodes) {
+ for (const episode of episodes) {
+ const special = animeList.getSpecialEpisode(tvdbId, episode.index);
+ if (special) {
+ if (special.tmdbId) {
+ await this.processPlexMovieByTmdbId(episode, special.tmdbId);
+ } else if (special.imdbId) {
+ const tmdbMovie = await this.tmdb.getMovieByImdbId({
+ imdbId: special.imdbId,
+ });
+ await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // checks if any of this.libraries has Hama agent set in Plex
+ private async hasHamaAgent() {
+ const plexLibraries = await this.plexClient.getLibraries();
+ return this.libraries.some((library) =>
+ plexLibraries.some(
+ (plexLibrary) =>
+ plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
+ )
+ );
+ }
+}
+
+export const plexFullScanner = new PlexScanner();
+export const plexRecentScanner = new PlexScanner(true);
diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts
new file mode 100644
index 000000000..4c4e6e7fc
--- /dev/null
+++ b/server/lib/scanners/radarr/index.ts
@@ -0,0 +1,105 @@
+import { uniqWith } from 'lodash';
+import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
+import { getSettings, RadarrSettings } from '../../settings';
+import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
+
+type SyncStatus = StatusBase & {
+ currentServer: RadarrSettings;
+ servers: RadarrSettings[];
+};
+
+class RadarrScanner
+ extends BaseScanner
+ implements RunnableScanner {
+ private servers: RadarrSettings[];
+ private currentServer: RadarrSettings;
+ private radarrApi: RadarrAPI;
+
+ constructor() {
+ super('Radarr Scan', { bundleSize: 50 });
+ }
+
+ public status(): SyncStatus {
+ return {
+ running: this.running,
+ progress: this.progress,
+ total: this.items.length,
+ currentServer: this.currentServer,
+ servers: this.servers,
+ };
+ }
+
+ public async run(): Promise {
+ const settings = getSettings();
+ const sessionId = this.startRun();
+
+ try {
+ this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
+ return (
+ radarrA.hostname === radarrB.hostname &&
+ radarrA.port === radarrB.port &&
+ radarrA.baseUrl === radarrB.baseUrl
+ );
+ });
+
+ for (const server of this.servers) {
+ this.currentServer = server;
+ if (server.syncEnabled) {
+ this.log(
+ `Beginning to process Radarr server: ${server.name}`,
+ 'info'
+ );
+
+ this.radarrApi = new RadarrAPI({
+ apiKey: server.apiKey,
+ url: RadarrAPI.buildUrl(server, '/api/v3'),
+ });
+
+ this.items = await this.radarrApi.getMovies();
+
+ await this.loop(this.processRadarrMovie.bind(this), { sessionId });
+ } else {
+ this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
+ }
+ }
+
+ this.log('Radarr scan complete', 'info');
+ } catch (e) {
+ this.log('Scan interrupted', 'error', { errorMessage: e.message });
+ } finally {
+ this.endRun(sessionId);
+ }
+ }
+
+ private async processRadarrMovie(radarrMovie: RadarrMovie): Promise {
+ if (!radarrMovie.monitored && !radarrMovie.downloaded) {
+ this.log(
+ 'Title is unmonitored and has not been downloaded. Skipping item.',
+ 'debug',
+ {
+ title: radarrMovie.title,
+ }
+ );
+ return;
+ }
+
+ try {
+ const server4k = this.enable4kMovie && this.currentServer.is4k;
+ await this.processMovie(radarrMovie.tmdbId, {
+ is4k: server4k,
+ serviceId: this.currentServer.id,
+ externalServiceId: radarrMovie.id,
+ externalServiceSlug: radarrMovie.titleSlug,
+ title: radarrMovie.title,
+ processing: !radarrMovie.downloaded,
+ });
+ } catch (e) {
+ this.log('Failed to process Radarr media', 'error', {
+ errorMessage: e.message,
+ title: radarrMovie.title,
+ });
+ }
+ }
+}
+
+export const radarrScanner = new RadarrScanner();
diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts
new file mode 100644
index 000000000..73500db9f
--- /dev/null
+++ b/server/lib/scanners/sonarr/index.ts
@@ -0,0 +1,134 @@
+import { uniqWith } from 'lodash';
+import { getRepository } from 'typeorm';
+import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
+import Media from '../../../entity/Media';
+import { getSettings, SonarrSettings } from '../../settings';
+import BaseScanner, {
+ ProcessableSeason,
+ RunnableScanner,
+ StatusBase,
+} from '../baseScanner';
+
+type SyncStatus = StatusBase & {
+ currentServer: SonarrSettings;
+ servers: SonarrSettings[];
+};
+
+class SonarrScanner
+ extends BaseScanner
+ implements RunnableScanner {
+ private servers: SonarrSettings[];
+ private currentServer: SonarrSettings;
+ private sonarrApi: SonarrAPI;
+
+ constructor() {
+ super('Sonarr Scan', { bundleSize: 50 });
+ }
+
+ public status(): SyncStatus {
+ return {
+ running: this.running,
+ progress: this.progress,
+ total: this.items.length,
+ currentServer: this.currentServer,
+ servers: this.servers,
+ };
+ }
+
+ public async run(): Promise {
+ const settings = getSettings();
+ const sessionId = this.startRun();
+
+ try {
+ this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
+ return (
+ sonarrA.hostname === sonarrB.hostname &&
+ sonarrA.port === sonarrB.port &&
+ sonarrA.baseUrl === sonarrB.baseUrl
+ );
+ });
+
+ for (const server of this.servers) {
+ this.currentServer = server;
+ if (server.syncEnabled) {
+ this.log(
+ `Beginning to process Sonarr server: ${server.name}`,
+ 'info'
+ );
+
+ this.sonarrApi = new SonarrAPI({
+ apiKey: server.apiKey,
+ url: SonarrAPI.buildUrl(server, '/api/v3'),
+ });
+
+ this.items = await this.sonarrApi.getSeries();
+
+ await this.loop(this.processSonarrSeries.bind(this), { sessionId });
+ } else {
+ this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
+ }
+ }
+
+ this.log('Sonarr scan complete', 'info');
+ } catch (e) {
+ this.log('Scan interrupted', 'error', { errorMessage: e.message });
+ } finally {
+ this.endRun(sessionId);
+ }
+ }
+
+ private async processSonarrSeries(sonarrSeries: SonarrSeries) {
+ try {
+ const mediaRepository = getRepository(Media);
+ const server4k = this.enable4kShow && this.currentServer.is4k;
+ const processableSeasons: ProcessableSeason[] = [];
+ let tmdbId: number;
+
+ const media = await mediaRepository.findOne({
+ where: { tvdbId: sonarrSeries.tvdbId },
+ });
+
+ if (!media || !media.tmdbId) {
+ const tvShow = await this.tmdb.getShowByTvdbId({
+ tvdbId: sonarrSeries.tvdbId,
+ });
+
+ tmdbId = tvShow.id;
+ } else {
+ tmdbId = media.tmdbId;
+ }
+
+ const filteredSeasons = sonarrSeries.seasons.filter(
+ (sn) => sn.seasonNumber !== 0
+ );
+
+ for (const season of filteredSeasons) {
+ const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
+
+ processableSeasons.push({
+ seasonNumber: season.seasonNumber,
+ episodes: !server4k ? totalAvailableEpisodes : 0,
+ episodes4k: server4k ? totalAvailableEpisodes : 0,
+ totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
+ processing: season.monitored && totalAvailableEpisodes === 0,
+ is4kOverride: server4k,
+ });
+ }
+
+ await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
+ serviceId: this.currentServer.id,
+ externalServiceId: sonarrSeries.id,
+ externalServiceSlug: sonarrSeries.titleSlug,
+ title: sonarrSeries.title,
+ is4k: server4k,
+ });
+ } catch (e) {
+ this.log('Failed to process Sonarr media', 'error', {
+ errorMessage: e.message,
+ title: sonarrSeries.title,
+ });
+ }
+ }
+}
+
+export const sonarrScanner = new SonarrScanner();
diff --git a/server/lib/settings.ts b/server/lib/settings.ts
index a988bfd6a..cfef7729a 100644
--- a/server/lib/settings.ts
+++ b/server/lib/settings.ts
@@ -1,19 +1,23 @@
+import { randomUUID } from 'crypto';
import fs from 'fs';
-import path from 'path';
import { merge } from 'lodash';
-import { v4 as uuidv4 } from 'uuid';
-import { Permission } from './permissions';
+import path from 'path';
+import webpush from 'web-push';
import { MediaServerType } from '../constants/server';
+import { Permission } from './permissions';
export interface Library {
id: string;
name: string;
enabled: boolean;
+ type: 'show' | 'movie';
+ lastScan?: number;
}
export interface Region {
iso_3166_1: string;
english_name: string;
+ name?: string;
}
export interface Language {
@@ -29,6 +33,7 @@ export interface PlexSettings {
port: number;
useSsl?: boolean;
libraries: Library[];
+ webAppUrl?: string;
}
export interface JellyfinSettings {
@@ -38,7 +43,7 @@ export interface JellyfinSettings {
serverId: string;
}
-interface DVRSettings {
+export interface DVRSettings {
id: number;
name: string;
hostname: string;
@@ -49,6 +54,7 @@ interface DVRSettings {
activeProfileId: number;
activeProfileName: string;
activeDirectory: string;
+ tags: number[];
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
@@ -66,21 +72,35 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number;
+ animeTags?: number[];
enableSeasonFolders: boolean;
}
+interface Quota {
+ quotaLimit?: number;
+ quotaDays?: number;
+}
+
export interface MainSettings {
apiKey: string;
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
+ cacheImages: boolean;
defaultPermissions: number;
+ defaultQuotas: {
+ movie: Quota;
+ tv: Quota;
+ };
hideAvailable: boolean;
localLogin: boolean;
+ newPlexLogin: boolean;
region: string;
originalLanguage: string;
trustProxy: boolean;
mediaServerType: number;
+ partialRequestsEnabled: boolean;
+ locale: string;
}
interface PublicSettings {
@@ -89,6 +109,7 @@ interface PublicSettings {
interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
+ applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
@@ -98,15 +119,23 @@ interface FullPublicSettings extends PublicSettings {
mediaServerType: number;
jellyfinHost?: string;
jellyfinServerName?: string;
+ partialRequestsEnabled: boolean;
+ cacheImages: boolean;
+ vapidPublic: string;
+ enablePushRegistration: boolean;
+ locale: string;
+ emailEnabled: boolean;
}
export interface NotificationAgentConfig {
enabled: boolean;
- types: number;
+ types?: number;
options: Record;
}
export interface NotificationAgentDiscord extends NotificationAgentConfig {
options: {
+ botUsername?: string;
+ botAvatarUrl?: string;
webhookUrl: string;
};
}
@@ -123,15 +152,27 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
smtpHost: string;
smtpPort: number;
secure: boolean;
+ ignoreTls: boolean;
+ requireTls: boolean;
authUser?: string;
authPass?: string;
allowSelfSigned: boolean;
senderName: string;
+ pgpPrivateKey?: string;
+ pgpPassword?: string;
+ };
+}
+
+export interface NotificationAgentLunaSea extends NotificationAgentConfig {
+ options: {
+ webhookUrl: string;
+ profileName?: string;
};
}
export interface NotificationAgentTelegram extends NotificationAgentConfig {
options: {
+ botUsername?: string;
botAPI: string;
chatId: string;
sendSilently: boolean;
@@ -148,7 +189,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
- priority: number;
};
}
@@ -156,28 +196,41 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
options: {
webhookUrl: string;
jsonPayload: string;
- authHeader: string;
+ authHeader?: string;
};
}
+export enum NotificationAgentKey {
+ DISCORD = 'discord',
+ EMAIL = 'email',
+ PUSHBULLET = 'pushbullet',
+ PUSHOVER = 'pushover',
+ SLACK = 'slack',
+ TELEGRAM = 'telegram',
+ WEBHOOK = 'webhook',
+ WEBPUSH = 'webpush',
+}
+
interface NotificationAgents {
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
+ lunasea: NotificationAgentLunaSea;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
slack: NotificationAgentSlack;
telegram: NotificationAgentTelegram;
webhook: NotificationAgentWebhook;
+ webpush: NotificationAgentConfig;
}
interface NotificationSettings {
- enabled: boolean;
- autoapprovalEnabled: boolean;
agents: NotificationAgents;
}
interface AllSettings {
clientId: string;
+ vapidPublic: string;
+ vapidPrivate: string;
main: MainSettings;
plex: PlexSettings;
jellyfin: JellyfinSettings;
@@ -196,23 +249,33 @@ class Settings {
constructor(initialSettings?: AllSettings) {
this.data = {
- clientId: uuidv4(),
+ clientId: randomUUID(),
+ vapidPrivate: '',
+ vapidPublic: '',
main: {
apiKey: '',
applicationTitle: 'Overseerr',
applicationUrl: '',
csrfProtection: false,
+ cacheImages: false,
defaultPermissions: Permission.REQUEST,
+ defaultQuotas: {
+ movie: {},
+ tv: {},
+ },
hideAvailable: false,
localLogin: true,
+ newPlexLogin: true,
region: '',
originalLanguage: '',
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
+ partialRequestsEnabled: true,
+ locale: 'en',
},
plex: {
name: '',
- ip: '127.0.0.1',
+ ip: '',
port: 32400,
useSsl: false,
libraries: [],
@@ -229,17 +292,16 @@ class Settings {
initialized: false,
},
notifications: {
- enabled: true,
- autoapprovalEnabled: false,
agents: {
email: {
enabled: false,
- types: 0,
options: {
emailFrom: '',
- smtpHost: '127.0.0.1',
+ smtpHost: '',
smtpPort: 587,
secure: false,
+ ignoreTls: false,
+ requireTls: false,
allowSelfSigned: false,
senderName: 'Overseerr',
},
@@ -251,6 +313,13 @@ class Settings {
webhookUrl: '',
},
},
+ lunasea: {
+ enabled: false,
+ types: 0,
+ options: {
+ webhookUrl: '',
+ },
+ },
slack: {
enabled: false,
types: 0,
@@ -280,7 +349,6 @@ class Settings {
options: {
accessToken: '',
userToken: '',
- priority: 0,
},
},
webhook: {
@@ -288,11 +356,14 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
- authHeader: '',
jsonPayload:
- 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
+ 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
},
},
+ webpush: {
+ enabled: false,
+ options: {},
+ },
},
},
};
@@ -357,6 +428,7 @@ class Settings {
return {
...this.data.public,
applicationTitle: this.data.main.applicationTitle,
+ applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
movie4kEnabled: this.data.radarr.some(
@@ -369,6 +441,12 @@ class Settings {
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
+ partialRequestsEnabled: this.data.main.partialRequestsEnabled,
+ cacheImages: this.data.main.cacheImages,
+ vapidPublic: this.vapidPublic,
+ enablePushRegistration: this.data.notifications.agents.webpush.enabled,
+ locale: this.data.main.locale,
+ emailEnabled: this.data.notifications.agents.email.enabled,
};
}
@@ -382,13 +460,25 @@ class Settings {
get clientId(): string {
if (!this.data.clientId) {
- this.data.clientId = uuidv4();
+ this.data.clientId = randomUUID();
this.save();
}
return this.data.clientId;
}
+ get vapidPublic(): string {
+ this.generateVapidKeys();
+
+ return this.data.vapidPublic;
+ }
+
+ get vapidPrivate(): string {
+ this.generateVapidKeys();
+
+ return this.data.vapidPrivate;
+ }
+
public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey();
this.save();
@@ -396,7 +486,16 @@ class Settings {
}
private generateApiKey(): string {
- return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
+ return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
+ }
+
+ private generateVapidKeys(force = false): void {
+ if (!this.data.vapidPublic || !this.data.vapidPrivate || force) {
+ const vapidKeys = webpush.generateVAPIDKeys();
+ this.data.vapidPrivate = vapidKeys.privateKey;
+ this.data.vapidPublic = vapidKeys.publicKey;
+ this.save();
+ }
}
/**
diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts
index 6d36bb2f9..68869222f 100644
--- a/server/middleware/auth.ts
+++ b/server/middleware/auth.ts
@@ -5,31 +5,35 @@ import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
const settings = getSettings();
+ let user: User | undefined;
+
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);
let userId = 1; // Work on original administrator account
- // If a User ID is provided, we will act on that users behalf
+ // If a User ID is provided, we will act on that user's behalf
if (req.header('X-API-User')) {
userId = Number(req.header('X-API-User'));
}
- const user = await userRepository.findOne({ where: { id: userId } });
- if (user) {
- req.user = user;
- }
+ user = await userRepository.findOne({ where: { id: userId } });
} else if (req.session?.userId) {
const userRepository = getRepository(User);
- const user = await userRepository.findOne({
+ user = await userRepository.findOne({
where: { id: req.session.userId },
});
-
- if (user) {
- req.user = user;
- }
}
+
+ if (user) {
+ req.user = user;
+ }
+
+ req.locale = user?.settings?.locale
+ ? user.settings.locale
+ : settings.main.locale;
+
next();
};
diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts
new file mode 100644
index 000000000..1e0175cc1
--- /dev/null
+++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddTelegramSettingsToUserSettings1614334195680
+ implements MigrationInterface {
+ name = 'AddTelegramSettingsToUserSettings1614334195680';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts
new file mode 100644
index 000000000..b88e0dcaa
--- /dev/null
+++ b/server/migration/1615333940450-AddPGPToUserSettings.ts
@@ -0,0 +1,31 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddPGPToUserSettings1615333940450 implements MigrationInterface {
+ name = 'AddPGPToUserSettings1615333940450';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts
new file mode 100644
index 000000000..44947baba
--- /dev/null
+++ b/server/migration/1616576677254-AddUserQuotaFields.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserQuotaFields1616576677254 implements MigrationInterface {
+ name = 'AddUserQuotaFields1616576677254';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"`
+ );
+ await queryRunner.query(`DROP TABLE "user"`);
+ await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
+ await queryRunner.query(
+ `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user"`);
+ }
+}
diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts
new file mode 100644
index 000000000..c8bd6dd45
--- /dev/null
+++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts
@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateTagsFieldonMediaRequest1617624225464
+ implements MigrationInterface {
+ name = 'CreateTagsFieldonMediaRequest1617624225464';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "media_request"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "temporary_media_request"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_media_request"`);
+ }
+}
diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts
new file mode 100644
index 000000000..86a52c089
--- /dev/null
+++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts
@@ -0,0 +1,52 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserSettingsNotificationAgentsField1617730837489
+ implements MigrationInterface {
+ name = 'AddUserSettingsNotificationAgentsField1617730837489';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts
new file mode 100644
index 000000000..90ea0d3f9
--- /dev/null
+++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts
@@ -0,0 +1,36 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateUserPushSubscriptions1618912653565
+ implements MigrationInterface {
+ name = 'CreateUserPushSubscriptions1618912653565';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
+ );
+ await queryRunner.query(`DROP TABLE "user_push_subscription"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
+ await queryRunner.query(`DROP TABLE "user_push_subscription"`);
+ }
+}
diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts
new file mode 100644
index 000000000..9842bca71
--- /dev/null
+++ b/server/migration/1619239659754-AddUserSettingsLocale.ts
@@ -0,0 +1,31 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserSettingsLocale1619239659754 implements MigrationInterface {
+ name = 'AddUserSettingsLocale1619239659754';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts
new file mode 100644
index 000000000..67d770722
--- /dev/null
+++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts
@@ -0,0 +1,52 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddUserSettingsNotificationTypes1619339817343
+ implements MigrationInterface {
+ name = 'AddUserSettingsNotificationTypes1619339817343';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ await queryRunner.query(
+ `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
+ );
+ await queryRunner.query(
+ `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
+ );
+ await queryRunner.query(
+ `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
+ );
+ await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
+ }
+}
diff --git a/server/models/Movie.ts b/server/models/Movie.ts
index be4828ec6..ff8a96a47 100644
--- a/server/models/Movie.ts
+++ b/server/models/Movie.ts
@@ -1,19 +1,22 @@
import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
+ TmdbProductionCompany,
} from '../api/themoviedb/interfaces';
+import Media from '../entity/Media';
import {
- ProductionCompany,
- Genre,
Cast,
Crew,
+ ExternalIds,
+ Genre,
mapCast,
mapCrew,
- ExternalIds,
mapExternalIds,
mapVideos,
+ mapWatchProviders,
+ ProductionCompany,
+ WatchProviders,
} from './common';
-import Media from '../entity/Media';
export interface Video {
url?: string;
@@ -77,8 +80,21 @@ export interface MovieDetails {
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
+ watchProviders?: WatchProviders[];
}
+export const mapProductionCompany = (
+ company: TmdbProductionCompany
+): ProductionCompany => ({
+ id: company.id,
+ name: company.name,
+ originCountry: company.origin_country,
+ description: company.description,
+ headquarters: company.headquarters,
+ homepage: company.homepage,
+ logoPath: company.logo_path,
+});
+
export const mapMovieDetails = (
movie: TmdbMovieDetails,
media?: Media
@@ -91,12 +107,7 @@ export const mapMovieDetails = (
originalLanguage: movie.original_language,
originalTitle: movie.original_title,
popularity: movie.popularity,
- productionCompanies: movie.production_companies.map((company) => ({
- id: company.id,
- logoPath: company.logo_path,
- originCountry: company.origin_country,
- name: company.name,
- })),
+ productionCompanies: movie.production_companies.map(mapProductionCompany),
productionCountries: movie.production_countries,
releaseDate: movie.release_date,
releases: movie.release_dates,
@@ -128,4 +139,5 @@ export const mapMovieDetails = (
: undefined,
externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media,
+ watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
});
diff --git a/server/models/Person.ts b/server/models/Person.ts
index 522a8e5e9..14925edb6 100644
--- a/server/models/Person.ts
+++ b/server/models/Person.ts
@@ -8,6 +8,7 @@ import Media from '../entity/Media';
export interface PersonDetail {
id: number;
name: string;
+ birthday: string;
deathday: string;
knownForDepartment: string;
alsoKnownAs?: string[];
@@ -64,6 +65,7 @@ export interface CombinedCredit {
export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({
id: person.id,
name: person.name,
+ birthday: person.birthday,
deathday: person.deathday,
knownForDepartment: person.known_for_department,
alsoKnownAs: person.also_known_as,
diff --git a/server/models/Tv.ts b/server/models/Tv.ts
index 3631573ea..9f6b25687 100644
--- a/server/models/Tv.ts
+++ b/server/models/Tv.ts
@@ -1,23 +1,27 @@
-import {
- Genre,
- ProductionCompany,
- Cast,
- Crew,
- mapAggregateCast,
- mapCrew,
- ExternalIds,
- mapExternalIds,
- Keyword,
- mapVideos,
-} from './common';
import type {
- TmdbTvEpisodeResult,
- TmdbTvSeasonResult,
- TmdbTvDetails,
+ TmdbNetwork,
TmdbSeasonWithEpisodes,
+ TmdbTvDetails,
+ TmdbTvEpisodeResult,
TmdbTvRatingResult,
+ TmdbTvSeasonResult,
} from '../api/themoviedb/interfaces';
import type Media from '../entity/Media';
+import {
+ Cast,
+ Crew,
+ ExternalIds,
+ Genre,
+ Keyword,
+ mapAggregateCast,
+ mapCrew,
+ mapExternalIds,
+ mapVideos,
+ mapWatchProviders,
+ ProductionCompany,
+ TvNetwork,
+ WatchProviders,
+} from './common';
import { Video } from './Movie';
interface Episode {
@@ -77,7 +81,7 @@ export interface TvDetails {
lastEpisodeToAir?: Episode;
name: string;
nextEpisodeToAir?: Episode;
- networks: ProductionCompany[];
+ networks: TvNetwork[];
numberOfEpisodes: number;
numberOfSeasons: number;
originCountry: string[];
@@ -89,6 +93,7 @@ export interface TvDetails {
spokenLanguages: SpokenLanguage[];
seasons: Season[];
status: string;
+ tagline?: string;
type: string;
voteAverage: number;
voteCount: number;
@@ -99,6 +104,7 @@ export interface TvDetails {
externalIds: ExternalIds;
keywords: Keyword[];
mediaInfo?: Media;
+ watchProviders?: WatchProviders[];
}
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
@@ -139,6 +145,15 @@ export const mapSeasonWithEpisodes = (
posterPath: season.poster_path,
});
+export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
+ id: network.id,
+ name: network.name,
+ originCountry: network.origin_country,
+ headquarters: network.headquarters,
+ homepage: network.homepage,
+ logoPath: network.logo_path,
+});
+
export const mapTvDetails = (
show: TmdbTvDetails,
media?: Media
@@ -157,17 +172,13 @@ export const mapTvDetails = (
languages: show.languages,
lastAirDate: show.last_air_date,
name: show.name,
- networks: show.networks.map((network) => ({
- id: network.id,
- name: network.name,
- originCountry: network.origin_country,
- logoPath: network.logo_path,
- })),
+ networks: show.networks.map(mapNetwork),
numberOfEpisodes: show.number_of_episodes,
numberOfSeasons: show.number_of_seasons,
originCountry: show.origin_country,
originalLanguage: show.original_language,
originalName: show.original_name,
+ tagline: show.tagline,
overview: show.overview,
popularity: show.popularity,
productionCompanies: show.production_companies.map((company) => ({
@@ -205,4 +216,5 @@ export const mapTvDetails = (
name: keyword.name,
})),
mediaInfo: media,
+ watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
});
diff --git a/server/models/common.ts b/server/models/common.ts
index d26cf6379..49e2305cb 100644
--- a/server/models/common.ts
+++ b/server/models/common.ts
@@ -1,12 +1,13 @@
import type {
- TmdbCreditCast,
TmdbAggregateCreditCast,
+ TmdbCreditCast,
TmdbCreditCrew,
TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
+ TmdbWatchProviderDetails,
+ TmdbWatchProviders,
} from '../api/themoviedb/interfaces';
-
import { Video } from '../models/Movie';
export interface ProductionCompany {
@@ -14,6 +15,18 @@ export interface ProductionCompany {
logoPath?: string;
originCountry: string;
name: string;
+ description?: string;
+ headquarters?: string;
+ homepage?: string;
+}
+
+export interface TvNetwork {
+ id: number;
+ logoPath?: string;
+ originCountry?: string;
+ name: string;
+ headquarters?: string;
+ homepage?: string;
}
export interface Keyword {
@@ -58,6 +71,20 @@ export interface ExternalIds {
twitterId?: string;
}
+export interface WatchProviders {
+ iso_3166_1: string;
+ link?: string;
+ buy?: WatchProviderDetails[];
+ flatrate?: WatchProviderDetails[];
+}
+
+export interface WatchProviderDetails {
+ displayPriority?: number;
+ logoPath?: string;
+ id: number;
+ name: string;
+}
+
export const mapCast = (person: TmdbCreditCast): Cast => ({
castId: person.cast_id,
character: person.character,
@@ -112,7 +139,33 @@ export const mapVideos = (videoResult: TmdbVideoResult): Video[] =>
url: siteUrlCreator(site, key),
}));
+export const mapWatchProviders = (watchProvidersResult: {
+ [iso_3166_1: string]: TmdbWatchProviders;
+}): WatchProviders[] =>
+ Object.entries(watchProvidersResult).map(
+ ([iso_3166_1, provider]) =>
+ ({
+ iso_3166_1,
+ link: provider.link,
+ buy: mapWatchProviderDetails(provider.buy ?? []),
+ flatrate: mapWatchProviderDetails(provider.flatrate ?? []),
+ } as WatchProviders)
+ );
+
+export const mapWatchProviderDetails = (
+ watchProviderDetails: TmdbWatchProviderDetails[]
+): WatchProviderDetails[] =>
+ watchProviderDetails.map(
+ (provider) =>
+ ({
+ displayPriority: provider.display_priority,
+ logoPath: provider.logo_path,
+ id: provider.provider_id,
+ name: provider.provider_name,
+ } as WatchProviderDetails)
+ );
+
const siteUrlCreator = (site: Video['site'], key: string): string =>
({
- YouTube: `https://www.youtube.com/watch?v=${key}/`,
+ YouTube: `https://www.youtube.com/watch?v=${key}`,
}[site]);
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index 8e561400d..75e25d4ed 100644
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -1,14 +1,14 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
-import { User } from '../entity/User';
-import PlexTvAPI from '../api/plextv';
import JellyfinAPI from '../api/jellyfin';
-import { isAuthenticated } from '../middleware/auth';
-import { Permission } from '../lib/permissions';
-import logger from '../logger';
-import { getSettings } from '../lib/settings';
-import { UserType } from '../constants/user';
+import PlexTvAPI from '../api/plextv';
import { MediaServerType } from '../constants/server';
+import { UserType } from '../constants/user';
+import { User } from '../entity/User';
+import { Permission } from '../lib/permissions';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import { isAuthenticated } from '../middleware/auth';
const authRoutes = Router();
@@ -51,9 +51,13 @@ authRoutes.post('/plex', async (req, res, next) => {
const account = await plextv.getUser();
// Next let's see if the user already exists
- let user = await userRepository.findOne({
- where: { plexId: account.id },
- });
+ let user = await userRepository
+ .createQueryBuilder('user')
+ .where('user.plexId = :id', { id: account.id })
+ .orWhere('user.email = :email', {
+ email: account.email.toLowerCase(),
+ })
+ .getOne();
if (user) {
// Let's check if their Plex token is up-to-date
@@ -66,9 +70,12 @@ authRoutes.post('/plex', async (req, res, next) => {
user.email = account.email;
user.plexUsername = account.username;
- if (user.username === account.username) {
- user.username = '';
+ // In case the user was previously a local account
+ if (user.userType === UserType.LOCAL) {
+ user.userType = UserType.PLEX;
+ user.plexId = account.id;
}
+
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
@@ -93,6 +100,24 @@ authRoutes.post('/plex', async (req, res, next) => {
// 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.',
+ {
+ label: 'Auth',
+ account: {
+ ...account,
+ authentication_token: '__REDACTED__',
+ authToken: '__REDACTED__',
+ },
+ }
+ );
+ 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({
@@ -126,7 +151,7 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
- message: 'You do not have access to this Plex server.',
+ message: 'Access denied.',
});
}
}
@@ -142,7 +167,7 @@ authRoutes.post('/plex', async (req, res, next) => {
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
- message: 'Something went wrong. Is your auth token valid?',
+ message: 'Something went wrong.',
});
}
});
@@ -322,10 +347,11 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
try {
- const user = await userRepository.findOne({
- select: ['id', 'password'],
- where: { email: body.email },
- });
+ const user = await userRepository
+ .createQueryBuilder('user')
+ .select(['user.id', 'user.password'])
+ .where('user.email = :email', { email: body.email.toLowerCase() })
+ .getOne();
const isCorrectCredentials = await user?.passwordMatch(body.password);
@@ -389,9 +415,10 @@ authRoutes.post('/reset-password', async (req, res) => {
.json({ error: 'You must provide an email address.' });
}
- const user = await userRepository.findOne({
- where: { email: body.email },
- });
+ const user = await userRepository
+ .createQueryBuilder('user')
+ .where('user.email = :email', { email: body.email.toLowerCase() })
+ .getOne();
if (user) {
await user.resetPassword();
diff --git a/server/routes/collection.ts b/server/routes/collection.ts
index 75f1a455f..8ffbb51c9 100644
--- a/server/routes/collection.ts
+++ b/server/routes/collection.ts
@@ -11,7 +11,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const collection = await tmdb.getCollection({
collectionId: Number(req.params.id),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
diff --git a/server/routes/discover.ts b/server/routes/discover.ts
index e248870ae..dd3a9fa66 100644
--- a/server/routes/discover.ts
+++ b/server/routes/discover.ts
@@ -1,11 +1,16 @@
import { Router } from 'express';
+import { sortBy } from 'lodash';
import TheMovieDb from '../api/themoviedb';
-import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search';
-import Media from '../entity/Media';
-import { isMovie, isPerson } from '../utils/typeHelpers';
import { MediaType } from '../constants/media';
-import { getSettings } from '../lib/settings';
+import Media from '../entity/Media';
import { User } from '../entity/User';
+import { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
+import { getSettings } from '../lib/settings';
+import logger from '../logger';
+import { mapProductionCompany } from '../models/Movie';
+import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
+import { mapNetwork } from '../models/Tv';
+import { isMovie, isPerson } from '../utils/typeHelpers';
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -37,7 +42,9 @@ discoverRoutes.get('/movies', async (req, res) => {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
- language: req.query.language as string,
+ 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(
@@ -59,6 +66,133 @@ discoverRoutes.get('/movies', async (req, res) => {
});
});
+discoverRoutes.get<{ language: string }>(
+ '/movies/language/:language',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanaguage(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.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
+ )
+ )
+ ),
+ });
+ }
+);
+
+discoverRoutes.get<{ genreId: string }>(
+ '/movies/genre/:genreId',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanaguage(req.user);
+
+ const genres = await tmdb.getMovieGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ const genre = genres.find(
+ (genre) => genre.id === Number(req.params.genreId)
+ );
+
+ if (!genre) {
+ return next({ status: 404, message: 'Unable to retrieve genre' });
+ }
+
+ 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)
+ );
+
+ 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
+ )
+ )
+ ),
+ });
+ }
+);
+
+discoverRoutes.get<{ studioId: string }>(
+ '/movies/studio/:studioId',
+ async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const studio = await tmdb.getStudio(Number(req.params.studioId));
+
+ const data = await tmdb.getDiscoverMovies({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ studio: Number(req.params.studioId),
+ });
+
+ 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,
+ studio: mapProductionCompany(studio),
+ results: data.results.map((result) =>
+ mapMovieResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ return next({ status: 404, message: 'Unable to retrieve studio' });
+ }
+ }
+);
+
discoverRoutes.get('/movies/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
@@ -70,7 +204,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
primaryReleaseDateGte: date,
});
@@ -98,7 +232,9 @@ discoverRoutes.get('/tv', async (req, res) => {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
- language: req.query.language as string,
+ 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(
@@ -120,6 +256,131 @@ discoverRoutes.get('/tv', async (req, res) => {
});
});
+discoverRoutes.get<{ language: string }>(
+ '/tv/language/:language',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanaguage(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({
+ 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
+ )
+ )
+ ),
+ });
+ }
+);
+
+discoverRoutes.get<{ genreId: string }>(
+ '/tv/genre/:genreId',
+ async (req, res, next) => {
+ const tmdb = createTmdbWithRegionLanaguage(req.user);
+
+ const genres = await tmdb.getTvGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ const genre = genres.find(
+ (genre) => genre.id === Number(req.params.genreId)
+ );
+
+ if (!genre) {
+ return next({ status: 404, message: 'Unable to retrieve genre' });
+ }
+
+ 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)
+ );
+
+ 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
+ )
+ )
+ ),
+ });
+ }
+);
+
+discoverRoutes.get<{ networkId: string }>(
+ '/tv/network/:networkId',
+ async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const network = await tmdb.getNetwork(Number(req.params.networkId));
+
+ const data = await tmdb.getDiscoverTv({
+ page: Number(req.query.page),
+ language: req.locale ?? (req.query.language as string),
+ network: Number(req.params.networkId),
+ });
+
+ 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,
+ network: mapNetwork(network),
+ results: data.results.map((result) =>
+ mapTvResult(
+ result,
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
+ )
+ ),
+ });
+ } catch (e) {
+ return next({ status: 404, message: 'Unable to retrieve network' });
+ }
+ }
+);
+
discoverRoutes.get('/tv/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
@@ -131,7 +392,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
firstAirDateGte: date,
});
@@ -159,7 +420,7 @@ discoverRoutes.get('/trending', async (req, res) => {
const data = await tmdb.getAllTrending({
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -175,15 +436,18 @@ discoverRoutes.get('/trending', async (req, res) => {
? mapMovieResult(
result,
media.find(
- (req) =>
- req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
: isPerson(result)
? mapPersonResult(result)
: mapTvResult(
result,
- media.find((req) => req.tmdbId === result.id && MediaType.TV)
+ media.find(
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.TV
+ )
)
),
});
@@ -197,7 +461,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -212,8 +476,8 @@ discoverRoutes.get<{ keywordId: string }>(
mapMovieResult(
result,
media.find(
- (req) =>
- req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
+ (med) =>
+ med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
@@ -221,4 +485,86 @@ discoverRoutes.get<{ keywordId: string }>(
}
);
+discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
+ '/genreslider/movie',
+ async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const mappedGenres: GenreSliderItem[] = [];
+
+ const genres = await tmdb.getMovieGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ await Promise.all(
+ genres.map(async (genre) => {
+ const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
+
+ mappedGenres.push({
+ id: genre.id,
+ name: genre.name,
+ backdrops: genreData.results
+ .filter((title) => !!title.backdrop_path)
+ .map((title) => title.backdrop_path) as string[],
+ });
+ })
+ );
+
+ const sortedData = sortBy(mappedGenres, 'name');
+
+ return res.status(200).json(sortedData);
+ } catch (e) {
+ logger.error('Something went wrong retrieving the movie genre slider', {
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve movie genre slider.',
+ });
+ }
+ }
+);
+
+discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
+ '/genreslider/tv',
+ async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+
+ try {
+ const mappedGenres: GenreSliderItem[] = [];
+
+ const genres = await tmdb.getTvGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ await Promise.all(
+ genres.map(async (genre) => {
+ const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
+
+ mappedGenres.push({
+ id: genre.id,
+ name: genre.name,
+ backdrops: genreData.results
+ .filter((title) => !!title.backdrop_path)
+ .map((title) => title.backdrop_path) as string[],
+ });
+ })
+ );
+
+ const sortedData = sortBy(mappedGenres, 'name');
+
+ return res.status(200).json(sortedData);
+ } catch (e) {
+ logger.error('Something went wrong retrieving the tv genre slider', {
+ errorMessage: e.message,
+ });
+ return next({
+ status: 500,
+ message: 'Unable to retrieve tv genre slider.',
+ });
+ }
+ }
+);
+
export default discoverRoutes;
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 7527c0304..e99ab3da0 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -1,31 +1,75 @@
import { Router } from 'express';
-import user from './user';
-import authRoutes from './auth';
-import { checkUser, isAuthenticated } from '../middleware/auth';
-import settingsRoutes from './settings';
+import GithubAPI from '../api/github';
+import TheMovieDb from '../api/themoviedb';
+import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
-import searchRoutes from './search';
-import discoverRoutes from './discover';
-import requestRoutes from './request';
-import movieRoutes from './movie';
-import tvRoutes from './tv';
-import mediaRoutes from './media';
-import personRoutes from './person';
-import collectionRoutes from './collection';
+import { checkUser, isAuthenticated } from '../middleware/auth';
+import { mapProductionCompany } from '../models/Movie';
+import { mapNetwork } from '../models/Tv';
+import { appDataPath, appDataStatus } from '../utils/appDataVolume';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
+import authRoutes from './auth';
+import collectionRoutes from './collection';
+import discoverRoutes from './discover';
+import mediaRoutes from './media';
+import movieRoutes from './movie';
+import personRoutes from './person';
+import requestRoutes from './request';
+import searchRoutes from './search';
import serviceRoutes from './service';
-import { appDataStatus, appDataPath } from '../utils/appDataVolume';
-import TheMovieDb from '../api/themoviedb';
+import settingsRoutes from './settings';
+import tvRoutes from './tv';
+import user from './user';
const router = Router();
router.use(checkUser);
-router.get('/status', (req, res) => {
+router.get('/status', async (req, res) => {
+ const githubApi = new GithubAPI();
+
+ const currentVersion = getAppVersion();
+ const commitTag = getCommitTag();
+ let updateAvailable = false;
+ let commitsBehind = 0;
+
+ if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
+ const commits = await githubApi.getOverseerrCommits();
+
+ if (commits.length) {
+ const filteredCommits = commits.filter(
+ (commit) => !commit.commit.message.includes('[skip ci]')
+ );
+ if (filteredCommits[0].sha !== commitTag) {
+ updateAvailable = true;
+ }
+
+ const commitIndex = filteredCommits.findIndex(
+ (commit) => commit.sha === commitTag
+ );
+
+ if (updateAvailable) {
+ commitsBehind = commitIndex;
+ }
+ }
+ } else if (commitTag !== 'local') {
+ const releases = await githubApi.getOverseerrReleases();
+
+ if (releases.length) {
+ const latestVersion = releases[0];
+
+ if (!latestVersion.name.includes(currentVersion)) {
+ updateAvailable = true;
+ }
+ }
+ }
+
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
+ updateAvailable,
+ commitsBehind,
});
});
@@ -37,10 +81,16 @@ router.get('/status/appdata', (_req, res) => {
});
router.use('/user', isAuthenticated(), user);
-router.get('/settings/public', (_req, res) => {
+router.get('/settings/public', async (req, res) => {
const settings = getSettings();
- return res.status(200).json(settings.fullPublicSettings);
+ if (!(req.user?.settings?.notificationTypes.webpush ?? true)) {
+ return res
+ .status(200)
+ .json({ ...settings.fullPublicSettings, enablePushRegistration: false });
+ } else {
+ return res.status(200).json(settings.fullPublicSettings);
+ }
});
router.use(
'/settings',
@@ -74,6 +124,42 @@ router.get('/languages', isAuthenticated(), async (req, res) => {
return res.status(200).json(languages);
});
+router.get<{ id: string }>('/studio/:id', async (req, res) => {
+ const tmdb = new TheMovieDb();
+
+ const studio = await tmdb.getStudio(Number(req.params.id));
+
+ return res.status(200).json(mapProductionCompany(studio));
+});
+
+router.get<{ id: string }>('/network/:id', async (req, res) => {
+ const tmdb = new TheMovieDb();
+
+ const network = await tmdb.getNetwork(Number(req.params.id));
+
+ return res.status(200).json(mapNetwork(network));
+});
+
+router.get('/genres/movie', isAuthenticated(), async (req, res) => {
+ const tmdb = new TheMovieDb();
+
+ const genres = await tmdb.getMovieGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ return res.status(200).json(genres);
+});
+
+router.get('/genres/tv', isAuthenticated(), async (req, res) => {
+ const tmdb = new TheMovieDb();
+
+ const genres = await tmdb.getTvGenres({
+ language: req.locale ?? (req.query.language as string),
+ });
+
+ return res.status(200).json(genres);
+});
+
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',
diff --git a/server/routes/movie.ts b/server/routes/movie.ts
index cadaf5a7e..d871652a4 100644
--- a/server/routes/movie.ts
+++ b/server/routes/movie.ts
@@ -1,11 +1,11 @@
import { Router } from 'express';
+import RottenTomatoes from '../api/rottentomatoes';
import TheMovieDb from '../api/themoviedb';
+import { MediaType } from '../constants/media';
+import Media from '../entity/Media';
+import logger from '../logger';
import { mapMovieDetails } from '../models/Movie';
import { mapMovieResult } from '../models/Search';
-import Media from '../entity/Media';
-import RottenTomatoes from '../api/rottentomatoes';
-import logger from '../logger';
-import { MediaType } from '../constants/media';
const movieRoutes = Router();
@@ -15,7 +15,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try {
const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@@ -36,7 +36,7 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id),
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -64,7 +64,7 @@ movieRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id),
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
diff --git a/server/routes/person.ts b/server/routes/person.ts
index 7b8d90c4f..e18e55c84 100644
--- a/server/routes/person.ts
+++ b/server/routes/person.ts
@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try {
const person = await tmdb.getPerson({
personId: Number(req.params.id),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapPersonDetails(person));
} catch (e) {
@@ -30,7 +30,7 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const castMedia = await Media.getRelatedMedia(
diff --git a/server/routes/request.ts b/server/routes/request.ts
index afec0d258..8fed74107 100644
--- a/server/routes/request.ts
+++ b/server/routes/request.ts
@@ -1,15 +1,15 @@
import { Router } from 'express';
-import { isAuthenticated } from '../middleware/auth';
-import { Permission } from '../lib/permissions';
import { getRepository } from 'typeorm';
-import { MediaRequest } from '../entity/MediaRequest';
import TheMovieDb from '../api/themoviedb';
+import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
-import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
+import { MediaRequest } from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest';
-import logger from '../logger';
-import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { User } from '../entity/User';
+import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
+import { Permission } from '../lib/permissions';
+import logger from '../logger';
+import { isAuthenticated } from '../middleware/auth';
const requestRoutes = Router();
@@ -17,6 +17,9 @@ requestRoutes.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0;
+ const requestedBy = req.query.requestedBy
+ ? Number(req.query.requestedBy)
+ : null;
let statusFilter: MediaRequestStatus[];
@@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => {
{ type: 'or' }
)
) {
+ if (requestedBy && requestedBy !== req.user?.id) {
+ return next({
+ status: 403,
+ message: "You do not have permission to view this user's requests.",
+ });
+ }
+
query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id,
});
+ } else if (requestedBy) {
+ query = query.andWhere('requestedBy.id = :id', {
+ id: requestedBy,
+ });
}
const [requests, requestCount] = await query
@@ -125,229 +139,299 @@ requestRoutes.get('/', async (req, res, next) => {
}
});
-requestRoutes.post(
- '/',
- isAuthenticated(Permission.REQUEST),
- async (req, res, next) => {
- const tmdb = new TheMovieDb();
- const mediaRepository = getRepository(Media);
- const requestRepository = getRepository(MediaRequest);
- const userRepository = getRepository(User);
+requestRoutes.post('/', async (req, res, next) => {
+ const tmdb = new TheMovieDb();
+ const mediaRepository = getRepository(Media);
+ const requestRepository = getRepository(MediaRequest);
+ const userRepository = getRepository(User);
- try {
- let requestUser = req.user;
+ try {
+ let requestUser = req.user;
- if (
- req.body.userId &&
- !req.user?.hasPermission([
- Permission.MANAGE_USERS,
- Permission.MANAGE_REQUESTS,
- ])
+ if (
+ req.body.userId &&
+ !req.user?.hasPermission([
+ Permission.MANAGE_USERS,
+ Permission.MANAGE_REQUESTS,
+ ])
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify the request user.',
+ });
+ } else if (req.body.userId) {
+ requestUser = await userRepository.findOneOrFail({
+ where: { id: req.body.userId },
+ });
+ }
+
+ if (!requestUser) {
+ return next({
+ status: 500,
+ message: 'User missing from request context.',
+ });
+ }
+
+ if (
+ req.body.mediaType === MediaType.MOVIE &&
+ !req.user?.hasPermission(
+ req.body.is4k
+ ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
+ : [Permission.REQUEST, Permission.REQUEST_MOVIE],
+ {
+ type: 'or',
+ }
+ )
+ ) {
+ return next({
+ status: 403,
+ message: `You do not have permission to make ${
+ req.body.is4k ? '4K ' : ''
+ }movie requests.`,
+ });
+ } else if (
+ req.body.mediaType === MediaType.TV &&
+ !req.user?.hasPermission(
+ req.body.is4k
+ ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
+ : [Permission.REQUEST, Permission.REQUEST_TV],
+ {
+ type: 'or',
+ }
+ )
+ ) {
+ return next({
+ status: 403,
+ message: `You do not have permission to make ${
+ req.body.is4k ? '4K ' : ''
+ }series requests.`,
+ });
+ }
+
+ const quotas = await requestUser.getQuota();
+
+ if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
+ return next({
+ status: 403,
+ message: 'Movie Quota Exceeded',
+ });
+ } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
+ return next({
+ status: 403,
+ message: 'Series Quota Exceeded',
+ });
+ }
+
+ const tmdbMedia =
+ req.body.mediaType === MediaType.MOVIE
+ ? await tmdb.getMovie({ movieId: req.body.mediaId })
+ : await tmdb.getTvShow({ tvId: req.body.mediaId });
+
+ let media = await mediaRepository.findOne({
+ where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
+ relations: ['requests'],
+ });
+
+ if (!media) {
+ media = new Media({
+ tmdbId: tmdbMedia.id,
+ tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
+ status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
+ status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
+ mediaType: req.body.mediaType,
+ });
+ } else {
+ if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
+ media.status = MediaStatus.PENDING;
+ }
+
+ if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
+ media.status4k = MediaStatus.PENDING;
+ }
+ }
+
+ if (req.body.mediaType === MediaType.MOVIE) {
+ const existing = await requestRepository
+ .createQueryBuilder('request')
+ .leftJoin('request.media', 'media')
+ .where('request.is4k = :is4k', { is4k: req.body.is4k })
+ .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
+ .andWhere('request.status != :requestStatus', {
+ requestStatus: MediaRequestStatus.DECLINED,
+ })
+ .getOne();
+
+ if (existing) {
+ logger.warn('Duplicate request for media blocked', {
+ tmdbId: tmdbMedia.id,
+ mediaType: req.body.mediaType,
+ is4k: req.body.is4k,
+ label: 'Media Request',
+ });
+ return next({
+ status: 409,
+ message: 'Request for this media already exists.',
+ });
+ }
+
+ await mediaRepository.save(media);
+
+ const request = new MediaRequest({
+ type: MediaType.MOVIE,
+ media,
+ requestedBy: requestUser,
+ // If the user is an admin or has the "auto approve" permission, automatically approve the request
+ status: req.user?.hasPermission(
+ [
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K_MOVIE
+ : Permission.AUTO_APPROVE_MOVIE,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? MediaRequestStatus.APPROVED
+ : MediaRequestStatus.PENDING,
+ modifiedBy: req.user?.hasPermission(
+ [
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K_MOVIE
+ : Permission.AUTO_APPROVE_MOVIE,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? req.user
+ : undefined,
+ is4k: req.body.is4k,
+ serverId: req.body.serverId,
+ profileId: req.body.profileId,
+ rootFolder: req.body.rootFolder,
+ tags: req.body.tags,
+ });
+
+ await requestRepository.save(request);
+ return res.status(201).json(request);
+ } else if (req.body.mediaType === MediaType.TV) {
+ const requestedSeasons = req.body.seasons as number[];
+ let existingSeasons: number[] = [];
+
+ // We need to check existing requests on this title to make sure we don't double up on seasons that were
+ // already requested. In the case they were, we just throw out any duplicates but still approve the request.
+ // (Unless there are no seasons, in which case we abort)
+ if (media.requests) {
+ existingSeasons = media.requests
+ .filter(
+ (request) =>
+ request.is4k === req.body.is4k &&
+ request.status !== MediaRequestStatus.DECLINED
+ )
+ .reduce((seasons, request) => {
+ const combinedSeasons = request.seasons.map(
+ (season) => season.seasonNumber
+ );
+
+ return [...seasons, ...combinedSeasons];
+ }, [] as number[]);
+ }
+
+ const finalSeasons = requestedSeasons.filter(
+ (rs) => !existingSeasons.includes(rs)
+ );
+
+ if (finalSeasons.length === 0) {
+ return next({
+ status: 202,
+ message: 'No seasons available to request',
+ });
+ } else if (
+ quotas.tv.limit &&
+ finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
return next({
status: 403,
- message: 'You do not have permission to modify the request user.',
- });
- } else if (req.body.userId) {
- requestUser = await userRepository.findOneOrFail({
- where: { id: req.body.userId },
+ message: 'Series Quota Exceeded',
});
}
- const tmdbMedia =
- req.body.mediaType === 'movie'
- ? await tmdb.getMovie({ movieId: req.body.mediaId })
- : await tmdb.getTvShow({ tvId: req.body.mediaId });
+ await mediaRepository.save(media);
- let media = await mediaRepository.findOne({
- where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
- relations: ['requests'],
+ const request = new MediaRequest({
+ type: MediaType.TV,
+ media,
+ requestedBy: requestUser,
+ // If the user is an admin or has the "auto approve" permission, automatically approve the request
+ status: req.user?.hasPermission(
+ [
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K_TV
+ : Permission.AUTO_APPROVE_TV,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? MediaRequestStatus.APPROVED
+ : MediaRequestStatus.PENDING,
+ modifiedBy: req.user?.hasPermission(
+ [
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K_TV
+ : Permission.AUTO_APPROVE_TV,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? req.user
+ : undefined,
+ is4k: req.body.is4k,
+ serverId: req.body.serverId,
+ profileId: req.body.profileId,
+ rootFolder: req.body.rootFolder,
+ languageProfileId: req.body.languageProfileId,
+ tags: req.body.tags,
+ seasons: finalSeasons.map(
+ (sn) =>
+ new SeasonRequest({
+ seasonNumber: sn,
+ status: req.user?.hasPermission(
+ [
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K
+ : Permission.AUTO_APPROVE,
+ req.body.is4k
+ ? Permission.AUTO_APPROVE_4K_TV
+ : Permission.AUTO_APPROVE_TV,
+ Permission.MANAGE_REQUESTS,
+ ],
+ { type: 'or' }
+ )
+ ? MediaRequestStatus.APPROVED
+ : MediaRequestStatus.PENDING,
+ })
+ ),
});
- if (!media) {
- media = new Media({
- tmdbId: tmdbMedia.id,
- tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
- status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
- status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
- mediaType: req.body.mediaType,
- });
- } else {
- if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
- media.status = MediaStatus.PENDING;
- }
-
- if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
- media.status4k = MediaStatus.PENDING;
- }
- }
-
- if (req.body.mediaType === 'movie') {
- const existing = await requestRepository.findOne({
- where: {
- media: {
- tmdbId: tmdbMedia.id,
- },
- requestedBy: req.user,
- is4k: req.body.is4k,
- },
- });
-
- if (existing) {
- logger.warn('Duplicate request for media blocked', {
- tmdbId: tmdbMedia.id,
- mediaType: req.body.mediaType,
- });
- return next({
- status: 409,
- message: 'Request for this media already exists.',
- });
- }
-
- await mediaRepository.save(media);
-
- const request = new MediaRequest({
- type: MediaType.MOVIE,
- media,
- requestedBy: requestUser,
- // If the user is an admin or has the "auto approve" permission, automatically approve the request
- status:
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K
- : Permission.AUTO_APPROVE
- ) ||
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K_MOVIE
- : Permission.AUTO_APPROVE_MOVIE
- )
- ? MediaRequestStatus.APPROVED
- : MediaRequestStatus.PENDING,
- modifiedBy:
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K
- : Permission.AUTO_APPROVE
- ) ||
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K_MOVIE
- : Permission.AUTO_APPROVE_MOVIE
- )
- ? req.user
- : undefined,
- is4k: req.body.is4k,
- serverId: req.body.serverId,
- profileId: req.body.profileId,
- rootFolder: req.body.rootFolder,
- });
-
- await requestRepository.save(request);
- return res.status(201).json(request);
- } else if (req.body.mediaType === 'tv') {
- const requestedSeasons = req.body.seasons as number[];
- let existingSeasons: number[] = [];
-
- // We need to check existing requests on this title to make sure we don't double up on seasons that were
- // already requested. In the case they were, we just throw out any duplicates but still approve the request.
- // (Unless there are no seasons, in which case we abort)
- if (media.requests) {
- existingSeasons = media.requests
- .filter(
- (request) =>
- request.is4k === req.body.is4k &&
- request.status !== MediaRequestStatus.DECLINED
- )
- .reduce((seasons, request) => {
- const combinedSeasons = request.seasons.map(
- (season) => season.seasonNumber
- );
-
- return [...seasons, ...combinedSeasons];
- }, [] as number[]);
- }
-
- const finalSeasons = requestedSeasons.filter(
- (rs) => !existingSeasons.includes(rs)
- );
-
- if (finalSeasons.length === 0) {
- return next({
- status: 202,
- message: 'No seasons available to request',
- });
- }
-
- await mediaRepository.save(media);
-
- const request = new MediaRequest({
- type: MediaType.TV,
- media,
- requestedBy: requestUser,
- // If the user is an admin or has the "auto approve" permission, automatically approve the request
- status:
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K
- : Permission.AUTO_APPROVE
- ) ||
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K_TV
- : Permission.AUTO_APPROVE_TV
- )
- ? MediaRequestStatus.APPROVED
- : MediaRequestStatus.PENDING,
- modifiedBy:
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K
- : Permission.AUTO_APPROVE
- ) ||
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K_TV
- : Permission.AUTO_APPROVE_TV
- )
- ? req.user
- : undefined,
- is4k: req.body.is4k,
- serverId: req.body.serverId,
- profileId: req.body.profileId,
- rootFolder: req.body.rootFolder,
- languageProfileId: req.body.languageProfileId,
- seasons: finalSeasons.map(
- (sn) =>
- new SeasonRequest({
- seasonNumber: sn,
- status:
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K
- : Permission.AUTO_APPROVE
- ) ||
- req.user?.hasPermission(
- req.body.is4k
- ? Permission.AUTO_APPROVE_4K_TV
- : Permission.AUTO_APPROVE_TV
- )
- ? MediaRequestStatus.APPROVED
- : MediaRequestStatus.PENDING,
- })
- ),
- });
-
- await requestRepository.save(request);
- return res.status(201).json(request);
- }
-
- next({ status: 500, message: 'Invalid media type' });
- } catch (e) {
- next({ status: 500, message: e.message });
+ await requestRepository.save(request);
+ return res.status(201).json(request);
}
+
+ next({ status: 500, message: 'Invalid media type' });
+ } catch (e) {
+ next({ status: 500, message: e.message });
}
-);
+});
requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);
@@ -421,7 +505,6 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
requestRoutes.put<{ requestId: string }>(
'/:requestId',
- isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
@@ -431,17 +514,30 @@ requestRoutes.put<{ requestId: string }>(
);
if (!request) {
- return next({ status: 404, message: 'Request not found' });
+ return next({ status: 404, message: 'Request not found.' });
+ }
+
+ if (
+ (request.requestedBy.id !== req.user?.id ||
+ (req.body.mediaType !== 'tv' &&
+ !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
+ !req.user?.hasPermission(Permission.MANAGE_REQUESTS)
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to modify this request.',
+ });
}
let requestUser = req.user;
if (
req.body.userId &&
- !(
- req.user?.hasPermission(Permission.MANAGE_USERS) &&
- req.user?.hasPermission(Permission.MANAGE_REQUESTS)
- )
+ req.body.userId !== req.user?.id &&
+ !req.user?.hasPermission([
+ Permission.MANAGE_USERS,
+ Permission.MANAGE_REQUESTS,
+ ])
) {
return next({
status: 403,
@@ -453,25 +549,28 @@ requestRoutes.put<{ requestId: string }>(
});
}
- if (req.body.mediaType === 'movie') {
+ if (req.body.mediaType === MediaType.MOVIE) {
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
+ request.tags = req.body.tags;
request.requestedBy = requestUser as User;
requestRepository.save(request);
- } else if (req.body.mediaType === 'tv') {
+ } else if (req.body.mediaType === MediaType.TV) {
const mediaRepository = getRepository(Media);
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
+ request.languageProfileId = req.body.languageProfileId;
+ request.tags = req.body.tags;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;
if (!requestedSeasons || requestedSeasons.length === 0) {
throw new Error(
- 'Missing seasons. If you want to cancel a tv request, use the DELETE method.'
+ 'Missing seasons. If you want to cancel a series request, use the DELETE method.'
);
}
@@ -558,7 +657,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
) {
return next({
status: 401,
- message: 'You do not have permission to remove this request',
+ message: 'You do not have permission to delete this request.',
});
}
@@ -567,7 +666,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
return res.status(204).send();
} catch (e) {
logger.error(e.message);
- next({ status: 404, message: 'Request not found' });
+ next({ status: 404, message: 'Request not found.' });
}
});
@@ -593,7 +692,7 @@ requestRoutes.post<{
label: 'Media Request',
message: e.message,
});
- next({ status: 404, message: 'Request not found' });
+ next({ status: 404, message: 'Request not found.' });
}
}
);
@@ -637,7 +736,7 @@ requestRoutes.post<{
label: 'Media Request',
message: e.message,
});
- next({ status: 404, message: 'Request not found' });
+ next({ status: 404, message: 'Request not found.' });
}
}
);
diff --git a/server/routes/search.ts b/server/routes/search.ts
index 622e54693..c843e78c3 100644
--- a/server/routes/search.ts
+++ b/server/routes/search.ts
@@ -1,7 +1,7 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
-import { mapSearchResults } from '../models/Search';
import Media from '../entity/Media';
+import { mapSearchResults } from '../models/Search';
const searchRoutes = Router();
@@ -11,7 +11,7 @@ searchRoutes.get('/', async (req, res) => {
const results = await tmdb.searchMulti({
query: req.query.query as string,
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
diff --git a/server/routes/service.ts b/server/routes/service.ts
index 5e6dccc84..862ab3748 100644
--- a/server/routes/service.ts
+++ b/server/routes/service.ts
@@ -1,12 +1,12 @@
import { Router } from 'express';
-import RadarrAPI from '../api/radarr';
-import SonarrAPI from '../api/sonarr';
+import RadarrAPI from '../api/servarr/radarr';
+import SonarrAPI from '../api/servarr/sonarr';
+import TheMovieDb from '../api/themoviedb';
import {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
-import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@@ -22,6 +22,7 @@ serviceRoutes.get('/radarr', async (req, res) => {
isDefault: radarr.isDefault,
activeDirectory: radarr.activeDirectory,
activeProfileId: radarr.activeProfileId,
+ activeTags: radarr.tags ?? [],
})
);
@@ -46,11 +47,12 @@ serviceRoutes.get<{ radarrId: string }>(
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const rootFolders = await radarr.getRootFolders();
+ const tags = await radarr.getTags();
return res.status(200).json({
server: {
@@ -60,6 +62,7 @@ serviceRoutes.get<{ radarrId: string }>(
isDefault: radarrSettings.isDefault,
activeDirectory: radarrSettings.activeDirectory,
activeProfileId: radarrSettings.activeProfileId,
+ activeTags: radarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -71,6 +74,7 @@ serviceRoutes.get<{ radarrId: string }>(
path: folder.path,
totalSpace: folder.totalSpace,
})),
+ tags,
} as ServiceCommonServerWithDetails);
}
);
@@ -90,6 +94,7 @@ serviceRoutes.get('/sonarr', async (req, res) => {
activeAnimeDirectory: sonarr.activeAnimeDirectory,
activeLanguageProfileId: sonarr.activeLanguageProfileId,
activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId,
+ activeTags: [],
})
);
@@ -114,13 +119,14 @@ serviceRoutes.get<{ sonarrId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
- url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
+ url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
+ const tags = await sonarr.getTags();
return res.status(200).json({
server: {
@@ -135,6 +141,8 @@ serviceRoutes.get<{ sonarrId: string }>(
activeLanguageProfileId: sonarrSettings.activeLanguageProfileId,
activeAnimeLanguageProfileId:
sonarrSettings.activeAnimeLanguageProfileId,
+ activeTags: sonarrSettings.tags,
+ activeAnimeTags: sonarrSettings.animeTags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -147,6 +155,7 @@ serviceRoutes.get<{ sonarrId: string }>(
totalSpace: folder.totalSpace,
})),
languageProfiles: languageProfiles,
+ tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
@@ -182,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>(
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const response = await sonarr.getSeriesByTitle(tv.name);
diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts
index ccfb29d6b..9b43d4aa9 100644
--- a/server/routes/settings/index.ts
+++ b/server/routes/settings/index.ts
@@ -1,24 +1,34 @@
import { Router } from 'express';
-import { getSettings, Library, MainSettings } from '../../lib/settings';
+import rateLimit from 'express-rate-limit';
+import fs from 'fs';
+import { merge, omit } from 'lodash';
+import path from 'path';
import { getRepository } from 'typeorm';
-import { User } from '../../entity/User';
+import { URL } from 'url';
+import JellyfinAPI from '../../api/jellyfin';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
-import JellyfinAPI from '../../api/jellyfin';
-import { jobPlexFullSync } from '../../job/plexsync';
-import { jobJellyfinFullSync } from '../../job/jellyfinsync';
-import { scheduledJobs } from '../../job/schedule';
-import { Permission } from '../../lib/permissions';
-import { isAuthenticated } from '../../middleware/auth';
-import { merge, omit } from 'lodash';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
-import { getAppVersion } from '../../utils/appVersion';
-import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
-import notificationRoutes from './notifications';
-import sonarrRoutes from './sonarr';
-import radarrRoutes from './radarr';
+import { User } from '../../entity/User';
+import { PlexConnection } from '../../interfaces/api/plexInterfaces';
+import {
+ LogMessage,
+ LogsResultsResponse,
+ SettingsAboutResponse,
+} from '../../interfaces/api/settingsInterfaces';
+import { jobJellyfinFullSync } from '../../job/jellyfinsync';
+import { scheduledJobs } from '../../job/schedule';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
+import { Permission } from '../../lib/permissions';
+import { plexFullScanner } from '../../lib/scanners/plex';
+import { getSettings, MainSettings } from '../../lib/settings';
+import logger from '../../logger';
+import { isAuthenticated } from '../../middleware/auth';
+import { getAppVersion } from '../../utils/appVersion';
+import notificationRoutes from './notifications';
+import radarrRoutes from './radarr';
+import sonarrRoutes from './sonarr';
const settingsRoutes = Router();
@@ -107,7 +117,6 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
- const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/;
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
@@ -120,40 +129,51 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
return device.provides.includes('server') && device.owned;
});
const settings = getSettings();
+
if (devices) {
await Promise.all(
devices.map(async (device) => {
+ const plexDirectConnections: PlexConnection[] = [];
+
+ device.connection.forEach((connection) => {
+ const url = new URL(connection.uri);
+
+ if (url.hostname !== connection.address) {
+ const plexDirectConnection = { ...connection };
+ plexDirectConnection.address = url.hostname;
+ plexDirectConnections.push(plexDirectConnection);
+
+ // Connect to IP addresses over HTTP
+ connection.protocol = 'http';
+ }
+ });
+
+ plexDirectConnections.forEach((plexDirectConnection) => {
+ device.connection.push(plexDirectConnection);
+ });
+
await Promise.all(
device.connection.map(async (connection) => {
- connection.host = connection.uri.replace(regexp, '$3');
- let msg:
- | { status: number; message: string }
- | undefined = undefined;
const plexDeviceSettings = {
...settings.plex,
- ip: connection.host,
+ ip: connection.address,
port: connection.port,
- useSsl: connection.protocol === 'https' ? true : false,
+ useSsl: connection.protocol === 'https',
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexSettings: plexDeviceSettings,
timeout: 5000,
});
+
try {
await plexClient.getStatus();
- msg = {
- status: 200,
- message: 'OK',
- };
+ connection.status = 200;
+ connection.message = 'OK';
} catch (e) {
- msg = {
- status: 500,
- message: e.message,
- };
+ connection.status = 500;
+ connection.message = e.message.split(':')[0];
}
- connection.status = msg?.status;
- connection.message = msg?.message;
})
);
})
@@ -179,26 +199,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
});
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
- const libraries = await plexapi.getLibraries();
-
- const newLibraries: Library[] = libraries
- // Remove libraries that are not movie or show
- .filter((library) => library.type === 'movie' || library.type === 'show')
- // Remove libraries that do not have a metadata agent set (usually personal video libraries)
- .filter((library) => library.agent !== 'com.plexapp.agents.none')
- .map((library) => {
- const existing = settings.plex.libraries.find(
- (l) => l.id === library.key && l.name === library.title
- );
-
- return {
- id: library.key,
- name: library.title,
- enabled: existing?.enabled ?? false,
- };
- });
-
- settings.plex.libraries = newLibraries;
+ await plexapi.syncLibraries();
}
const enabledLibraries = req.query.enable
@@ -213,16 +214,16 @@ settingsRoutes.get('/plex/library', async (req, res) => {
});
settingsRoutes.get('/plex/sync', (_req, res) => {
- return res.status(200).json(jobPlexFullSync.status());
+ return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.post('/plex/sync', (req, res) => {
if (req.body.cancel) {
- jobPlexFullSync.cancel();
+ plexFullScanner.cancel();
} else if (req.body.start) {
- jobPlexFullSync.run();
+ plexFullScanner.run();
}
- return res.status(200).json(jobPlexFullSync.status());
+ return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.get('/jellyfin', (_req, res) => {
@@ -297,6 +298,85 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => {
}
return res.status(200).json(jobJellyfinFullSync.status());
});
+settingsRoutes.get(
+ '/logs',
+ rateLimit({ windowMs: 60 * 1000, max: 50 }),
+ (req, res, next) => {
+ const pageSize = req.query.take ? Number(req.query.take) : 25;
+ const skip = req.query.skip ? Number(req.query.skip) : 0;
+
+ let filter: string[] = [];
+ switch (req.query.filter) {
+ case 'debug':
+ filter.push('debug');
+ // falls through
+ case 'info':
+ filter.push('info');
+ // falls through
+ case 'warn':
+ filter.push('warn');
+ // falls through
+ case 'error':
+ filter.push('error');
+ break;
+ default:
+ filter = ['debug', 'info', 'warn', 'error'];
+ }
+
+ const logFile = process.env.CONFIG_DIRECTORY
+ ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr.log`
+ : path.join(__dirname, '../../../config/logs/overseerr.log');
+ const logs: LogMessage[] = [];
+
+ try {
+ fs.readFileSync(logFile)
+ .toString()
+ .split('\n')
+ .forEach((line) => {
+ if (!line.length) return;
+
+ const timestamp = line.match(new RegExp(/^.{24}/)) || [];
+ const level = line.match(new RegExp(/\s\[\w+\]/)) || [];
+ const label = line.match(new RegExp(/\]\[.+?\]/)) || [];
+ const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || [];
+
+ if (level.length && filter.includes(level[0].slice(2, -1))) {
+ logs.push({
+ timestamp: timestamp[0],
+ level: level.length ? level[0].slice(2, -1) : '',
+ label: label.length ? label[0].slice(2, -1) : '',
+ message: message.length && message[1] ? message[1] : '',
+ data:
+ message.length && message[2]
+ ? JSON.parse(message[2])
+ : undefined,
+ });
+ }
+ });
+
+ const displayedLogs = logs.reverse().slice(skip, skip + pageSize);
+
+ return res.status(200).json({
+ pageInfo: {
+ pages: Math.ceil(logs.length / pageSize),
+ pageSize,
+ results: logs.length,
+ page: Math.ceil(skip / pageSize) + 1,
+ },
+ results: displayedLogs,
+ } as LogsResultsResponse);
+ } catch (error) {
+ logger.error('Something went wrong while fetching the logs', {
+ label: 'Logs',
+ errorMessage: error.message,
+ });
+ return next({
+ status: 500,
+ message: 'Something went wrong while fetching the logs',
+ });
+ }
+ }
+);
settingsRoutes.get('/jobs', (_req, res) => {
return res.status(200).json(
diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts
index 58be3a4f2..bb21c7b60 100644
--- a/server/routes/settings/notifications.ts
+++ b/server/routes/settings/notifications.ts
@@ -1,39 +1,18 @@
import { Router } from 'express';
-import { getSettings } from '../../lib/settings';
import { Notification } from '../../lib/notifications';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
+import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
+import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
+import PushoverAgent from '../../lib/notifications/agents/pushover';
import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram';
-import PushoverAgent from '../../lib/notifications/agents/pushover';
import WebhookAgent from '../../lib/notifications/agents/webhook';
-import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
+import WebPushAgent from '../../lib/notifications/agents/webpush';
+import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
-notificationRoutes.get('/', (_req, res) => {
- const settings = getSettings().notifications;
- return res.status(200).json({
- enabled: settings.enabled,
- autoapprovalEnabled: settings.autoapprovalEnabled,
- });
-});
-
-notificationRoutes.post('/', (req, res) => {
- const settings = getSettings();
-
- Object.assign(settings.notifications, {
- enabled: req.body.enabled,
- autoapprovalEnabled: req.body.autoapprovalEnabled,
- });
- settings.save();
-
- return res.status(200).json({
- enabled: settings.notifications.enabled,
- autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
- });
-});
-
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();
@@ -49,23 +28,30 @@ notificationRoutes.post('/discord', (req, res) => {
res.status(200).json(settings.notifications.agents.discord);
});
-notificationRoutes.post('/discord/test', (req, res, next) => {
+notificationRoutes.post('/discord/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
const discordAgent = new DiscordAgent(req.body);
- discordAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await discordAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Discord notification.',
+ });
+ }
});
notificationRoutes.get('/slack', (_req, res) => {
@@ -83,23 +69,30 @@ notificationRoutes.post('/slack', (req, res) => {
res.status(200).json(settings.notifications.agents.slack);
});
-notificationRoutes.post('/slack/test', (req, res, next) => {
+notificationRoutes.post('/slack/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
const slackAgent = new SlackAgent(req.body);
- slackAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await slackAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Slack notification.',
+ });
+ }
});
notificationRoutes.get('/telegram', (_req, res) => {
@@ -117,23 +110,30 @@ notificationRoutes.post('/telegram', (req, res) => {
res.status(200).json(settings.notifications.agents.telegram);
});
-notificationRoutes.post('/telegram/test', (req, res, next) => {
+notificationRoutes.post('/telegram/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
const telegramAgent = new TelegramAgent(req.body);
- telegramAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await telegramAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Telegram notification.',
+ });
+ }
});
notificationRoutes.get('/pushbullet', (_req, res) => {
@@ -151,23 +151,30 @@ notificationRoutes.post('/pushbullet', (req, res) => {
res.status(200).json(settings.notifications.agents.pushbullet);
});
-notificationRoutes.post('/pushbullet/test', (req, res, next) => {
+notificationRoutes.post('/pushbullet/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
const pushbulletAgent = new PushbulletAgent(req.body);
- pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Pushbullet notification.',
+ });
+ }
});
notificationRoutes.get('/pushover', (_req, res) => {
@@ -185,23 +192,30 @@ notificationRoutes.post('/pushover', (req, res) => {
res.status(200).json(settings.notifications.agents.pushover);
});
-notificationRoutes.post('/pushover/test', (req, res, next) => {
+notificationRoutes.post('/pushover/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
const pushoverAgent = new PushoverAgent(req.body);
- pushoverAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send Pushover notification.',
+ });
+ }
});
notificationRoutes.get('/email', (_req, res) => {
@@ -219,7 +233,48 @@ notificationRoutes.post('/email', (req, res) => {
res.status(200).json(settings.notifications.agents.email);
});
-notificationRoutes.post('/email/test', (req, res, next) => {
+notificationRoutes.post('/email/test', async (req, res, next) => {
+ if (!req.user) {
+ return next({
+ status: 500,
+ message: 'User information is missing from the request.',
+ });
+ }
+
+ const emailAgent = new EmailAgent(req.body);
+ if (
+ await emailAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send email notification.',
+ });
+ }
+});
+
+notificationRoutes.get('/webpush', (_req, res) => {
+ const settings = getSettings();
+
+ res.status(200).json(settings.notifications.agents.webpush);
+});
+
+notificationRoutes.post('/webpush', (req, res) => {
+ const settings = getSettings();
+
+ settings.notifications.agents.webpush = req.body;
+ settings.save();
+
+ res.status(200).json(settings.notifications.agents.webpush);
+});
+
+notificationRoutes.post('/webpush/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
@@ -227,15 +282,22 @@ notificationRoutes.post('/email/test', (req, res, next) => {
});
}
- const emailAgent = new EmailAgent(req.body);
- emailAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ const webpushAgent = new WebPushAgent(req.body);
+ if (
+ await webpushAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send web push notification.',
+ });
+ }
});
notificationRoutes.get('/webhook', (_req, res) => {
@@ -283,11 +345,11 @@ notificationRoutes.post('/webhook', (req, res, next) => {
}
});
-notificationRoutes.post('/webhook/test', (req, res, next) => {
+notificationRoutes.post('/webhook/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
- message: 'User information missing from request',
+ message: 'User information is missing from the request.',
});
}
@@ -307,17 +369,65 @@ notificationRoutes.post('/webhook/test', (req, res, next) => {
};
const webhookAgent = new WebhookAgent(testBody);
- webhookAgent.send(Notification.TEST_NOTIFICATION, {
- notifyUser: req.user,
- subject: 'Test Notification',
- message:
- 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
- });
-
- return res.status(204).send();
+ if (
+ await webhookAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send webhook notification.',
+ });
+ }
} catch (e) {
next({ status: 500, message: e.message });
}
});
+notificationRoutes.get('/lunasea', (_req, res) => {
+ const settings = getSettings();
+
+ res.status(200).json(settings.notifications.agents.lunasea);
+});
+
+notificationRoutes.post('/lunasea', (req, res) => {
+ const settings = getSettings();
+
+ settings.notifications.agents.lunasea = req.body;
+ settings.save();
+
+ res.status(200).json(settings.notifications.agents.lunasea);
+});
+
+notificationRoutes.post('/lunasea/test', async (req, res, next) => {
+ if (!req.user) {
+ return next({
+ status: 500,
+ message: 'User information missing from request',
+ });
+ }
+
+ const lunaseaAgent = new LunaSeaAgent(req.body);
+ if (
+ await lunaseaAgent.send(Notification.TEST_NOTIFICATION, {
+ notifyUser: req.user,
+ subject: 'Test Notification',
+ message:
+ 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
+ })
+ ) {
+ return res.status(204).send();
+ } else {
+ return next({
+ status: 500,
+ message: 'Failed to send web push notification.',
+ });
+ }
+});
+
export default notificationRoutes;
diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts
index 1e17a4757..d250ea298 100644
--- a/server/routes/settings/radarr.ts
+++ b/server/routes/settings/radarr.ts
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import RadarrAPI from '../../api/radarr';
+import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => {
return res.status(201).json(newRadarr);
});
-radarrRoutes.post('/test', async (req, res, next) => {
+radarrRoutes.post<
+ undefined,
+ Record,
+ RadarrSettings & { tagLabel?: string }
+>('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
- url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'),
+ url: RadarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
+ const tags = await radarr.getTags();
return res.status(200).json({
profiles,
@@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
id: folder.id,
path: folder.path,
})),
+ tags,
});
} catch (e) {
logger.error('Failed to test Radarr', {
@@ -62,40 +68,41 @@ radarrRoutes.post('/test', async (req, res, next) => {
}
});
-radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
- const settings = getSettings();
+radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
+ '/:id',
+ (req, res, next) => {
+ const settings = getSettings();
- const radarrIndex = settings.radarr.findIndex(
- (r) => r.id === Number(req.params.id)
- );
+ const radarrIndex = settings.radarr.findIndex(
+ (r) => r.id === Number(req.params.id)
+ );
- if (radarrIndex === -1) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ if (radarrIndex === -1) {
+ return next({ status: '404', message: 'Settings instance not found' });
+ }
+
+ // If we are setting this as the default, clear any previous defaults for the same type first
+ // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
+ // and are the default
+ if (req.body.isDefault) {
+ settings.radarr
+ .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
+ .forEach((radarrInstance) => {
+ radarrInstance.isDefault = false;
+ });
+ }
+
+ settings.radarr[radarrIndex] = {
+ ...req.body,
+ id: Number(req.params.id),
+ } as RadarrSettings;
+ settings.save();
+
+ return res.status(200).json(settings.radarr[radarrIndex]);
}
+);
- // If we are setting this as the default, clear any previous defaults for the same type first
- // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
- // and are the default
- if (req.body.isDefault) {
- settings.radarr
- .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
- .forEach((radarrInstance) => {
- radarrInstance.isDefault = false;
- });
- }
-
- settings.radarr[radarrIndex] = {
- ...req.body,
- id: Number(req.params.id),
- } as RadarrSettings;
- settings.save();
-
- return res.status(200).json(settings.radarr[radarrIndex]);
-});
-
-radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
+radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
@@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
if (!radarrSettings) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ return next({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
- url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
+ url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@@ -123,7 +128,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
});
-radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
+radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
);
if (radarrIndex === -1) {
- return res
- .status(404)
- .json({ status: '404', message: 'Settings instance not found' });
+ return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts
index d9bbe3c2c..4f63ebb37 100644
--- a/server/routes/settings/sonarr.ts
+++ b/server/routes/settings/sonarr.ts
@@ -1,5 +1,5 @@
import { Router } from 'express';
-import SonarrAPI from '../../api/sonarr';
+import SonarrAPI from '../../api/servarr/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -39,12 +39,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
- url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'),
+ url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
+ const tags = await sonarr.getTags();
return res.status(200).json({
profiles,
@@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
path: folder.path,
})),
languageProfiles,
+ tags,
});
} catch (e) {
logger.error('Failed to test Sonarr', {
diff --git a/server/routes/tv.ts b/server/routes/tv.ts
index 1ddf1f80c..043e610f7 100644
--- a/server/routes/tv.ts
+++ b/server/routes/tv.ts
@@ -1,11 +1,11 @@
import { Router } from 'express';
-import TheMovieDb from '../api/themoviedb';
-import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
-import { mapTvResult } from '../models/Search';
-import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
-import logger from '../logger';
+import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
+import Media from '../entity/Media';
+import logger from '../logger';
+import { mapTvResult } from '../models/Search';
+import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv';
const tvRoutes = Router();
@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tv.id, MediaType.TV);
@@ -35,7 +35,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
const season = await tmdb.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapSeasonWithEpisodes(season));
@@ -47,7 +47,7 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id),
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -75,7 +75,7 @@ tvRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id),
page: Number(req.query.page),
- language: req.query.language as string,
+ language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts
index 803aed7c5..4bccc772d 100644
--- a/server/routes/user/index.ts
+++ b/server/routes/user/index.ts
@@ -1,16 +1,20 @@
import { Router } from 'express';
+import gravatarUrl from 'gravatar-url';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv';
+import { UserType } from '../../constants/user';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
+import { UserPushSubscription } from '../../entity/UserPushSubscription';
+import {
+ QuotaResponse,
+ UserRequestsResponse,
+ UserResultsResponse,
+} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
-import gravatarUrl from 'gravatar-url';
-import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth';
-import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
-import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -27,7 +31,7 @@ router.get('/', async (req, res, next) => {
break;
case 'displayname':
query = query.orderBy(
- '(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)',
+ "(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
'ASC'
);
break;
@@ -78,10 +82,28 @@ router.post(
const body = req.body;
const userRepository = getRepository(User);
+ const existingUser = await userRepository
+ .createQueryBuilder('user')
+ .where('user.email = :email', {
+ email: body.email.toLowerCase(),
+ })
+ .getOne();
+
+ if (existingUser) {
+ return next({
+ status: 409,
+ message: 'User already exists with submitted email.',
+ errors: ['USER_EXISTS'],
+ });
+ }
+
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
- if (!passedExplicitPassword && !settings.notifications.agents.email) {
+ if (
+ !passedExplicitPassword &&
+ !settings.notifications.agents.email.enabled
+ ) {
throw new Error('Email notifications must be enabled');
}
@@ -109,6 +131,48 @@ router.post(
}
);
+router.post<
+ never,
+ unknown,
+ {
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+ }
+>('/registerPushSubscription', async (req, res, next) => {
+ try {
+ const userPushSubRepository = getRepository(UserPushSubscription);
+
+ const existingSubs = await userPushSubRepository.find({
+ where: { auth: req.body.auth },
+ });
+
+ if (existingSubs.length > 0) {
+ logger.debug(
+ 'User push subscription already exists. Skipping registration.',
+ { label: 'API' }
+ );
+ return res.status(204).send();
+ }
+
+ const userPushSubscription = new UserPushSubscription({
+ auth: req.body.auth,
+ endpoint: req.body.endpoint,
+ p256dh: req.body.p256dh,
+ user: req.user,
+ });
+
+ userPushSubRepository.save(userPushSubscription);
+
+ return res.status(204).send();
+ } catch (e) {
+ logger.error('Failed to register user push subscription', {
+ label: 'API',
+ });
+ next({ status: 500, message: 'Failed to register subscription.' });
+ }
+});
+
router.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
@@ -167,7 +231,10 @@ router.get<{ id: string }, UserRequestsResponse>(
}
);
-const canMakePermissionsChange = (permissions: number, user?: User) =>
+export const canMakePermissionsChange = (
+ permissions: number,
+ user?: User
+): boolean =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
// Only let users with the manage settings permission, grant the same permission
@@ -275,7 +342,7 @@ router.delete<{ id: string }>(
});
}
- if (user.hasPermission(Permission.ADMIN)) {
+ if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
@@ -329,47 +396,45 @@ router.post(
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
- const user = await userRepository.findOne({
- where: [{ plexId: account.id }, { email: account.email }],
- });
+ if (account.email) {
+ const user = await userRepository
+ .createQueryBuilder('user')
+ .where('user.plexId = :id', { id: account.id })
+ .orWhere('user.email = :email', {
+ email: account.email.toLowerCase(),
+ })
+ .getOne();
- if (user) {
- // Update the users avatar with their plex thumbnail (incase it changed)
- user.avatar = account.thumb;
- user.email = account.email;
- user.plexUsername = account.username;
+ if (user) {
+ // Update the user's avatar with their Plex thumbnail, in case it changed
+ user.avatar = account.thumb;
+ user.email = account.email;
+ user.plexUsername = account.username;
- // in-case the user was previously a local account
- if (user.userType === UserType.LOCAL) {
- user.userType = UserType.PLEX;
- user.plexId = parseInt(account.id);
-
- if (user.username === account.username) {
- user.username = '';
+ // In case the user was previously a local account
+ if (user.userType === UserType.LOCAL) {
+ user.userType = UserType.PLEX;
+ user.plexId = parseInt(account.id);
+ }
+ await userRepository.save(user);
+ } else {
+ if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
+ const newUser = new User({
+ plexUsername: account.username,
+ email: account.email,
+ permissions: settings.main.defaultPermissions,
+ plexId: parseInt(account.id),
+ plexToken: '',
+ avatar: account.thumb,
+ userType: UserType.PLEX,
+ });
+ await userRepository.save(newUser);
+ createdUsers.push(newUser);
}
- }
- await userRepository.save(user);
- } else {
- // Check to make sure it's a real account
- if (
- account.email &&
- account.username &&
- (await mainPlexTv.checkUserAccess(Number(account.id)))
- ) {
- const newUser = new User({
- plexUsername: account.username,
- email: account.email,
- permissions: settings.main.defaultPermissions,
- plexId: parseInt(account.id),
- plexToken: '',
- avatar: account.thumb,
- userType: UserType.PLEX,
- });
- await userRepository.save(newUser);
- createdUsers.push(newUser);
}
}
}
+
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
@@ -377,4 +442,36 @@ router.post(
}
);
+router.get<{ id: string }, QuotaResponse>(
+ '/:id/quota',
+ async (req, res, next) => {
+ try {
+ const userRepository = getRepository(User);
+
+ if (
+ Number(req.params.id) !== req.user?.id &&
+ !req.user?.hasPermission(
+ [Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
+ { type: 'and' }
+ )
+ ) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to access this endpoint.',
+ });
+ }
+
+ const user = await userRepository.findOneOrFail({
+ where: { id: Number(req.params.id) },
+ });
+
+ const quotas = await user.getQuota();
+
+ return res.status(200).json(quotas);
+ } catch (e) {
+ next({ status: 404, message: e.message });
+ }
+ }
+);
+
export default router;
diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts
index c2e075119..226dcae09 100644
--- a/server/routes/user/usersettings.ts
+++ b/server/routes/user/usersettings.ts
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
+import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User';
import { UserSettings } from '../../entity/UserSettings';
import {
@@ -7,6 +8,7 @@ import {
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions';
+import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
@@ -21,6 +23,7 @@ const isOwnProfileOrAdmin = (): Middleware => {
message: "You do not have permission to view this user's settings.",
});
}
+
next();
};
return authMiddleware;
@@ -32,6 +35,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
'/main',
isOwnProfileOrAdmin(),
async (req, res, next) => {
+ const {
+ main: { defaultQuotas },
+ } = getSettings();
const userRepository = getRepository(User);
try {
@@ -45,8 +51,17 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({
username: user.username,
+ locale: user.settings?.locale,
region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage,
+ movieQuotaLimit: user.movieQuotaLimit,
+ movieQuotaDays: user.movieQuotaDays,
+ tvQuotaLimit: user.tvQuotaLimit,
+ tvQuotaDays: user.tvQuotaDays,
+ globalMovieQuotaDays: defaultQuotas.movie.quotaDays,
+ globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
+ globalTvQuotaDays: defaultQuotas.tv.quotaDays,
+ globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -70,21 +85,48 @@ userSettingsRoutes.post<
return next({ status: 404, message: 'User not found.' });
}
+ // "Owner" user settings cannot be modified by other users
+ if (user.id === 1 && req.user?.id !== 1) {
+ return next({
+ status: 403,
+ message: "You do not have permission to modify this user's settings.",
+ });
+ }
+
user.username = req.body.username;
+
+ // Update quota values only if the user has the correct permissions
+ if (
+ !user.hasPermission(Permission.MANAGE_USERS) &&
+ req.user?.id !== user.id
+ ) {
+ user.movieQuotaDays = req.body.movieQuotaDays;
+ user.movieQuotaLimit = req.body.movieQuotaLimit;
+ user.tvQuotaDays = req.body.tvQuotaDays;
+ user.tvQuotaLimit = req.body.tvQuotaLimit;
+ }
+
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
+ locale: req.body.locale,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
});
} else {
+ user.settings.locale = req.body.locale;
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
}
await userRepository.save(user);
- return res.status(200).json({ username: user.username });
+ return res.status(200).json({
+ username: user.username,
+ region: user.settings.region,
+ locale: user.settings.locale,
+ originalLanguage: user.settings.originalLanguage,
+ });
} catch (e) {
next({ status: 500, message: e.message });
}
@@ -137,7 +179,19 @@ userSettingsRoutes.post<
if (req.body.newPassword.length < 8) {
return next({
status: 400,
- message: 'Password must be at least 8 characters',
+ message: 'Password must be at least 8 characters.',
+ });
+ }
+
+ if (
+ (user.id === 1 && req.user?.id !== 1) ||
+ (user.hasPermission(Permission.ADMIN) &&
+ user.id !== req.user?.id &&
+ req.user?.id !== 1)
+ ) {
+ return next({
+ status: 403,
+ message: "You do not have permission to modify this user's password.",
});
}
@@ -184,6 +238,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
+ const settings = getSettings()?.notifications.agents;
try {
const user = await userRepository.findOne({
@@ -195,8 +250,19 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
- enableNotifications: user.settings?.enableNotifications ?? true,
+ emailEnabled: settings?.email.enabled,
+ pgpKey: user.settings?.pgpKey,
+ discordEnabled: settings?.discord.enabled,
+ discordEnabledTypes: settings?.discord.enabled
+ ? settings?.discord.types
+ : 0,
discordId: user.settings?.discordId,
+ telegramEnabled: settings?.telegram.enabled,
+ telegramBotUsername: settings?.telegram.options.botUsername,
+ telegramChatId: user.settings?.telegramChatId,
+ telegramSendSilently: user?.settings?.telegramSendSilently,
+ webPushEnabled: settings?.webpush.enabled,
+ notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -204,43 +270,64 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
);
-userSettingsRoutes.post<
- { id: string },
- UserSettingsNotificationsResponse,
- UserSettingsNotificationsResponse
->('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
- const userRepository = getRepository(User);
+userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
+ '/notifications',
+ isOwnProfileOrAdmin(),
+ async (req, res, next) => {
+ const userRepository = getRepository(User);
- try {
- const user = await userRepository.findOne({
- where: { id: Number(req.params.id) },
- });
-
- if (!user) {
- return next({ status: 404, message: 'User not found.' });
- }
-
- if (!user.settings) {
- user.settings = new UserSettings({
- user: req.user,
- enableNotifications: req.body.enableNotifications,
- discordId: req.body.discordId,
+ try {
+ const user = await userRepository.findOne({
+ where: { id: Number(req.params.id) },
});
- } else {
- user.settings.enableNotifications = req.body.enableNotifications;
- user.settings.discordId = req.body.discordId;
+
+ if (!user) {
+ return next({ status: 404, message: 'User not found.' });
+ }
+
+ // "Owner" user settings cannot be modified by other users
+ if (user.id === 1 && req.user?.id !== 1) {
+ return next({
+ status: 403,
+ message: "You do not have permission to modify this user's settings.",
+ });
+ }
+
+ if (!user.settings) {
+ user.settings = new UserSettings({
+ user: req.user,
+ pgpKey: req.body.pgpKey,
+ discordId: req.body.discordId,
+ telegramChatId: req.body.telegramChatId,
+ telegramSendSilently: req.body.telegramSendSilently,
+ notificationTypes: req.body.notificationTypes,
+ });
+ } else {
+ user.settings.pgpKey = req.body.pgpKey;
+ user.settings.discordId = req.body.discordId;
+ user.settings.telegramChatId = req.body.telegramChatId;
+ user.settings.telegramSendSilently = req.body.telegramSendSilently;
+ user.settings.notificationTypes = Object.assign(
+ {},
+ user.settings.notificationTypes,
+ req.body.notificationTypes
+ );
+ }
+
+ userRepository.save(user);
+
+ return res.status(200).json({
+ pgpKey: user.settings?.pgpKey,
+ discordId: user.settings?.discordId,
+ telegramChatId: user.settings?.telegramChatId,
+ telegramSendSilently: user?.settings?.telegramSendSilently,
+ notificationTypes: user.settings.notificationTypes,
+ });
+ } catch (e) {
+ next({ status: 500, message: e.message });
}
-
- userRepository.save(user);
-
- return res.status(200).json({
- enableNotifications: user.settings.enableNotifications,
- discordId: user.settings.discordId,
- });
- } catch (e) {
- next({ status: 500, message: e.message });
}
-});
+);
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',
@@ -283,13 +370,20 @@ userSettingsRoutes.post<
return next({ status: 404, message: 'User not found.' });
}
- if (user.id === 1) {
+ // "Owner" user permissions cannot be modified, and users cannot set their own permissions
+ if (user.id === 1 || req.user?.id === user.id) {
return next({
- status: 500,
- message: 'Permissions for user with ID 1 cannot be modified',
+ status: 403,
+ message: 'You do not have permission to modify this user',
});
}
+ if (!canMakePermissionsChange(req.body.permissions, req.user)) {
+ return next({
+ status: 403,
+ message: 'You do not have permission to grant this level of access',
+ });
+ }
user.permissions = req.body.permissions;
await userRepository.save(user);
diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts
index b434f6c0f..fb9bf24c2 100644
--- a/server/subscriber/MediaSubscriber.ts
+++ b/server/subscriber/MediaSubscriber.ts
@@ -1,3 +1,4 @@
+import { truncate } from 'lodash';
import {
EntitySubscriberInterface,
EventSubscriber,
@@ -31,8 +32,14 @@ export class MediaSubscriber implements EntitySubscriberInterface {
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
notifyUser: request.requestedBy,
- subject: movie.title,
- message: movie.overview,
+ 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: request,
@@ -84,8 +91,14 @@ export class MediaSubscriber implements EntitySubscriberInterface {
);
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
- subject: tv.name,
- message: tv.overview,
+ subject: `${tv.name}${
+ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
+ }`,
+ message: truncate(tv.overview, {
+ length: 500,
+ separator: /\s/,
+ omission: '…',
+ }),
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity,
@@ -131,7 +144,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
event.entity.mediaType === MediaType.MOVIE &&
event.entity.status === MediaStatus.AVAILABLE
) {
- this.notifyAvailableMovie(event.entity, event.databaseEntity);
+ this.notifyAvailableMovie(event.entity as Media, event.databaseEntity);
}
if (
@@ -139,21 +152,21 @@ export class MediaSubscriber implements EntitySubscriberInterface {
(event.entity.status === MediaStatus.AVAILABLE ||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
) {
- this.notifyAvailableSeries(event.entity, event.databaseEntity);
+ this.notifyAvailableSeries(event.entity as Media, event.databaseEntity);
}
if (
event.entity.status === MediaStatus.AVAILABLE &&
event.databaseEntity.status === MediaStatus.PENDING
) {
- this.updateChildRequestStatus(event.entity, false);
+ this.updateChildRequestStatus(event.entity as Media, false);
}
if (
event.entity.status4k === MediaStatus.AVAILABLE &&
event.databaseEntity.status4k === MediaStatus.PENDING
) {
- this.updateChildRequestStatus(event.entity, true);
+ this.updateChildRequestStatus(event.entity as Media, true);
}
}
}
diff --git a/server/templates/email/generatedpassword/html.pug b/server/templates/email/generatedpassword/html.pug
index 1fa4713f6..129695abb 100644
--- a/server/templates/email/generatedpassword/html.pug
+++ b/server/templates/email/generatedpassword/html.pug
@@ -5,7 +5,7 @@ head
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
- link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
+ link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
@@ -26,73 +26,37 @@ head
mso-line-height-rule: exactly;
}
style.
- @media (max-width: 600px) {
- .sm-w-full {
+ .title:hover * {
+ text-decoration: underline;
+ }
+ @media only screen and (max-width:600px) {
+ table {
+ font-size: 20px !important;
width: 100% !important;
}
}
-div(role='article' aria-roledescription='email' aria-label='' lang='en')
- table(style="\
- background-color: #f2f4f6;\
- font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
- width: 100%;\
- " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
+div(style='display: block; background-color: #111827;')
+ table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
tr
- td(align='center')
- table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(align='center' style='\
- font-size: 16px;\
- padding-top: 25px;\
- padding-bottom: 25px;\
- text-align: center;\
- ')
- a(href=applicationUrl style='\
- text-shadow: 0 1px 0 #ffffff;\
- font-weight: 700;\
- font-size: 16px;\
- color: #a8aaaf;\
- text-decoration: none;\
- ')
- | #{applicationTitle}
- tr
- td(style='width: 100%' width='100%')
- table.sm-w-full(align='center' style='\
- background-color: #ffffff;\
- margin-left: auto;\
- margin-right: auto;\
- width: 570px;\
- ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(style='padding: 45px')
- div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
- | Your new password is:
- div(style='font-size: 16px; text-align: center')
- | #{password}
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- color: #51545e;\
- ')
- a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
-tr
- td
- table.sm-w-full(align='center' style='\
- margin-left: auto;\
- margin-right: auto;\
- text-align: center;\
- width: 570px;\
- ' width='570' cellpadding='0' cellspacing='0' role='presentation')
+ td(style="text-align: center;")
+ a(href=applicationUrl)
+ img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | An account has been created for you at #{applicationTitle}.
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 1rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | Your new password is:
+ div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | #{password}
+ if applicationUrl
tr
- td(align='center' style='font-size: 16px; padding: 45px')
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- text-align: center;\
- color: #a8aaaf;\
- ')
- | #{applicationTitle}
+ td
+ a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
+ span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
+ | Open #{applicationTitle}
diff --git a/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug
index 814fcab06..6d3a97403 100644
--- a/server/templates/email/media-request/html.pug
+++ b/server/templates/email/media-request/html.pug
@@ -5,7 +5,7 @@ head
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
- link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
+ link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
@@ -26,89 +26,56 @@ head
mso-line-height-rule: exactly;
}
style.
- @media (max-width: 600px) {
- .sm-w-full {
+ .title:hover * {
+ text-decoration: underline;
+ }
+ @media only screen and (max-width:600px) {
+ table {
+ font-size: 20px !important;
width: 100% !important;
}
}
-div(role='article' aria-roledescription='email' aria-label='' lang='en')
- table(style="\
- background-color: #f2f4f6;\
- font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
- width: 100%;\
- " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
+div(style='display: block; background-color: #111827;')
+ table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
tr
- td(align='center')
- table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(align='center' style='\
- font-size: 16px;\
- padding-top: 25px;\
- padding-bottom: 25px;\
- text-align: center;\
- ')
- a(href=applicationUrl style='\
- text-shadow: 0 1px 0 #ffffff;\
- font-weight: 700;\
- font-size: 16px;\
- color: #a8aaaf;\
- text-decoration: none;\
- ')
- | #{applicationTitle}
- tr
- td(style='width: 100%' width='100%')
- table.sm-w-full(align='center' style='\
- background-color: #ffffff;\
- margin-left: auto;\
- margin-right: auto;\
- width: 570px;\
- ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(style='padding: 45px')
- div(style='font-size: 16px')
- | #{body}
- br
- br
- p(style='margin-top: 4px; text-align: center')
- | #{mediaName}
- table(cellpadding='0' cellspacing='0' role='presentation')
- tr
- td
- table(cellpadding='0' cellspacing='0' role='presentation')
- a(href=actionUrl style='color: #3869d4')
- img(src=imageUrl alt='')
- p(style='\
- font-size: 16px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- color: #51545e;\
- ')
- | Requested by #{requestedBy} at #{timestamp}
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- color: #51545e;\
- ')
- a(href=actionUrl style='color: #3869d4') Open in #{applicationTitle}
-tr
- td
- table.sm-w-full(align='center' style='\
- margin-left: auto;\
- margin-right: auto;\
- text-align: center;\
- width: 570px;\
- ' width='570' cellpadding='0' cellspacing='0' role='presentation')
+ td(style="text-align: center;")
+ a(href=applicationUrl)
+ img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | #{body}
+ tr
+ td
+ div(style='box-sizing: border-box; margin: 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgba(100, 100, 100, 1); background: linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgb(17, 24, 39) 75%), url(' + imageUrl + ') center 25%/cover')
+ table(style='color: #fff; width: 100%;')
+ tr
+ td(style='vertical-align: top;')
+ a(href=actionUrl style='display: block; max-width: 20rem; color: #fff; font-weight: 700; text-decoration: none; margin: 0 1rem 0.25rem 0; font-size: 1.3em; line-height: 1.25em; margin-bottom: 5px;' class='title')
+ span
+ | #{mediaName}
+ div(style='overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .975em; line-height: 1.45em; padding-top: .25rem; padding-bottom: .25rem;')
+ span(style='display: block;')
+ b(style='color: #9ca3af; font-weight: 700;')
+ | Requested By
+ | #{requestedBy}
+ each extra in mediaExtra
+ span(style='display: block;')
+ b(style='color: #9ca3af; font-weight: 700;')
+ | #{extra.name}
+ | #{extra.value}
+ td(rowspan='2' style='width: 7rem;')
+ a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
+ div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
+ img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
+ tr
+ td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
+ span
+ | #{timestamp}
+ if actionUrl
tr
- td(align='center' style='font-size: 16px; padding: 45px')
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- text-align: center;\
- color: #a8aaaf;\
- ')
- | #{applicationTitle}
+ td
+ a(href=actionUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
+ span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
+ | Open in #{applicationTitle}
diff --git a/server/templates/email/resetpassword/html.pug b/server/templates/email/resetpassword/html.pug
index f7c8bb08d..a6fcc6468 100644
--- a/server/templates/email/resetpassword/html.pug
+++ b/server/templates/email/resetpassword/html.pug
@@ -5,7 +5,7 @@ head
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
- link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
+ link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
@@ -26,75 +26,34 @@ head
mso-line-height-rule: exactly;
}
style.
- @media (max-width: 600px) {
- .sm-w-full {
+ .title:hover * {
+ text-decoration: underline;
+ }
+ @media only screen and (max-width:600px) {
+ table {
+ font-size: 20px !important;
width: 100% !important;
}
}
-div(role='article' aria-roledescription='email' aria-label='' lang='en')
- table(style="\
- background-color: #f2f4f6;\
- font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
- width: 100%;\
- " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
+div(style='display: block; background-color: #111827;')
+ table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
tr
- td(align='center')
- table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(align='center' style='\
- font-size: 16px;\
- padding-top: 25px;\
- padding-bottom: 25px;\
- text-align: center;\
- ')
- a(href=applicationUrl style='\
- text-shadow: 0 1px 0 #ffffff;\
- font-weight: 700;\
- font-size: 16px;\
- color: #a8aaaf;\
- text-decoration: none;\
- ')
- | #{applicationTitle}
- tr
- td(style='width: 100%' width='100%')
- table.sm-w-full(align='center' style='\
- background-color: #ffffff;\
- margin-left: auto;\
- margin-right: auto;\
- width: 570px;\
- ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(style='padding: 45px')
- div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
- | A request to reset the password was made. Click
- a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here
- | to set a new password.
- div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
- | If you did not request this recovery link you can safely ignore this email.
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- color: #51545e;\
- ')
- a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
-tr
- td
- table.sm-w-full(align='center' style='\
- margin-left: auto;\
- margin-right: auto;\
- text-align: center;\
- width: 570px;\
- ' width='570' cellpadding='0' cellspacing='0' role='presentation')
+ td(style="text-align: center;")
+ a(href=applicationUrl)
+ img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | Your #{applicationTitle} account password was requested to be reset. Click below to reset your password.
+ if resetPasswordLink
tr
- td(align='center' style='font-size: 16px; padding: 45px')
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- text-align: center;\
- color: #a8aaaf;\
- ')
- | #{applicationTitle}.
+ td
+ a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
+ span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
+ | Reset Password
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 1rem; font-size: .85em;')
+ span
+ | If you did not request that your password be reset, you can safely ignore this email.
diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug
index b4abfebbf..9dc9044c0 100644
--- a/server/templates/email/test-email/html.pug
+++ b/server/templates/email/test-email/html.pug
@@ -5,7 +5,7 @@ head
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
- link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
+ link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
@@ -26,71 +26,29 @@ head
mso-line-height-rule: exactly;
}
style.
- @media (max-width: 600px) {
- .sm-w-full {
+ .title:hover * {
+ text-decoration: underline;
+ }
+ @media only screen and (max-width:600px) {
+ table {
+ font-size: 20px !important;
width: 100% !important;
}
}
-div(role='article' aria-roledescription='email' aria-label='' lang='en')
- table(style="\
- background-color: #f2f4f6;\
- font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
- width: 100%;\
- " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
+div(style='display: block; background-color: #111827;')
+ table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
tr
- td(align='center')
- table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(align='center' style='\
- font-size: 16px;\
- padding-top: 25px;\
- padding-bottom: 25px;\
- text-align: center;\
- ')
- a(href=applicationUrl style='\
- text-shadow: 0 1px 0 #ffffff;\
- font-weight: 700;\
- font-size: 16px;\
- color: #a8aaaf;\
- text-decoration: none;\
- ')
- | #{applicationTitle}
- tr
- td(style='width: 100%' width='100%')
- table.sm-w-full(align='center' style='\
- background-color: #ffffff;\
- margin-left: auto;\
- margin-right: auto;\
- width: 570px;\
- ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
- tr
- td(style='padding: 45px')
- div(style='font-size: 16px')
- | #{body}
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- color: #51545e;\
- ')
- a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
-tr
- td
- table.sm-w-full(align='center' style='\
- margin-left: auto;\
- margin-right: auto;\
- text-align: center;\
- width: 570px;\
- ' width='570' cellpadding='0' cellspacing='0' role='presentation')
+ td(style="text-align: center;")
+ a(href=applicationUrl)
+ img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
+ tr
+ td(style='text-align: center;')
+ div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
+ span
+ | #{body}
+ if applicationUrl
tr
- td(align='center' style='font-size: 16px; padding: 45px')
- p(style='\
- font-size: 13px;\
- line-height: 24px;\
- margin-top: 6px;\
- margin-bottom: 20px;\
- text-align: center;\
- color: #a8aaaf;\
- ')
- | #{applicationTitle}
+ td
+ a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
+ span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
+ | Open #{applicationTitle}
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 90a880069..ee7fd9724 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -6,6 +6,7 @@ declare global {
namespace Express {
export interface Request {
user?: User;
+ locale?: string;
}
}
diff --git a/server/types/plex-api.d.ts b/server/types/plex-api.d.ts
index 2e6cdc165..77be0ff49 100644
--- a/server/types/plex-api.d.ts
+++ b/server/types/plex-api.d.ts
@@ -21,6 +21,13 @@ declare module 'plex-api' {
requestOptions?: Record;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- query: >(endpoint: string) => Promise;
+ query: >(
+ endpoint:
+ | string
+ | {
+ uri: string;
+ extraHeaders?: Record;
+ }
+ ) => Promise;
}
}
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index 7aad64061..ecb8712ad 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -11,22 +11,22 @@ confinement: strict
parts:
overseerr:
plugin: nodejs
- nodejs-version: "12.18.4"
- nodejs-package-manager: "yarn"
- nodejs-yarn-version: v1.22.5
+ nodejs-version: '14.17.0'
+ nodejs-package-manager: 'yarn'
+ nodejs-yarn-version: v1.22.10
build-packages:
- git
- on arm64:
- - build-essential
- - automake
- - python-gi
- - python-gi-dev
+ - build-essential
+ - automake
+ - python-gi
+ - python-gi-dev
- on armhf:
- - libatomic1
- - build-essential
- - automake
- - python-gi
- - python-gi-dev
+ - libatomic1
+ - build-essential
+ - automake
+ - python-gi
+ - python-gi-dev
source: .
override-pull: |
snapcraftctl pull
@@ -56,7 +56,7 @@ parts:
snapcraftctl set-version "$SNAP_VERSION"
snapcraftctl set-grade "$GRADE"
build-environment:
- - PATH: "$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH"
+ - PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH'
override-build: |
set -e
# Set COMMIT_TAG before the build begins
@@ -72,11 +72,9 @@ parts:
rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml
stage-packages:
- on armhf:
- - libatomic1
- stage:
- [ .next, ./* ]
- prime:
- [ .next, ./* ]
+ - libatomic1
+ stage: [.next, ./*]
+ prime: [.next, ./*]
apps:
deamon:
@@ -89,8 +87,8 @@ apps:
- network
- network-bind
environment:
- PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH"
- OVERSEERR_SNAP: "True"
+ PATH: '$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH'
+ OVERSEERR_SNAP: 'True'
CONFIG_DIRECTORY: $SNAP_USER_COMMON
- LOG_LEVEL: "debug"
- NODE_ENV: "production"
+ LOG_LEVEL: 'debug'
+ NODE_ENV: 'production'
diff --git a/src/assets/available.svg b/src/assets/available.svg
deleted file mode 100644
index 87b9bdeb9..000000000
--- a/src/assets/available.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg
deleted file mode 100644
index d83a0d8aa..000000000
--- a/src/assets/bolt.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/download.svg b/src/assets/download.svg
deleted file mode 100644
index 4dd0492bd..000000000
--- a/src/assets/download.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/extlogos/discord.svg b/src/assets/extlogos/discord.svg
index bce41d990..64aef2025 100644
--- a/src/assets/extlogos/discord.svg
+++ b/src/assets/extlogos/discord.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/extlogos/lunasea.svg b/src/assets/extlogos/lunasea.svg
new file mode 100644
index 000000000..359ca8161
--- /dev/null
+++ b/src/assets/extlogos/lunasea.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/extlogos/pushbullet.svg b/src/assets/extlogos/pushbullet.svg
index c241c5d43..bd97ab860 100644
--- a/src/assets/extlogos/pushbullet.svg
+++ b/src/assets/extlogos/pushbullet.svg
@@ -1 +1 @@
-image/svg+xml
+
\ No newline at end of file
diff --git a/src/assets/extlogos/pushover.svg b/src/assets/extlogos/pushover.svg
index 7225c8059..19681a5bc 100644
--- a/src/assets/extlogos/pushover.svg
+++ b/src/assets/extlogos/pushover.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/extlogos/slack.svg b/src/assets/extlogos/slack.svg
index f292c13cd..5c0db3a2a 100644
--- a/src/assets/extlogos/slack.svg
+++ b/src/assets/extlogos/slack.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/extlogos/telegram.svg b/src/assets/extlogos/telegram.svg
index f7cc49334..ba9984de4 100644
--- a/src/assets/extlogos/telegram.svg
+++ b/src/assets/extlogos/telegram.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/requested.svg b/src/assets/requested.svg
deleted file mode 100644
index 825678d07..000000000
--- a/src/assets/requested.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/rt_aud_fresh.svg b/src/assets/rt_aud_fresh.svg
index 7143281bd..f9fa29044 100644
--- a/src/assets/rt_aud_fresh.svg
+++ b/src/assets/rt_aud_fresh.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/rt_aud_rotten.svg b/src/assets/rt_aud_rotten.svg
index c97f1f65a..cd84ac5b0 100644
--- a/src/assets/rt_aud_rotten.svg
+++ b/src/assets/rt_aud_rotten.svg
@@ -1,8 +1 @@
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/src/assets/rt_fresh.svg b/src/assets/rt_fresh.svg
index 89c3e610c..ed6f44d73 100644
--- a/src/assets/rt_fresh.svg
+++ b/src/assets/rt_fresh.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/rt_rotten.svg b/src/assets/rt_rotten.svg
index e9c99d22b..60ba169e0 100644
--- a/src/assets/rt_rotten.svg
+++ b/src/assets/rt_rotten.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/services/imdb.svg b/src/assets/services/imdb.svg
index 59602f7e1..ffbad298f 100644
--- a/src/assets/services/imdb.svg
+++ b/src/assets/services/imdb.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg
index 5debcdf37..5991b25d8 100644
--- a/src/assets/services/plex.svg
+++ b/src/assets/services/plex.svg
@@ -1 +1 @@
-plex-logo
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/services/radarr.svg b/src/assets/services/radarr.svg
new file mode 100644
index 000000000..1a373693d
--- /dev/null
+++ b/src/assets/services/radarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/services/rt.svg b/src/assets/services/rt.svg
index a5560ffac..b7792c3aa 100644
--- a/src/assets/services/rt.svg
+++ b/src/assets/services/rt.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/services/sonarr.svg b/src/assets/services/sonarr.svg
new file mode 100644
index 000000000..465330418
--- /dev/null
+++ b/src/assets/services/sonarr.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/services/tmdb.svg b/src/assets/services/tmdb.svg
index 84537a014..c40f3f7bc 100644
--- a/src/assets/services/tmdb.svg
+++ b/src/assets/services/tmdb.svg
@@ -1 +1 @@
-Asset 4
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/services/tvdb.svg b/src/assets/services/tvdb.svg
index f9369b4fe..872703a12 100644
--- a/src/assets/services/tvdb.svg
+++ b/src/assets/services/tvdb.svg
@@ -1 +1 @@
-image/svg+xml
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/spinner.svg b/src/assets/spinner.svg
index a5ade8a11..dde7eb8b4 100644
--- a/src/assets/spinner.svg
+++ b/src/assets/spinner.svg
@@ -1,21 +1 @@
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/src/assets/tmdb_logo.svg b/src/assets/tmdb_logo.svg
index e98e4ab29..bdf988ba7 100644
--- a/src/assets/tmdb_logo.svg
+++ b/src/assets/tmdb_logo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/unavailable.svg b/src/assets/unavailable.svg
deleted file mode 100644
index d94748051..000000000
--- a/src/assets/unavailable.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/useradd.svg b/src/assets/useradd.svg
deleted file mode 100644
index 1c6055ec6..000000000
--- a/src/assets/useradd.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/xcircle.svg b/src/assets/xcircle.svg
deleted file mode 100644
index 7a7b4533f..000000000
--- a/src/assets/xcircle.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx
index 3023db81a..fce97bd53 100644
--- a/src/components/AppDataWarning/index.tsx
+++ b/src/components/AppDataWarning/index.tsx
@@ -4,7 +4,6 @@ import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
- dockerVolumeMissing: 'Docker Volume Mount Missing',
dockerVolumeMissingDescription:
'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});
@@ -26,14 +25,14 @@ const AppDataWarning: React.FC = () => {
return (
<>
{!data.appData && (
-
- {intl.formatMessage(messages.dockerVolumeMissingDescription, {
+ {msg};
},
appDataPath: data.appDataPath,
})}
-
+ />
)}
>
);
diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx
index 5148a309a..180bd2ed5 100644
--- a/src/components/CollectionDetails/index.tsx
+++ b/src/components/CollectionDetails/index.tsx
@@ -1,40 +1,39 @@
+import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
import axios from 'axios';
+import { uniq } from 'lodash';
+import Link from 'next/link';
import { useRouter } from 'next/router';
-import React, { useContext, useState } from 'react';
+import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection';
-import { LanguageContext } from '../../context/LanguageContext';
+import useSettings from '../../hooks/useSettings';
+import { Permission, useUser } from '../../hooks/useUser';
+import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
-import StatusBadge from '../StatusBadge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
+import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
+import PageTitle from '../Common/PageTitle';
import Slider from '../Slider';
+import StatusBadge from '../StatusBadge';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
-import PageTitle from '../Common/PageTitle';
-import { useUser, Permission } from '../../hooks/useUser';
-import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
- overviewunavailable: 'Overview unavailable.',
overview: 'Overview',
- movies: 'Movies',
- numberofmovies: 'Number of Movies: {count}',
- requesting: 'Requesting…',
- request: 'Request',
+ numberofmovies: '{count} Movies',
requestcollection: 'Request Collection',
requestswillbecreated:
'The following titles will have requests created for them:',
- request4k: 'Request 4K',
requestcollection4k: 'Request Collection in 4K',
requestswillbecreated4k:
'The following titles will have 4K requests created for them:',
- requestSuccess: '{title} successfully requested!',
+ requestSuccess: '{title} requested successfully!',
});
interface CollectionDetailsProps {
@@ -48,20 +47,23 @@ const CollectionDetails: React.FC = ({
const router = useRouter();
const settings = useSettings();
const { addToast } = useToasts();
- const { locale } = useContext(LanguageContext);
const { hasPermission } = useUser();
const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false);
const [is4k, setIs4k] = useState(false);
const { data, error, revalidate } = useSWR(
- `/api/v1/collection/${router.query.collectionId}?language=${locale}`,
+ `/api/v1/collection/${router.query.collectionId}`,
{
initialData: collection,
revalidateOnMount: true,
}
);
+ const { data: genres } = useSWR<{ id: number; name: string }[]>(
+ `/api/v1/genres/movie`
+ );
+
if (!data && !error) {
return ;
}
@@ -105,6 +107,24 @@ const CollectionDetails: React.FC = ({
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
}
+ const hasRequestable =
+ hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
+ type: 'or',
+ }) &&
+ data.parts.filter(
+ (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
+ ).length > 0;
+
+ const hasRequestable4k =
+ settings.currentSettings.movie4kEnabled &&
+ hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
+ type: 'or',
+ }) &&
+ data.parts.filter(
+ (part) =>
+ !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
+ ).length > 0;
+
const requestableParts = data.parts.filter(
(part) =>
!part.mediaInfo ||
@@ -147,14 +167,68 @@ const CollectionDetails: React.FC = ({
}
};
+ const collectionAttributes: React.ReactNode[] = [];
+
+ collectionAttributes.push(
+ intl.formatMessage(messages.numberofmovies, {
+ count: data.parts.length,
+ })
+ );
+
+ if (genres && data.parts.some((part) => part.genreIds.length)) {
+ collectionAttributes.push(
+ uniq(
+ data.parts.reduce(
+ (genresList: number[], curr) => genresList.concat(curr.genreIds),
+ []
+ )
+ )
+ .map((genreId) => (
+
+
+ {genres.find((g) => g.id === genreId)?.name}
+
+
+ ))
+ .reduce((prev, curr) => (
+ <>
+ {intl.formatMessage(globalMessages.delimitedlist, {
+ a: prev,
+ b: curr,
+ })}
+ >
+ ))
+ );
+ }
+
return (
+ {data.backdropPath && (
+
+ )}
= ({
onOk={() => requestBundle()}
okText={
isRequesting
- ? intl.formatMessage(messages.requesting)
- : intl.formatMessage(is4k ? messages.request4k : messages.request)
+ ? intl.formatMessage(globalMessages.requesting)
+ : intl.formatMessage(
+ is4k ? globalMessages.request4k : globalMessages.request
+ )
}
okDisabled={isRequesting}
okButtonType="primary"
@@ -178,22 +254,7 @@ const CollectionDetails: React.FC = ({
title={intl.formatMessage(
is4k ? messages.requestcollection4k : messages.requestcollection
)}
- iconSvg={
-
-
-
- }
+ iconSvg={ }
>
{intl.formatMessage(
@@ -216,24 +277,29 @@ const CollectionDetails: React.FC = ({
-
-
-
+
+
-
-
-
- (part.mediaInfo?.downloadStatus ?? []).length > 0
- )}
- />
-
+
+
+ (part.mediaInfo?.downloadStatus ?? []).length > 0
+ )}
+ />
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -241,123 +307,79 @@ const CollectionDetails: React.FC = ({
type: 'or',
}
) && (
-
-
- (part.mediaInfo?.downloadStatus4k ?? []).length > 0
- )}
- />
-
+
+ (part.mediaInfo?.downloadStatus4k ?? []).length > 0
+ )}
+ />
)}
-
{data.name}
-
- {intl.formatMessage(messages.numberofmovies, {
- count: data.parts.length,
- })}
+ {data.name}
+
+ {collectionAttributes.length > 0 &&
+ collectionAttributes
+ .map((t, k) => {t} )
+ .reduce((prev, curr) => (
+ <>
+ {prev} | {curr}
+ >
+ ))}
-
- {hasPermission(Permission.REQUEST) &&
- (collectionStatus !== MediaStatus.AVAILABLE ||
- (settings.currentSettings.movie4kEnabled &&
- hasPermission(
- [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
- { type: 'or' }
- ) &&
- collectionStatus4k !== MediaStatus.AVAILABLE)) && (
-
-
+ {(hasRequestable || hasRequestable4k) && (
+ {
+ setRequestModal(true);
+ setIs4k(!hasRequestable);
+ }}
+ text={
+ <>
+
+
+ {intl.formatMessage(
+ hasRequestable
+ ? messages.requestcollection
+ : messages.requestcollection4k
+ )}
+
+ >
+ }
+ >
+ {hasRequestable && hasRequestable4k && (
+ {
setRequestModal(true);
- setIs4k(collectionStatus === MediaStatus.AVAILABLE);
+ setIs4k(true);
}}
- text={
- <>
-
-
-
-
- {intl.formatMessage(
- collectionStatus === MediaStatus.AVAILABLE
- ? messages.requestcollection4k
- : messages.requestcollection
- )}
-
- >
- }
>
- {settings.currentSettings.movie4kEnabled &&
- hasPermission(
- [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
- { type: 'or' }
- ) &&
- collectionStatus !== MediaStatus.AVAILABLE &&
- collectionStatus4k !== MediaStatus.AVAILABLE && (
- {
- setRequestModal(true);
- setIs4k(true);
- }}
- >
-
-
-
-
- {intl.formatMessage(messages.requestcollection4k)}
-
-
- )}
-
-
- )}
+
+
+ {intl.formatMessage(messages.requestcollection4k)}
+
+
+ )}
+
+ )}
-
-
-
- {intl.formatMessage(messages.overview)}
-
-
- {data.overview
- ? data.overview
- : intl.formatMessage(messages.overviewunavailable)}
-
-
-
-
-
-
-
{intl.formatMessage(messages.movies)}
+ {data.overview && (
+
+
+
{intl.formatMessage(messages.overview)}
+
{data.overview}
+ )}
+
+
+ {intl.formatMessage(globalMessages.movies)}
+
= ({ title, children, type }) => {
let design = {
bgColor: 'bg-yellow-600',
- titleColor: 'text-yellow-200',
+ titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
- svg: (
-
-
-
- ),
+ svg: ,
};
switch (type) {
case 'info':
design = {
bgColor: 'bg-indigo-600',
- titleColor: 'text-indigo-200',
+ titleColor: 'text-indigo-100',
textColor: 'text-indigo-300',
- svg: (
-
-
-
- ),
+ svg: ,
};
break;
case 'error':
design = {
bgColor: 'bg-red-600',
- titleColor: 'text-red-200',
+ titleColor: 'text-red-100',
textColor: 'text-red-300',
- svg: (
-
-
-
- ),
+ svg: ,
};
break;
}
return (
-
+
{design.svg}
-
- {title}
-
-
{children}
+ {title && (
+
+ {title}
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx
index bd5e467b6..f1083e5b2 100644
--- a/src/components/Common/Button/index.tsx
+++ b/src/components/Common/Button/index.tsx
@@ -45,52 +45,50 @@ function Button
(
ref?: React.Ref>
): JSX.Element {
const buttonStyle = [
- 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer',
+ 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
buttonStyle.push(
- 'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50'
+ 'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700'
);
break;
case 'danger':
buttonStyle.push(
- 'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50'
+ 'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
);
break;
case 'warning':
buttonStyle.push(
- 'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50'
+ 'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700'
);
break;
case 'success':
buttonStyle.push(
- 'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50'
+ 'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700'
);
break;
case 'ghost':
buttonStyle.push(
- 'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50'
+ 'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:
buttonStyle.push(
- 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
+ 'text-gray-200 bg-gray-600 border-gray-600 hover:text-white hover:bg-gray-500 hover:border-gray-500 group-hover:text-white group-hover:bg-gray-500 group-hover:border-gray-500 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-500 active:border-gray-500'
);
}
switch (buttonSize) {
case 'sm':
- buttonStyle.push('px-2.5 py-1.5 text-xs');
- break;
- case 'md':
- buttonStyle.push('px-4 py-2 text-sm');
+ buttonStyle.push('px-2.5 py-1.5 text-xs button-sm');
break;
case 'lg':
- buttonStyle.push('px-6 py-3 text-base');
+ buttonStyle.push('px-6 py-3 text-base button-lg');
break;
+ case 'md':
default:
- buttonStyle.push('px-4 py-2 text-sm');
+ buttonStyle.push('px-4 py-2 text-sm button-md');
}
buttonStyle.push(className ?? '');
diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx
index 054ecaea1..944c9d8bb 100644
--- a/src/components/Common/ButtonWithDropdown/index.tsx
+++ b/src/components/Common/ButtonWithDropdown/index.tsx
@@ -1,13 +1,14 @@
+import { ChevronDownIcon } from '@heroicons/react/solid';
import React, {
- useState,
- useRef,
AnchorHTMLAttributes,
- ReactNode,
ButtonHTMLAttributes,
+ ReactNode,
+ useRef,
+ useState,
} from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
-import Transition from '../../Transition';
import { withProperties } from '../../../utils/typeHelpers';
+import Transition from '../../Transition';
interface DropdownItemProps extends AnchorHTMLAttributes {
buttonType?: 'primary' | 'ghost';
@@ -18,16 +19,16 @@ const DropdownItem: React.FC = ({
buttonType = 'primary',
...props
}) => {
- let styleClass = '';
+ let styleClass = 'button-md text-white';
switch (buttonType) {
case 'ghost':
- styleClass =
- 'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white';
+ styleClass +=
+ ' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white';
break;
default:
- styleClass =
- 'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white';
+ styleClass +=
+ ' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
}
return (
= ({
useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
- mainButtonClasses: '',
- dropdownSideButtonClasses: '',
- dropdownClasses: '',
+ mainButtonClasses: 'button-md text-white border',
+ dropdownSideButtonClasses: 'button-md border',
+ dropdownClasses: 'button-md',
};
switch (buttonType) {
case 'ghost':
- styleClasses.mainButtonClasses =
- 'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
- styleClasses.dropdownSideButtonClasses =
- 'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
- styleClasses.dropdownClasses = 'bg-gray-700';
+ styleClasses.mainButtonClasses +=
+ ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
+ styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
+ styleClasses.dropdownClasses += ' bg-gray-700';
break;
default:
- styleClasses.mainButtonClasses =
- 'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
- styleClasses.dropdownSideButtonClasses =
- 'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
- styleClasses.dropdownClasses = 'bg-indigo-600';
+ styleClasses.mainButtonClasses +=
+ ' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
+ styleClasses.dropdownSideButtonClasses +=
+ ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
+ styleClasses.dropdownClasses += ' bg-indigo-600';
}
return (
-
+
= ({
{text}
{children && (
-
+
setIsOpen((state) => !state)}
>
- {dropdownIcon ? (
- dropdownIcon
- ) : (
-
-
-
- )}
+ {dropdownIcon ? dropdownIcon : }
= ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
+
diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx
new file mode 100644
index 000000000..c91f09465
--- /dev/null
+++ b/src/components/Common/CachedImage/index.tsx
@@ -0,0 +1,18 @@
+import Image, { ImageProps } from 'next/image';
+import React from 'react';
+import useSettings from '../../../hooks/useSettings';
+
+/**
+ * The CachedImage component should be used wherever
+ * we want to offer the option to locally cache images.
+ *
+ * It uses the `next/image` Image component but overrides
+ * the `unoptimized` prop based on the application setting `cacheImages`.
+ **/
+const CachedImage: React.FC
= (props) => {
+ const { currentSettings } = useSettings();
+
+ return ;
+};
+
+export default CachedImage;
diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx
index 902e9a281..be656d89d 100644
--- a/src/components/Common/ImageFader/index.tsx
+++ b/src/components/Common/ImageFader/index.tsx
@@ -1,14 +1,16 @@
import React, {
- useState,
- useEffect,
- HTMLAttributes,
ForwardRefRenderFunction,
+ HTMLAttributes,
+ useEffect,
+ useState,
} from 'react';
+import CachedImage from '../CachedImage';
interface ImageFaderProps extends HTMLAttributes {
backgroundImages: string[];
rotationSpeed?: number;
isDarker?: boolean;
+ forceOptimize?: boolean;
}
const DEFAULT_ROTATION_SPEED = 6000;
@@ -18,6 +20,7 @@ const ImageFader: ForwardRefRenderFunction = (
backgroundImages,
rotationSpeed = DEFAULT_ROTATION_SPEED,
isDarker,
+ forceOptimize,
...props
},
ref
@@ -43,19 +46,37 @@ const ImageFader: ForwardRefRenderFunction = (
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
}
+ let overrides = {};
+
+ if (forceOptimize) {
+ overrides = {
+ unoptimized: false,
+ };
+ }
+
return (
{backgroundImages.map((imageUrl, i) => (
+ >
+
+
+
))}
);
diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx
index 689fba5cb..6d447fcb8 100644
--- a/src/components/Common/List/index.tsx
+++ b/src/components/Common/List/index.tsx
@@ -3,15 +3,16 @@ import { withProperties } from '../../../utils/typeHelpers';
interface ListItemProps {
title: string;
+ className?: string;
}
-const ListItem: React.FC
= ({ title, children }) => {
+const ListItem: React.FC = ({ title, className, children }) => {
return (
-
{title}
+ {title}
- {children}
+ {children}
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx
index 5b3b0bc3b..78c08bf76 100644
--- a/src/components/Common/ListView/index.tsx
+++ b/src/components/Common/ListView/index.tsx
@@ -1,17 +1,14 @@
import React from 'react';
+import { useIntl } from 'react-intl';
import {
- TvResult,
MovieResult,
PersonResult,
+ TvResult,
} from '../../../../server/models/Search';
-import TitleCard from '../../TitleCard';
import useVerticalScroll from '../../../hooks/useVerticalScroll';
+import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
-import { defineMessages, useIntl } from 'react-intl';
-
-const messages = defineMessages({
- noresults: 'No results.',
-});
+import TitleCard from '../../TitleCard';
interface ListViewProps {
items?: (TvResult | MovieResult | PersonResult)[];
@@ -34,11 +31,11 @@ const ListView: React.FC = ({
<>
{isEmpty && (
- {intl.formatMessage(messages.noresults)}
+ {intl.formatMessage(globalMessages.noresults)}
)}
-
- {items?.map((title) => {
+
+ {items?.map((title, index) => {
let titleCard: React.ReactNode;
switch (title.mediaType) {
@@ -90,7 +87,7 @@ const ListView: React.FC = ({
break;
}
- return {titleCard} ;
+ return {titleCard} ;
})}
{isLoading &&
!isReachingEnd &&
diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx
index 77abc6462..97a9cd514 100644
--- a/src/components/Common/Modal/index.tsx
+++ b/src/components/Common/Modal/index.tsx
@@ -1,12 +1,13 @@
import React, { MouseEvent, ReactNode, useRef } from 'react';
import ReactDOM from 'react-dom';
-import Button, { ButtonType } from '../Button';
-import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
-import LoadingSpinner from '../LoadingSpinner';
-import useClickOutside from '../../../hooks/useClickOutside';
import { useIntl } from 'react-intl';
+import useClickOutside from '../../../hooks/useClickOutside';
+import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
+import Button, { ButtonType } from '../Button';
+import CachedImage from '../CachedImage';
+import LoadingSpinner from '../LoadingSpinner';
interface ModalProps {
title?: string;
@@ -29,6 +30,7 @@ interface ModalProps {
backgroundClickable?: boolean;
iconSvg?: ReactNode;
loading?: boolean;
+ backdrop?: string;
}
const Modal: React.FC = ({
@@ -53,6 +55,7 @@ const Modal: React.FC = ({
tertiaryDisabled = false,
tertiaryText,
onTertiary,
+ backdrop,
}) => {
const intl = useIntl();
const modalRef = useRef(null);
@@ -66,7 +69,7 @@ const Modal: React.FC = ({
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
{
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
@@ -98,18 +101,35 @@ const Modal: React.FC
= ({
show={!loading}
>
-
- {iconSvg && (
-
- {iconSvg}
-
- )}
+ {backdrop && (
+
+ )}
+
+ {iconSvg &&
{iconSvg}
}
= ({
>
{title && (
{title}
@@ -126,12 +146,12 @@ const Modal: React.FC = ({
{children && (
-
+
{children}
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
-
+
{typeof onOk === 'function' && (
= ({ links }) => {
@@ -20,26 +21,7 @@ const PlayButton: React.FC = ({ links }) => {
buttonType="ghost"
text={
<>
-
-
-
-
+ {links[0].svg}
{links[0].text}
>
}
@@ -57,7 +39,8 @@ const PlayButton: React.FC = ({ links }) => {
}}
buttonType="ghost"
>
- {link.text}
+ {link.svg}
+ {link.text}
);
})}
diff --git a/src/components/Common/ProgressCircle/index.tsx b/src/components/Common/ProgressCircle/index.tsx
new file mode 100644
index 000000000..64ca49c17
--- /dev/null
+++ b/src/components/Common/ProgressCircle/index.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect, useRef } from 'react';
+
+interface ProgressCircleProps {
+ className?: string;
+ progress?: number;
+ useHeatLevel?: boolean;
+}
+
+const ProgressCircle: React.FC = ({
+ className,
+ progress = 0,
+ useHeatLevel,
+}) => {
+ const ref = useRef(null);
+
+ let color = '';
+ let emptyColor = 'text-gray-300';
+
+ if (useHeatLevel) {
+ color = 'text-green-500';
+
+ if (progress <= 50) {
+ color = 'text-yellow-500';
+ }
+
+ if (progress <= 10) {
+ color = 'text-red-500';
+ }
+
+ if (progress === 0) {
+ emptyColor = 'text-red-600';
+ }
+ }
+
+ useEffect(() => {
+ if (ref && ref.current) {
+ const radius = ref.current?.r.baseVal.value;
+ const circumference = (radius ?? 0) * 2 * Math.PI;
+ const offset = circumference - (progress / 100) * circumference;
+ ref.current.style.strokeDashoffset = `${offset}`;
+ ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
+ }
+ });
+
+ return (
+
+
+
+
+ );
+};
+
+export default ProgressCircle;
diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx
new file mode 100644
index 000000000..886fd7217
--- /dev/null
+++ b/src/components/Common/SensitiveInput/index.tsx
@@ -0,0 +1,55 @@
+import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
+import { Field } from 'formik';
+import React, { useState } from 'react';
+
+interface CustomInputProps extends React.ComponentProps<'input'> {
+ as?: 'input';
+}
+
+interface CustomFieldProps extends React.ComponentProps {
+ as?: 'field';
+}
+
+type SensitiveInputProps = CustomInputProps | CustomFieldProps;
+
+const SensitiveInput: React.FC = ({
+ as = 'input',
+ ...props
+}) => {
+ const [isHidden, setHidden] = useState(true);
+ const Component = as === 'input' ? 'input' : Field;
+ const componentProps =
+ as === 'input'
+ ? props
+ : {
+ ...props,
+ as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined,
+ };
+ return (
+ <>
+
+ {
+ e.preventDefault();
+ setHidden(!isHidden);
+ }}
+ type="button"
+ className="input-action"
+ >
+ {isHidden ? : }
+
+ >
+ );
+};
+
+export default SensitiveInput;
diff --git a/src/components/Common/SettingsTabs/index.tsx b/src/components/Common/SettingsTabs/index.tsx
new file mode 100644
index 000000000..5f450cf7a
--- /dev/null
+++ b/src/components/Common/SettingsTabs/index.tsx
@@ -0,0 +1,171 @@
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import React from 'react';
+import { hasPermission, Permission } from '../../../../server/lib/permissions';
+import { useUser } from '../../../hooks/useUser';
+
+export interface SettingsRoute {
+ text: string;
+ content?: React.ReactNode;
+ route: string;
+ regex: RegExp;
+ requiredPermission?: Permission | Permission[];
+ permissionType?: { type: 'and' | 'or' };
+ hidden?: boolean;
+}
+
+const SettingsLink: React.FC<{
+ tabType: 'default' | 'button';
+ currentPath: string;
+ route: string;
+ regex: RegExp;
+ hidden?: boolean;
+ isMobile?: boolean;
+}> = ({
+ children,
+ tabType,
+ currentPath,
+ route,
+ regex,
+ hidden = false,
+ isMobile = false,
+}) => {
+ if (hidden) {
+ return null;
+ }
+
+ if (isMobile) {
+ return {children} ;
+ }
+
+ let linkClasses =
+ 'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 border-transparent whitespace-nowrap first:ml-0';
+ let activeLinkColor = 'text-indigo-500 border-indigo-600';
+ let inactiveLinkColor =
+ 'text-gray-500 border-transparent hover:text-gray-300 hover:border-gray-400 focus:text-gray-300 focus:border-gray-400';
+
+ if (tabType === 'button') {
+ linkClasses =
+ 'px-3 py-2 text-sm font-medium transition duration-300 rounded-md whitespace-nowrap mx-2 my-1';
+ activeLinkColor = 'bg-indigo-700';
+ inactiveLinkColor = 'bg-gray-800 hover:bg-gray-700 focus:bg-gray-700';
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+const SettingsTabs: React.FC<{
+ tabType?: 'default' | 'button';
+ settingsRoutes: SettingsRoute[];
+}> = ({ tabType = 'default', settingsRoutes }) => {
+ const router = useRouter();
+ const { user: currentUser } = useUser();
+
+ return (
+ <>
+
+
+ Select a Tab
+
+ {
+ router.push(e.target.value);
+ }}
+ onBlur={(e) => {
+ router.push(e.target.value);
+ }}
+ defaultValue={
+ settingsRoutes.find((route) => !!router.pathname.match(route.regex))
+ ?.route
+ }
+ aria-label="Selected Tab"
+ >
+ {settingsRoutes
+ .filter(
+ (route) =>
+ !route.hidden &&
+ (route.requiredPermission
+ ? hasPermission(
+ route.requiredPermission,
+ currentUser?.permissions ?? 0,
+ route.permissionType
+ )
+ : true)
+ )
+ .map((route, index) => (
+
+ {route.text}
+
+ ))}
+
+
+ {tabType === 'button' ? (
+
+
+ {settingsRoutes.map((route, index) => (
+
+ {route.content ?? route.text}
+
+ ))}
+
+
+ ) : (
+
+
+ {settingsRoutes
+ .filter(
+ (route) =>
+ !route.hidden &&
+ (route.requiredPermission
+ ? hasPermission(
+ route.requiredPermission,
+ currentUser?.permissions ?? 0,
+ route.permissionType
+ )
+ : true)
+ )
+ .map((route, index) => (
+
+ {route.text}
+
+ ))}
+
+
+ )}
+ >
+ );
+};
+
+export default SettingsTabs;
diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx
index 43c47cc82..736a4a6e2 100644
--- a/src/components/Common/SlideOver/index.tsx
+++ b/src/components/Common/SlideOver/index.tsx
@@ -1,8 +1,9 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
-import React, { useState, useEffect, useRef } from 'react';
+import { XIcon } from '@heroicons/react/outline';
+import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
-import Transition from '../../Transition';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
+import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
@@ -43,7 +44,7 @@ const SlideOver: React.FC = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
onClose()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -70,9 +71,9 @@ const SlideOver: React.FC
= ({
onClick={(e) => e.stopPropagation()}
>
-
+
-
+
{title}
@@ -81,20 +82,7 @@ const SlideOver: React.FC
= ({
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
-
-
-
+
diff --git a/src/components/Common/Table/index.tsx b/src/components/Common/Table/index.tsx
index 3bf03b7bb..65b1549f7 100644
--- a/src/components/Common/Table/index.tsx
+++ b/src/components/Common/Table/index.tsx
@@ -13,7 +13,7 @@ const TH: React.FC> = ({
...props
}) => {
const style = [
- 'px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider',
+ 'px-4 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider truncate',
];
if (className) {
@@ -39,7 +39,7 @@ const TD: React.FC = ({
className,
...props
}) => {
- const style = ['whitespace-nowrap text-sm leading-5 text-white'];
+ const style = ['text-sm leading-5 text-white'];
switch (alignText) {
case 'left':
@@ -54,7 +54,7 @@ const TD: React.FC = ({
}
if (!noPadding) {
- style.push('px-6 py-4');
+ style.push('px-4 py-4');
}
if (className) {
@@ -73,7 +73,7 @@ const Table: React.FC = ({ children }) => {
-
diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx
new file mode 100644
index 000000000..396f6e85e
--- /dev/null
+++ b/src/components/CompanyCard/index.tsx
@@ -0,0 +1,48 @@
+import Link from 'next/link';
+import React, { useState } from 'react';
+
+interface CompanyCardProps {
+ name: string;
+ image: string;
+ url: string;
+}
+
+const CompanyCard: React.FC
= ({ image, url, name }) => {
+ const [isHovered, setHovered] = useState(false);
+
+ return (
+
+ {
+ setHovered(true);
+ }}
+ onMouseLeave={() => setHovered(false)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ setHovered(true);
+ }
+ }}
+ role="link"
+ tabIndex={0}
+ >
+
+
+
+
+ );
+};
+
+export default CompanyCard;
diff --git a/src/components/Discover/DiscoverMovieGenre/index.tsx b/src/components/Discover/DiscoverMovieGenre/index.tsx
new file mode 100644
index 000000000..e340f4eb9
--- /dev/null
+++ b/src/components/Discover/DiscoverMovieGenre/index.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import type { MovieResult } from '../../../../server/models/Search';
+import ListView from '../../Common/ListView';
+import { defineMessages, useIntl } from 'react-intl';
+import Header from '../../Common/Header';
+import PageTitle from '../../Common/PageTitle';
+import { useRouter } from 'next/router';
+import globalMessages from '../../../i18n/globalMessages';
+import useDiscover from '../../../hooks/useDiscover';
+import Error from '../../../pages/_error';
+
+const messages = defineMessages({
+ genreMovies: '{genre} Movies',
+});
+
+const DiscoverMovieGenre: React.FC = () => {
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ firstResultData,
+ } = useDiscover(
+ `/api/v1/discover/movies/genre/${router.query.genreId}`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.genreMovies, {
+ genre: firstResultData?.genre.name,
+ });
+
+ return (
+ <>
+
+
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverMovieGenre;
diff --git a/src/components/Discover/DiscoverMovieLanguage/index.tsx b/src/components/Discover/DiscoverMovieLanguage/index.tsx
new file mode 100644
index 000000000..b1e19d055
--- /dev/null
+++ b/src/components/Discover/DiscoverMovieLanguage/index.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import type { MovieResult } from '../../../../server/models/Search';
+import ListView from '../../Common/ListView';
+import { defineMessages, useIntl } from 'react-intl';
+import Header from '../../Common/Header';
+import PageTitle from '../../Common/PageTitle';
+import { useRouter } from 'next/router';
+import globalMessages from '../../../i18n/globalMessages';
+import useDiscover from '../../../hooks/useDiscover';
+import Error from '../../../pages/_error';
+
+const messages = defineMessages({
+ languageMovies: '{language} Movies',
+});
+
+const DiscoverMovieLanguage: React.FC = () => {
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover<
+ MovieResult,
+ {
+ originalLanguage: {
+ iso_639_1: string;
+ english_name: string;
+ name: string;
+ };
+ }
+ >(`/api/v1/discover/movies/language/${router.query.language}`);
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.languageMovies, {
+ language: intl.formatDisplayName(router.query.language as string, {
+ type: 'language',
+ fallback: 'none',
+ }),
+ });
+
+ return (
+ <>
+
+
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverMovieLanguage;
diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx
index 4ebad1430..cef4c6230 100644
--- a/src/components/Discover/DiscoverMovies.tsx
+++ b/src/components/Discover/DiscoverMovies.tsx
@@ -1,80 +1,40 @@
-import React, { useContext } from 'react';
-import { useSWRInfinite } from 'swr';
+import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
-import { LanguageContext } from '../../context/LanguageContext';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
-import useSettings from '../../hooks/useSettings';
-import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
+import useDiscover from '../../hooks/useDiscover';
+import Error from '../../pages/_error';
const messages = defineMessages({
discovermovies: 'Popular Movies',
});
-interface SearchResult {
- page: number;
- totalResults: number;
- totalPages: number;
- results: MovieResult[];
-}
-
const DiscoverMovies: React.FC = () => {
const intl = useIntl();
- const settings = useSettings();
- const { locale } = useContext(LanguageContext);
- const { data, error, size, setSize } = useSWRInfinite(
- (pageIndex: number, previousPageData: SearchResult | null) => {
- if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
- return null;
- }
- return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`;
- },
- {
- initialSize: 3,
- }
- );
-
- const isLoadingInitialData = !data && !error;
- const isLoadingMore =
- isLoadingInitialData ||
- (size > 0 && data && typeof data[size - 1] === 'undefined');
-
- const fetchMore = () => {
- setSize(size + 1);
- };
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover('/api/v1/discover/movies');
if (error) {
- return {error}
;
+ return ;
}
- let titles = (data ?? []).reduce(
- (a, v) => [...a, ...v.results],
- [] as MovieResult[]
- );
-
- if (settings.currentSettings.hideAvailable) {
- titles = titles.filter(
- (i) =>
- (i.mediaType === 'movie' || i.mediaType === 'tv') &&
- i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
- i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
- );
- }
-
- const isEmpty = !isLoadingInitialData && titles?.length === 0;
- const isReachingEnd =
- isEmpty || (data && data[data.length - 1]?.results.length < 20);
+ const title = intl.formatMessage(messages.discovermovies);
return (
<>
-
+
-
+
{
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ firstResultData,
+ } = useDiscover(
+ `/api/v1/discover/tv/network/${router.query.networkId}`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.networkSeries, {
+ network: firstResultData?.network.name,
+ });
+
+ return (
+ <>
+
+
+
+ {firstResultData?.network.logoPath ? (
+
+
+
+ ) : (
+ title
+ )}
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverTvNetwork;
diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx
new file mode 100644
index 000000000..bc7e270d3
--- /dev/null
+++ b/src/components/Discover/DiscoverStudio/index.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import type { MovieResult } from '../../../../server/models/Search';
+import ListView from '../../Common/ListView';
+import { defineMessages, useIntl } from 'react-intl';
+import Header from '../../Common/Header';
+import PageTitle from '../../Common/PageTitle';
+import { useRouter } from 'next/router';
+import globalMessages from '../../../i18n/globalMessages';
+import useDiscover from '../../../hooks/useDiscover';
+import Error from '../../../pages/_error';
+import { ProductionCompany } from '../../../../server/models/common';
+
+const messages = defineMessages({
+ studioMovies: '{studio} Movies',
+});
+
+const DiscoverMovieStudio: React.FC = () => {
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ firstResultData,
+ } = useDiscover(
+ `/api/v1/discover/movies/studio/${router.query.studioId}`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.studioMovies, {
+ studio: firstResultData?.studio.name,
+ });
+
+ return (
+ <>
+
+
+
+ {firstResultData?.studio.logoPath ? (
+
+
+
+ ) : (
+ title
+ )}
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverMovieStudio;
diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx
index d75cd943d..60c292258 100644
--- a/src/components/Discover/DiscoverTv.tsx
+++ b/src/components/Discover/DiscoverTv.tsx
@@ -1,79 +1,40 @@
-import React, { useContext } from 'react';
-import { useSWRInfinite } from 'swr';
+import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
-import { LanguageContext } from '../../context/LanguageContext';
+import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
-import useSettings from '../../hooks/useSettings';
-import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
+import useDiscover from '../../hooks/useDiscover';
+import Error from '../../pages/_error';
const messages = defineMessages({
discovertv: 'Popular Series',
});
-interface SearchResult {
- page: number;
- totalResults: number;
- totalPages: number;
- results: TvResult[];
-}
-
const DiscoverTv: React.FC = () => {
const intl = useIntl();
- const settings = useSettings();
- const { locale } = useContext(LanguageContext);
- const { data, error, size, setSize } = useSWRInfinite(
- (pageIndex: number, previousPageData: SearchResult | null) => {
- if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
- return null;
- }
- return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`;
- },
- {
- initialSize: 3,
- }
- );
-
- const isLoadingInitialData = !data && !error;
- const isLoadingMore =
- isLoadingInitialData ||
- (size > 0 && data && typeof data[size - 1] === 'undefined');
-
- const fetchMore = () => {
- setSize(size + 1);
- };
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover('/api/v1/discover/tv');
if (error) {
- return {error}
;
+ return ;
}
- let titles = (data ?? []).reduce(
- (a, v) => [...a, ...v.results],
- [] as TvResult[]
- );
-
- if (settings.currentSettings.hideAvailable) {
- titles = titles.filter(
- (i) =>
- i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
- i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
- );
- }
-
- const isEmpty = !isLoadingInitialData && titles?.length === 0;
- const isReachingEnd =
- isEmpty || (data && data[data.length - 1]?.results.length < 20);
+ const title = intl.formatMessage(messages.discovertv);
return (
<>
-
+
-
+
{
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ firstResultData,
+ } = useDiscover(
+ `/api/v1/discover/tv/genre/${router.query.genreId}`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.genreSeries, {
+ genre: firstResultData?.genre.name,
+ });
+
+ return (
+ <>
+
+
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverTvGenre;
diff --git a/src/components/Discover/DiscoverTvLanguage/index.tsx b/src/components/Discover/DiscoverTvLanguage/index.tsx
new file mode 100644
index 000000000..ed0873f90
--- /dev/null
+++ b/src/components/Discover/DiscoverTvLanguage/index.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import type { TvResult } from '../../../../server/models/Search';
+import ListView from '../../Common/ListView';
+import { defineMessages, useIntl } from 'react-intl';
+import Header from '../../Common/Header';
+import PageTitle from '../../Common/PageTitle';
+import { useRouter } from 'next/router';
+import globalMessages from '../../../i18n/globalMessages';
+import useDiscover from '../../../hooks/useDiscover';
+import Error from '../../../pages/_error';
+
+const messages = defineMessages({
+ languageSeries: '{language} Series',
+});
+
+const DiscoverTvLanguage: React.FC = () => {
+ const router = useRouter();
+ const intl = useIntl();
+
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover<
+ TvResult,
+ {
+ originalLanguage: {
+ iso_639_1: string;
+ english_name: string;
+ name: string;
+ };
+ }
+ >(`/api/v1/discover/tv/language/${router.query.language}`);
+
+ if (error) {
+ return ;
+ }
+
+ const title = isLoadingInitialData
+ ? intl.formatMessage(globalMessages.loading)
+ : intl.formatMessage(messages.languageSeries, {
+ language: intl.formatDisplayName(router.query.language as string, {
+ type: 'language',
+ fallback: 'none',
+ }),
+ });
+
+ return (
+ <>
+
+
+
+
+ 0)
+ }
+ isReachingEnd={isReachingEnd}
+ onScrollBottom={fetchMore}
+ />
+ >
+ );
+};
+
+export default DiscoverTvLanguage;
diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx
index 6e08c29db..5b59f26a2 100644
--- a/src/components/Discover/DiscoverTvUpcoming.tsx
+++ b/src/components/Discover/DiscoverTvUpcoming.tsx
@@ -1,81 +1,38 @@
-import React, { useContext } from 'react';
-import { useSWRInfinite } from 'swr';
+import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
-import { LanguageContext } from '../../context/LanguageContext';
+import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
-import useSettings from '../../hooks/useSettings';
-import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
+import useDiscover from '../../hooks/useDiscover';
+import Error from '../../pages/_error';
const messages = defineMessages({
upcomingtv: 'Upcoming Series',
});
-interface SearchResult {
- page: number;
- totalResults: number;
- totalPages: number;
- results: TvResult[];
-}
-
const DiscoverTvUpcoming: React.FC = () => {
const intl = useIntl();
- const settings = useSettings();
- const { locale } = useContext(LanguageContext);
- const { data, error, size, setSize } = useSWRInfinite(
- (pageIndex: number, previousPageData: SearchResult | null) => {
- if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
- return null;
- }
- return `/api/v1/discover/tv/upcoming?page=${
- pageIndex + 1
- }&language=${locale}`;
- },
- {
- initialSize: 3,
- }
- );
-
- const isLoadingInitialData = !data && !error;
- const isLoadingMore =
- isLoadingInitialData ||
- (size > 0 && data && typeof data[size - 1] === 'undefined');
-
- const fetchMore = () => {
- setSize(size + 1);
- };
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover('/api/v1/discover/tv/upcoming');
if (error) {
- return {error}
;
+ return ;
}
- let titles = (data ?? []).reduce(
- (a, v) => [...a, ...v.results],
- [] as TvResult[]
- );
-
- if (settings.currentSettings.hideAvailable) {
- titles = titles.filter(
- (i) =>
- i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
- i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
- );
- }
-
- const isEmpty = !isLoadingInitialData && titles?.length === 0;
- const isReachingEnd =
- isEmpty || (data && data[data.length - 1]?.results.length < 20);
-
return (
<>
-
+ {intl.formatMessage(messages.upcomingtv)}
{
+ const intl = useIntl();
+ const { data, error } = useSWR(
+ `/api/v1/discover/genreslider/movie`
+ );
+
+ if (!data && !error) {
+ return ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {intl.formatMessage(messages.moviegenres)}
+
+
+ {data.map((genre, index) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+
+export default MovieGenreList;
diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx
new file mode 100644
index 000000000..cf1b8ce1f
--- /dev/null
+++ b/src/components/Discover/MovieGenreSlider/index.tsx
@@ -0,0 +1,56 @@
+import { ArrowCircleRightIcon } from '@heroicons/react/outline';
+import Link from 'next/link';
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import useSWR from 'swr';
+import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
+import GenreCard from '../../GenreCard';
+import Slider from '../../Slider';
+import { genreColorMap } from '../constants';
+
+const messages = defineMessages({
+ moviegenres: 'Movie Genres',
+});
+
+const MovieGenreSlider: React.FC = () => {
+ const intl = useIntl();
+ const { data, error } = useSWR(
+ `/api/v1/discover/genreslider/movie`,
+ {
+ refreshInterval: 0,
+ revalidateOnFocus: false,
+ }
+ );
+
+ return (
+ <>
+
+ (
+
+ ))}
+ placeholder={ }
+ emptyMessage=""
+ />
+ >
+ );
+};
+
+export default React.memo(MovieGenreSlider);
diff --git a/src/components/Discover/NetworkSlider/index.tsx b/src/components/Discover/NetworkSlider/index.tsx
new file mode 100644
index 000000000..362da898a
--- /dev/null
+++ b/src/components/Discover/NetworkSlider/index.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import CompanyCard from '../../CompanyCard';
+import Slider from '../../Slider';
+
+const messages = defineMessages({
+ networks: 'Networks',
+});
+
+interface Network {
+ name: string;
+ image: string;
+ url: string;
+}
+
+const networks: Network[] = [
+ {
+ name: 'Netflix',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
+ url: '/discover/tv/network/213',
+ },
+ {
+ name: 'Disney+',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
+ url: '/discover/tv/network/2739',
+ },
+ {
+ name: 'Prime Video',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
+ url: '/discover/tv/network/1024',
+ },
+ {
+ name: 'Apple TV+',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
+ url: '/discover/tv/network/2552',
+ },
+ {
+ name: 'HBO',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
+ url: '/discover/tv/network/49',
+ },
+ {
+ name: 'ABC',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
+ url: '/discover/tv/network/2',
+ },
+ {
+ name: 'FOX',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
+ url: '/discover/tv/network/19',
+ },
+ {
+ name: 'Cinemax',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
+ url: '/discover/tv/network/359',
+ },
+ {
+ name: 'AMC',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png',
+ url: '/discover/tv/network/174',
+ },
+ {
+ name: 'Showtime',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
+ url: '/discover/tv/network/67',
+ },
+ {
+ name: 'Starz',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
+ url: '/discover/tv/network/318',
+ },
+ {
+ name: 'The CW',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
+ url: '/discover/tv/network/71',
+ },
+ {
+ name: 'NBC',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
+ url: '/discover/tv/network/6',
+ },
+ {
+ name: 'CBS',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
+ url: '/discover/tv/network/16',
+ },
+ {
+ name: 'BBC One',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
+ url: '/discover/tv/network/4',
+ },
+ {
+ name: 'Cartoon Network',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
+ url: '/discover/tv/network/56',
+ },
+ {
+ name: 'Adult Swim',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
+ url: '/discover/tv/network/80',
+ },
+ {
+ name: 'Nickelodeon',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
+ url: '/discover/tv/network/13',
+ },
+];
+
+const NetworkSlider: React.FC = () => {
+ const intl = useIntl();
+
+ return (
+ <>
+
+
+ {intl.formatMessage(messages.networks)}
+
+
+ (
+
+ ))}
+ emptyMessage=""
+ />
+ >
+ );
+};
+
+export default NetworkSlider;
diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx
new file mode 100644
index 000000000..59f0e8c07
--- /dev/null
+++ b/src/components/Discover/StudioSlider/index.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import CompanyCard from '../../CompanyCard';
+import Slider from '../../Slider';
+
+const messages = defineMessages({
+ studios: 'Studios',
+});
+
+interface Studio {
+ name: string;
+ image: string;
+ url: string;
+}
+
+const studios: Studio[] = [
+ {
+ name: 'Disney',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png',
+ url: '/discover/movies/studio/2',
+ },
+ {
+ name: '20th Century Fox',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
+ url: '/discover/movies/studio/25',
+ },
+ {
+ name: 'Sony Pictures',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png',
+ url: '/discover/movies/studio/34',
+ },
+ {
+ name: 'Warner Bros. Pictures',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png',
+ url: '/discover/movies/studio/174',
+ },
+ {
+ name: 'Universal',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
+ url: '/discover/movies/studio/33',
+ },
+ {
+ name: 'Paramount',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png',
+ url: '/discover/movies/studio/4',
+ },
+ {
+ name: 'Pixar',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
+ url: '/discover/movies/studio/3',
+ },
+ {
+ name: 'Dreamworks',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
+ url: '/discover/movies/studio/521',
+ },
+ {
+ name: 'Marvel Studios',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
+ url: '/discover/movies/studio/420',
+ },
+ {
+ name: 'DC',
+ image:
+ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png',
+ url: '/discover/movies/studio/9993',
+ },
+];
+
+const StudioSlider: React.FC = () => {
+ const intl = useIntl();
+
+ return (
+ <>
+
+
+ {intl.formatMessage(messages.studios)}
+
+
+ (
+
+ ))}
+ emptyMessage=""
+ />
+ >
+ );
+};
+
+export default StudioSlider;
diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx
index 75da4a410..c0f2e222d 100644
--- a/src/components/Discover/Trending.tsx
+++ b/src/components/Discover/Trending.tsx
@@ -1,86 +1,43 @@
-import React, { useContext } from 'react';
-import { useSWRInfinite } from 'swr';
+import React from 'react';
import type {
MovieResult,
TvResult,
PersonResult,
} from '../../../server/models/Search';
import ListView from '../Common/ListView';
-import { LanguageContext } from '../../context/LanguageContext';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
-import useSettings from '../../hooks/useSettings';
-import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
+import useDiscover from '../../hooks/useDiscover';
+import Error from '../../pages/_error';
const messages = defineMessages({
trending: 'Trending',
});
-interface SearchResult {
- page: number;
- totalResults: number;
- totalPages: number;
- results: (MovieResult | TvResult | PersonResult)[];
-}
-
const Trending: React.FC = () => {
const intl = useIntl();
- const settings = useSettings();
- const { locale } = useContext(LanguageContext);
- const { data, error, size, setSize } = useSWRInfinite(
- (pageIndex: number, previousPageData: SearchResult | null) => {
- if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
- return null;
- }
-
- return `/api/v1/discover/trending?page=${
- pageIndex + 1
- }&language=${locale}`;
- },
- {
- initialSize: 3,
- }
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover(
+ '/api/v1/discover/trending'
);
- const isLoadingInitialData = !data && !error;
- const isLoadingMore =
- isLoadingInitialData ||
- (size > 0 && data && typeof data[size - 1] === 'undefined');
-
- const fetchMore = () => {
- setSize(size + 1);
- };
-
if (error) {
- return {error}
;
+ return ;
}
- let titles = (data ?? []).reduce(
- (a, v) => [...a, ...v.results],
- [] as (MovieResult | TvResult | PersonResult)[]
- );
-
- if (settings.currentSettings.hideAvailable) {
- titles = titles.filter(
- (i) =>
- (i.mediaType === 'movie' || i.mediaType === 'tv') &&
- i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
- i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
- );
- }
-
- const isEmpty = !isLoadingInitialData && titles?.length === 0;
- const isReachingEnd =
- isEmpty || (data && data[data.length - 1]?.results.length < 20);
-
return (
<>
-
+ {intl.formatMessage(messages.trending)}
{
+ const intl = useIntl();
+ const { data, error } = useSWR(
+ `/api/v1/discover/genreslider/tv`
+ );
+
+ if (!data && !error) {
+ return ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {intl.formatMessage(messages.seriesgenres)}
+
+
+ {data.map((genre, index) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+
+export default TvGenreList;
diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx
new file mode 100644
index 000000000..54f8daa34
--- /dev/null
+++ b/src/components/Discover/TvGenreSlider/index.tsx
@@ -0,0 +1,56 @@
+import { ArrowCircleRightIcon } from '@heroicons/react/outline';
+import Link from 'next/link';
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import useSWR from 'swr';
+import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
+import GenreCard from '../../GenreCard';
+import Slider from '../../Slider';
+import { genreColorMap } from '../constants';
+
+const messages = defineMessages({
+ tvgenres: 'Series Genres',
+});
+
+const TvGenreSlider: React.FC = () => {
+ const intl = useIntl();
+ const { data, error } = useSWR(
+ `/api/v1/discover/genreslider/tv`,
+ {
+ refreshInterval: 0,
+ revalidateOnFocus: false,
+ }
+ );
+
+ return (
+ <>
+
+ (
+
+ ))}
+ placeholder={ }
+ emptyMessage=""
+ />
+ >
+ );
+};
+
+export default React.memo(TvGenreSlider);
diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx
index bc14a768e..1e14f73dc 100644
--- a/src/components/Discover/Upcoming.tsx
+++ b/src/components/Discover/Upcoming.tsx
@@ -1,81 +1,38 @@
-import React, { useContext } from 'react';
-import { useSWRInfinite } from 'swr';
+import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
-import { LanguageContext } from '../../context/LanguageContext';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
-import useSettings from '../../hooks/useSettings';
-import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
+import useDiscover from '../../hooks/useDiscover';
+import Error from '../../pages/_error';
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
});
-interface SearchResult {
- page: number;
- totalResults: number;
- totalPages: number;
- results: MovieResult[];
-}
-
const UpcomingMovies: React.FC = () => {
const intl = useIntl();
- const settings = useSettings();
- const { locale } = useContext(LanguageContext);
- const { data, error, size, setSize } = useSWRInfinite(
- (pageIndex: number, previousPageData: SearchResult | null) => {
- if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
- return null;
- }
- return `/api/v1/discover/movies/upcoming?page=${
- pageIndex + 1
- }&language=${locale}`;
- },
- {
- initialSize: 3,
- }
- );
-
- const isLoadingInitialData = !data && !error;
- const isLoadingMore =
- isLoadingInitialData ||
- (size > 0 && data && typeof data[size - 1] === 'undefined');
-
- const fetchMore = () => {
- setSize(size + 1);
- };
+ const {
+ isLoadingInitialData,
+ isEmpty,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ error,
+ } = useDiscover('/api/v1/discover/movies/upcoming');
if (error) {
- return