Compare commits
5 Commits
v1.24.0
...
v1.0.0-arm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc526ff26d | ||
|
|
bffb84d08b | ||
|
|
a66872588e | ||
|
|
a10921e411 | ||
|
|
7ca08057a7 |
@@ -364,8 +364,7 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
|
||||
"profile": "https://github.com/HubDuck",
|
||||
"contributions": [
|
||||
"translation",
|
||||
"doc"
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -377,96 +376,6 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
@@ -8,20 +8,16 @@
|
||||
.git
|
||||
.gitbook.yaml
|
||||
.gitconfig
|
||||
.github
|
||||
.gitignore
|
||||
.github
|
||||
.next
|
||||
.prettierignore
|
||||
config/db/*
|
||||
config/logs/*
|
||||
config/*.json
|
||||
dist
|
||||
config/db/db.sqlite3
|
||||
config/db/logs/overseerr.log
|
||||
Dockerfile*
|
||||
docker-compose.yml
|
||||
docs
|
||||
LICENSE
|
||||
node_modules
|
||||
public/os_logo_square.png
|
||||
public/preview.jpg
|
||||
snap
|
||||
stylelint.config.js
|
||||
|
||||
@@ -4,10 +4,12 @@ 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',
|
||||
'prettier/react',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 6,
|
||||
|
||||
26
.gitattributes
vendored
@@ -1,26 +0,0 @@
|
||||
* 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
|
||||
22
.github/workflows/ci.yml
vendored
@@ -12,21 +12,21 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.16-alpine
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
- 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 Docker Images
|
||||
name: Build & Publish to Docker Hub
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -38,23 +38,23 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v2.1.4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Log in to Docker Hub
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
@@ -70,7 +70,7 @@ 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-new,mode=max
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
|
||||
44
.github/workflows/preview.yml
vendored
@@ -1,44 +0,0 @@
|
||||
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
|
||||
- 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
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
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 }}
|
||||
156
.github/workflows/release.yml
vendored
@@ -4,52 +4,52 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- arm-test
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.16-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
run: yarn
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
# test:
|
||||
# name: Lint & Test Build
|
||||
# runs-on: ubuntu-20.04
|
||||
# container: node:12.18-alpine
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v2
|
||||
# - name: install dependencies
|
||||
# env:
|
||||
# HUSKY_SKIP_INSTALL: 1
|
||||
# run: yarn
|
||||
# - name: lint
|
||||
# run: yarn lint
|
||||
# - name: build
|
||||
# run: yarn build
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
needs: test
|
||||
# needs: test
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Node.js
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
node-version: 12
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Log in to Docker Hub
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Release
|
||||
@@ -58,61 +58,65 @@ 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
|
||||
# runs-on: ubuntu-20.04
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# architecture:
|
||||
# - amd64
|
||||
# - arm64
|
||||
# - armhf
|
||||
# steps:
|
||||
# - name: Checkout Code
|
||||
# uses: actions/checkout@v2
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
# - name: Switch to master branch
|
||||
# run: git checkout master
|
||||
# - name: Pull latest changes
|
||||
# run: git pull
|
||||
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: semantic-release
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
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: |
|
||||
git fetch --prune --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
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
|
||||
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 }}
|
||||
# - name: Prepare
|
||||
# id: prepare
|
||||
# run: |
|
||||
# git fetch --prune --tags
|
||||
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
# echo ::set-output name=RELEASE::stable
|
||||
# else
|
||||
# echo ::set-output name=RELEASE::edge
|
||||
# fi
|
||||
|
||||
# - name: Set Up QEMU
|
||||
# uses: docker/setup-qemu-action@v1
|
||||
# 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
|
||||
# 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
|
||||
@@ -121,6 +125,7 @@ jobs:
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
@@ -130,6 +135,7 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
26
.github/workflows/snap.yaml
vendored
@@ -2,8 +2,7 @@ name: Publish Snap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
@@ -12,27 +11,25 @@ jobs:
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.9.0
|
||||
uses: styfle/cancel-workflow-action@0.8.0
|
||||
with:
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
needs: jobs
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.16-alpine
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
- 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
|
||||
@@ -47,6 +44,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
@@ -56,31 +54,35 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
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
|
||||
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
|
||||
@@ -89,6 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
@@ -98,6 +101,7 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
@@ -32,7 +32,7 @@ yarn-error.log*
|
||||
.vercel
|
||||
|
||||
# database
|
||||
config/db/*.sqlite3*
|
||||
config/db/*.sqlite3
|
||||
config/settings.json
|
||||
|
||||
# logs
|
||||
|
||||
6
.vscode/settings.json
vendored
@@ -14,9 +14,5 @@
|
||||
"name": "Local SQLite",
|
||||
"database": "./config/db/db.sqlite3"
|
||||
}
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
]
|
||||
}
|
||||
|
||||
1287
CHANGELOG.md
27
Dockerfile
@@ -1,30 +1,28 @@
|
||||
FROM node:14.16-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
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}
|
||||
|
||||
RUN yarn build
|
||||
RUN yarn --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn build
|
||||
|
||||
# remove development dependencies
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
RUN rm -rf src server
|
||||
RUN rm -rf src && \
|
||||
rm -rf server
|
||||
|
||||
RUN touch config/DOCKER
|
||||
|
||||
@@ -33,12 +31,11 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
FROM node:14.16-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tzdata tini
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app ./
|
||||
COPY --from=BUILD_IMAGE /app /app
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
47
README.md
@@ -12,7 +12,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-50-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -22,14 +22,18 @@
|
||||
|
||||
- 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 scan, to keep track of the titles which are already available.
|
||||
- Plex library sync, to keep track of the titles which are already available.
|
||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
||||
- Granular permission system.
|
||||
- Support for various notification agents.
|
||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -37,6 +41,27 @@ 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 \
|
||||
--name overseerr \
|
||||
-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 and 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
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
@@ -123,22 +148,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -8,16 +8,8 @@
|
||||
|
||||
## Using Overseerr
|
||||
|
||||
- [Settings](using-overseerr/settings/README.md)
|
||||
- [Users](using-overseerr/users/README.md)
|
||||
- [Notifications](using-overseerr/notifications/README.md)
|
||||
- [Email](using-overseerr/notifications/email.md)
|
||||
- [Discord](using-overseerr/notifications/discord.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)
|
||||
- [Custom Webhooks](using-overseerr/notifications/webhooks.md)
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
# Reverse Proxy Examples
|
||||
|
||||
{% 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.
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk.
|
||||
{% endhint %}
|
||||
|
||||
## SWAG
|
||||
|
||||
A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-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).
|
||||
|
||||
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:
|
||||
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 {
|
||||
@@ -28,18 +22,20 @@ server {
|
||||
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)
|
||||
## Traefik \(v2\)
|
||||
|
||||
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
|
||||
|
||||
@@ -55,7 +51,7 @@ labels:
|
||||
- "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/).
|
||||
For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
## Nginx
|
||||
|
||||
@@ -88,6 +84,24 @@ server {
|
||||
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;
|
||||
@@ -100,15 +114,12 @@ 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.
|
||||
Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk!
|
||||
{% endhint %}
|
||||
|
||||
Add the following location block to your existing `nginx.conf` file.
|
||||
@@ -116,16 +127,13 @@ 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
|
||||
|
||||
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;
|
||||
@@ -144,7 +152,6 @@ location ^~ /overseerr {
|
||||
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
||||
}
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Installation
|
||||
|
||||
{% hint style="danger" %}
|
||||
Overseerr is currently in beta. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
|
||||
Overseerr is currently 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!
|
||||
{% endhint %}
|
||||
|
||||
{% hint style="info" %}
|
||||
@@ -32,7 +32,7 @@ docker run -d \
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
overseerr:
|
||||
@@ -68,7 +68,7 @@ docker run -d \
|
||||
|
||||
{% tab title="Manual Update" %}
|
||||
|
||||
```bash
|
||||
```text
|
||||
# Stop the Overseerr container
|
||||
docker stop overseerr
|
||||
|
||||
@@ -116,7 +116,7 @@ Docker on Windows works differently than it does on Linux; it uses a VM to run a
|
||||
## Linux
|
||||
|
||||
{% hint style="info" %}
|
||||
The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supported Linux install method aside from [Docker](#docker). Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
|
||||
The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd).
|
||||
{% endhint %}
|
||||
|
||||
**To install:**
|
||||
@@ -142,7 +142,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 %}
|
||||
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
# Asking for Support
|
||||
|
||||
Before seeking help, please make sure you have first tried these following:
|
||||
## Before Asking for Support
|
||||
|
||||
- **Updating** Overseerr to the latest version.
|
||||
- **Stopping and restarting** Overseerr.
|
||||
- **Restarting** your machine.
|
||||
- **Clearing** your browser cache.
|
||||
- **Analyzing** your logs, you just might find the solution yourself!
|
||||
- **Searching** the [documentation](../), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
|
||||
Before seeking help, please make sure you have tried these following first:
|
||||
|
||||
If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
|
||||
|
||||
Be sure to also include a link to your logs. (Please see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.)
|
||||
- **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 contacting support, please try to include as much information as possible. A vague statement like "it doesn't work" provides very little to go on, and makes it difficult for us to help you.
|
||||
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:
|
||||
|
||||
Try to answer the following questions:
|
||||
|
||||
- What were you trying to do, and how did you attempt it?
|
||||
- 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?
|
||||
- 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?
|
||||
- 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).
|
||||
- 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 current log file at `<your Overseeerr config directory>/logs/overseerr.log`.
|
||||
1. Locate the log file at `<Overseeerr-install-directory>/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).
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
{% hint style="info" %}
|
||||
If you can't find the solution to your problem here, please seek help on [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
|
||||
_Please do not post questions or support requests on the GitHub issue tracker!_
|
||||
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.
|
||||
{% 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?
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
### How can I access Overseerr outside of my home network?
|
||||
### How can I access Overseerr outside my home network?
|
||||
|
||||
The easiest but least secure method is to simply forward an external port (e.g., `5055`) on your router to the internal port used by Overseerr (default is TCP `5055`). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You would then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
|
||||
**A:** 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`.
|
||||
|
||||
A more advanced, user-friendly, and secure (if using SSL) method is to set up a web server and use a reverse proxy to access Overseerr. Please refer to our [reverse proxy examples](../extending-overseerr/reverse-proxy-examples.md) for more information.
|
||||
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).
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
|
||||
|
||||
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).
|
||||
**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
### Where can I find the changelog?
|
||||
|
||||
You can find the changelog in the **Settings → About** page in your Overseerr instance if you are using the `latest` tag. You can alternatively review the [release/version history on GitHub](https://github.com/sct/overseerr/releases).
|
||||
**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).
|
||||
|
||||
If you are using the `develop` tag, please refer to the [commit history for that branch on GitHub](https://github.com/sct/overseerr/commits/develop).
|
||||
### 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.)
|
||||
|
||||
### Some media is missing from Overseerr that I know is in Plex!
|
||||
|
||||
Overseerr currently supports the following agents:
|
||||
**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.
|
||||
|
||||
- New Plex Movie
|
||||
- Legacy Plex Movie
|
||||
- New Plex TV
|
||||
- Legacy Plex TV
|
||||
- TheTVDB
|
||||
- TMDb
|
||||
- [HAMA](https://github.com/ZeroQI/Hama.bundle)
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
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.
|
||||
First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
|
||||
|
||||
1. Verify that you are using one of the agents mentioned above.
|
||||
2. Refresh the metadata for just that item.
|
||||
@@ -66,56 +58,40 @@ 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"`
|
||||
|
||||
### Where can I find the log files?
|
||||
### TV series requests are failing after I updated Overseerr!
|
||||
|
||||
Please see [these instructions on how to locate and share your logs](./asking-for-support#how-can-i-share-my-logs).
|
||||
**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**.
|
||||
|
||||
## Users
|
||||
### Where can I find the logs?
|
||||
|
||||
### Why can't I see all of my Plex users?
|
||||
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
|
||||
Please see the [documentation for importing users from Plex](../using-overseerr/users#importing-users-from-plex).
|
||||
## 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.
|
||||
|
||||
### Can I create local users in Overseerr?
|
||||
|
||||
Yes! Please see the [documentation for creating local users](../using-overseerr/users#creating-local-users).
|
||||
**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
|
||||
|
||||
### Is is possible to set user roles in Overseerr?
|
||||
|
||||
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)!
|
||||
**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
|
||||
|
||||
## Requests
|
||||
|
||||
### I 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!
|
||||
|
||||
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.
|
||||
**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
|
||||
|
||||
### Help! My request still shows "requested" even though it is in Plex!
|
||||
|
||||
See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
|
||||
|
||||
### Series requests keep failing!
|
||||
|
||||
If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr.
|
||||
|
||||
Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
|
||||
|
||||
Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
|
||||
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
|
||||
|
||||
## Notifications
|
||||
|
||||
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
|
||||
|
||||
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).
|
||||
**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).
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
# Notifications
|
||||
|
||||
## Supported Notification Agents
|
||||
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.
|
||||
|
||||
Overseerr currently supports the following notification agents:
|
||||
## Currently Supported Notification Agents
|
||||
|
||||
- [Email](./email.md)
|
||||
- [Discord](./discord.md)
|
||||
- [Pushbullet](./pushbullet.md)
|
||||
- [Pushover](./pushover.md)
|
||||
- [Slack](./slack.md)
|
||||
- [Telegram](./telegram.md)
|
||||
- Discord
|
||||
- Email
|
||||
- Pushbullet
|
||||
- Pushover
|
||||
- Slack
|
||||
- Telegram
|
||||
- [Webhooks](./webhooks.md)
|
||||
|
||||
## Setting Up Notifications
|
||||
|
||||
Configuring your notifications is quite simple. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
|
||||
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
|
||||
|
||||
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
|
||||
|
||||
Note that some notifications are intended for the user who submitted the relevant request, while others are for administrators. For details, please see the documentation for the specific agent you would like to use.
|
||||
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 %}
|
||||
|
||||
## Requesting New Notification Agents
|
||||
|
||||
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!
|
||||
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!
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Discord
|
||||
|
||||
{% hint style="info" %}
|
||||
The following notification types will mention _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
- Media Failed
|
||||
|
||||
On the other hand, the notification types below will only mention the user who submitted the request:
|
||||
|
||||
- Media Approved (does not include automatic approvals)
|
||||
- Media Declined
|
||||
- Media Available
|
||||
|
||||
In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured and **Enable Mentions** checked in their Discord notification user settings.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
{% hint style="info" %}
|
||||
To configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
||||
{% endhint %}
|
||||
|
||||
### 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.
|
||||
|
||||
### Webhook URL
|
||||
|
||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Email
|
||||
|
||||
{% hint style="info" %}
|
||||
The following email notification types are sent to _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users:
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
- Media Failed
|
||||
|
||||
On the other hand, the email notification types below are only sent to the user who submitted the request:
|
||||
|
||||
- Media Approved (does not include automatic approvals)
|
||||
- Media Declined
|
||||
- Media Available
|
||||
|
||||
In order for users to receive email notifications, they must have **Enable Notifications** checked in their email notification user settings.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### 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.
|
||||
|
||||
### Sender Name (optional)
|
||||
|
||||
Configure a friendly name for the email sender.
|
||||
|
||||
### SMTP Host
|
||||
|
||||
Set this to the hostname or IP address of your SMTP host/server.
|
||||
|
||||
### SMTP Port
|
||||
|
||||
Set this to a supported port number for your SMTP host. `465` and `587` are commonly used.
|
||||
|
||||
### Enable SSL (optional)
|
||||
|
||||
This setting should only be enabled for ports that use [implicit SSL/TLS](https://tools.ietf.org/html/rfc8314) (e.g., port `465` in most cases).
|
||||
|
||||
For servers that support [opportunistic TLS/STARTTLS](https://en.wikipedia.org/wiki/Opportunistic_TLS) (typically via port `587`), this setting should **not** be enabled.
|
||||
|
||||
### 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-----`.
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,7 +0,0 @@
|
||||
# Slack
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
|
||||
Simply [create a webhook](https://catflixserver.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks) and enter the URL in this field.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Telegram
|
||||
|
||||
{% hint style="info" %}
|
||||
All notification types will be sent to the chat ID configured in your Overseerr application settings.
|
||||
|
||||
If a user has configured a chat ID and has **Enable Notifications** checked in their Telegram notification user settings as well, they will be sent the following notification types for requests which they submit:
|
||||
|
||||
- Media Approved (does not include automatic approvals)
|
||||
- Media Declined
|
||||
- Media Available
|
||||
|
||||
{% 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, users must have your bot added to a conversation in order to receive notifications.
|
||||
{% endhint %}
|
||||
|
||||
### Bot Username (optional)
|
||||
|
||||
If this value is configured, users will be able to start a chat with your bot and configure their own personal notifications.
|
||||
|
||||
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)
|
||||
|
||||
Instagram allows you to enable silent notifications. Those will present a pop-up to the user, but will not make any sound. That's a per user configuration.
|
||||
@@ -1,22 +1,20 @@
|
||||
# Webhook
|
||||
# Webhooks
|
||||
|
||||
The webhook notification agent allows you to send a custom JSON payload to any endpoint.
|
||||
Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
The following configuration options are available:
|
||||
|
||||
### Webhook URL (required)
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
### Authorization Header (optional)
|
||||
### Authorization Header
|
||||
|
||||
{% hint style="info" %}
|
||||
This is typically not needed. Please refer to your webhook provider's documentation for details.
|
||||
{% endhint %}
|
||||
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
|
||||
|
||||
This value will be sent as an `Authorization` HTTP header.
|
||||
|
||||
### JSON Payload
|
||||
### JSON Payload (required)
|
||||
|
||||
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.
|
||||
|
||||
@@ -31,41 +29,17 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
|
||||
|
||||
### User
|
||||
|
||||
These variables are for the target recipient of the notification.
|
||||
These variables are usually the target user of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{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).
|
||||
- `{{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_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
|
||||
|
||||
{% 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:
|
||||
### Media
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
- Media Failed
|
||||
|
||||
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
||||
|
||||
- Media Approved
|
||||
- Media Declined
|
||||
- Media Available
|
||||
|
||||
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
|
||||
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
- `{{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.
|
||||
These variables are only included in media related notifications, such as requests.
|
||||
|
||||
- `{{media_type}}` Media type. Either `movie` or `tv`.
|
||||
- `{{media_tmdbid}}` Media's TMDb ID.
|
||||
@@ -74,13 +48,10 @@ These `{{media}}` special variables are only included in media-related notificat
|
||||
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
|
||||
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
|
||||
|
||||
#### Request
|
||||
### Special
|
||||
|
||||
The `{{request}}` special variables are only included in request-related notifications.
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
- `{{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 one is set).
|
||||
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if one is set).
|
||||
- `{{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.
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# 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. If configured, [notifications](../notifications/README.md) will include links!
|
||||
|
||||
### Enable Proxy Support
|
||||
|
||||
If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy-examples.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html).
|
||||
|
||||
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**. 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.
|
||||
|
||||
### 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 User 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.
|
||||
|
||||
### 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 User Permissions
|
||||
|
||||
Select the permissions you would like assigned to new users to have by default upon account creation.
|
||||
|
||||
It is important to configure this, as any user with access to your Plex server will be able to sign in to Overseerr, and they will be granted the permissions you select here upon first sign-in.
|
||||
|
||||
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 %}
|
||||
|
||||
#### Server Name
|
||||
|
||||
This value is automatically retrieved from Plex, and cannot be edited manually in Overseerr.
|
||||
|
||||
#### Hostname or IP Address
|
||||
|
||||
If you have Overseerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`).
|
||||
|
||||
#### 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.
|
||||
|
||||
#### SSL
|
||||
|
||||
Tick this box to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported.
|
||||
|
||||
### Plex Libraries
|
||||
|
||||
In this section, simply select the libraries you would like Overseerr to scan. Overseerr will periodically check the selected libraries for available content to update the media status that is displayed to users.
|
||||
|
||||
If you do not see your Plex libraries listed, verify your Plex settings and then click the "Scan Plex Libraries" button.
|
||||
|
||||
### 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
|
||||
|
||||
#### 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.
|
||||
|
||||
#### SSL
|
||||
|
||||
Tick this box 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**.
|
||||
|
||||
#### Base URL
|
||||
|
||||
If you have configured a base URL for Radarr/Sonarr, you **must** enter it here in order for Overseerr to connect to those services!
|
||||
|
||||
You can verify whether or not you have a base URL configured in Radarr/Sonarr in **Settings → General → Host**. (Note that a restart of your Radarr/Sonarr servers is required if you modify this setting!)
|
||||
|
||||
#### 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
|
||||
|
||||
If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages.
|
||||
|
||||
#### Enable Scan
|
||||
|
||||
Enable this setting if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available.
|
||||
|
||||
#### Enable Automatic Search
|
||||
|
||||
Enable 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.
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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-user-permissions) defined in **Settings → Users**.
|
||||
|
||||
### Importing Users from Plex
|
||||
|
||||
Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
|
||||
|
||||
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-user-permissions) upon their first login.
|
||||
|
||||
### 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 [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).
|
||||
|
||||
#### 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.
|
||||
@@ -2,16 +2,12 @@ module.exports = {
|
||||
env: {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
},
|
||||
images: {
|
||||
domains: ['image.tmdb.org'],
|
||||
},
|
||||
future: {
|
||||
webpack5: true,
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
issuer: /\.(js|ts)x?$/,
|
||||
issuer: {
|
||||
test: /\.(js|ts)x?$/,
|
||||
},
|
||||
use: ['@svgr/webpack'],
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const devConfig = {
|
||||
synchronize: true,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
enableWAL: true,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
@@ -23,7 +22,6 @@ const prodConfig = {
|
||||
: 'config/db/db.sqlite3',
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
enableWAL: true,
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: false,
|
||||
|
||||
@@ -92,21 +92,24 @@ components:
|
||||
UserSettings:
|
||||
type: object
|
||||
properties:
|
||||
enableNotifications:
|
||||
type: boolean
|
||||
default: true
|
||||
discordId:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
language:
|
||||
telegramChatId:
|
||||
type: string
|
||||
telegramSendSilently:
|
||||
type: boolean
|
||||
required:
|
||||
- enableNotifications
|
||||
MainSettings:
|
||||
type: object
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
example: 'anapikey'
|
||||
readOnly: true
|
||||
appLanguage:
|
||||
type: string
|
||||
example: en
|
||||
applicationTitle:
|
||||
type: string
|
||||
example: Overseerr
|
||||
@@ -122,15 +125,9 @@ components:
|
||||
hideAvailable:
|
||||
type: boolean
|
||||
example: false
|
||||
partialRequestsEnabled:
|
||||
type: boolean
|
||||
example: false
|
||||
localLogin:
|
||||
type: boolean
|
||||
example: true
|
||||
newPlexLogin:
|
||||
type: boolean
|
||||
example: true
|
||||
defaultPermissions:
|
||||
type: number
|
||||
example: 32
|
||||
@@ -200,6 +197,9 @@ components:
|
||||
message:
|
||||
type: string
|
||||
example: 'OK'
|
||||
host:
|
||||
type: string
|
||||
example: '127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct'
|
||||
required:
|
||||
- protocol
|
||||
- address
|
||||
@@ -438,15 +438,6 @@ components:
|
||||
- is4k
|
||||
- enableSeasonFolders
|
||||
- isDefault
|
||||
ServarrTag:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
label:
|
||||
type: string
|
||||
example: A Label
|
||||
PublicSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -913,8 +904,6 @@ components:
|
||||
$ref: '#/components/schemas/Season'
|
||||
status:
|
||||
type: string
|
||||
tagline:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
voteAverage:
|
||||
@@ -1133,15 +1122,6 @@ components:
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
WebPushSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
WebhookSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1156,8 +1136,6 @@ components:
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
authHeader:
|
||||
type: string
|
||||
jsonPayload:
|
||||
type: string
|
||||
TelegramSettings:
|
||||
@@ -1212,22 +1190,12 @@ components:
|
||||
type: string
|
||||
priority:
|
||||
type: number
|
||||
LunaSeaSettings:
|
||||
NotificationSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
profileName:
|
||||
type: string
|
||||
example: true
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1255,12 +1223,6 @@ components:
|
||||
secure:
|
||||
type: boolean
|
||||
example: false
|
||||
ignoreTls:
|
||||
type: boolean
|
||||
example: false
|
||||
requireTls:
|
||||
type: boolean
|
||||
example: false
|
||||
authUser:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -1586,30 +1548,20 @@ components:
|
||||
UserSettingsNotifications:
|
||||
type: object
|
||||
properties:
|
||||
notificationAgents:
|
||||
type: number
|
||||
example: 0
|
||||
emailEnabled:
|
||||
type: boolean
|
||||
pgpKey:
|
||||
type: string
|
||||
nullable: true
|
||||
discordEnabled:
|
||||
enableNotifications:
|
||||
type: boolean
|
||||
default: true
|
||||
discordId:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramEnabled:
|
||||
type: boolean
|
||||
telegramBotUsername:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramChatId:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramSendSilently:
|
||||
type: boolean
|
||||
nullable: true
|
||||
required:
|
||||
- enableNotifications
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -2295,54 +2247,37 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed cache'
|
||||
/settings/logs:
|
||||
/settings/notifications:
|
||||
get:
|
||||
summary: Returns logs
|
||||
description: Returns list of all log items and details
|
||||
summary: Return notification settings
|
||||
description: Returns current notification settings in a JSON object.
|
||||
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: Server log returned
|
||||
description: Returned settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
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
|
||||
$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'
|
||||
/settings/notifications/email:
|
||||
get:
|
||||
summary: Get email notification settings
|
||||
@@ -2435,22 +2370,22 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/lunasea:
|
||||
/settings/notifications/telegram:
|
||||
get:
|
||||
summary: Get LunaSea notification settings
|
||||
description: Returns current LunaSea notification settings in a JSON object.
|
||||
summary: Get Telegram notification settings
|
||||
description: Returns current Telegram notification settings in a JSON object.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned LunaSea settings
|
||||
description: Returned Telegram settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
post:
|
||||
summary: Update LunaSea notification settings
|
||||
description: Updates LunaSea notification settings with the provided values.
|
||||
summary: Update Telegram notification settings
|
||||
description: Update Telegram notification settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -2458,18 +2393,18 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
/settings/notifications/lunasea/test:
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
/settings/notifications/telegram/test:
|
||||
post:
|
||||
summary: Test LunaSea settings
|
||||
description: Sends a test notification to the LunaSea agent.
|
||||
summary: Test Telegram settings
|
||||
description: Sends a test notification to the Telegram agent.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -2477,7 +2412,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
@@ -2514,8 +2449,8 @@ paths:
|
||||
$ref: '#/components/schemas/PushbulletSettings'
|
||||
/settings/notifications/pushbullet/test:
|
||||
post:
|
||||
summary: Test Pushbullet settings
|
||||
description: Sends a test notification to the Pushbullet agent.
|
||||
summary: Test Pushover settings
|
||||
description: Sends a test notification to the Pushover agent.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -2523,7 +2458,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PushbulletSettings'
|
||||
$ref: '#/components/schemas/PushoverSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
@@ -2619,98 +2554,6 @@ 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
|
||||
@@ -2753,7 +2596,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WebhookSettings'
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
@@ -3033,32 +2876,6 @@ 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
|
||||
@@ -3163,63 +2980,6 @@ 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
|
||||
@@ -4018,77 +3778,11 @@ 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:
|
||||
@@ -4116,12 +3810,6 @@ paths:
|
||||
type: string
|
||||
enum: [added, modified]
|
||||
default: added
|
||||
- in: query
|
||||
name: requestedBy
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Requests returned
|
||||
@@ -4731,7 +4419,7 @@ paths:
|
||||
type: number
|
||||
/media:
|
||||
get:
|
||||
summary: Get media
|
||||
summary: Return media
|
||||
description: Returns all media (can be filtered and limited) in a JSON object.
|
||||
tags:
|
||||
- media
|
||||
@@ -5049,12 +4737,6 @@ paths:
|
||||
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
|
||||
@@ -5077,12 +4759,6 @@ paths:
|
||||
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
|
||||
|
||||
107
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.24.0",
|
||||
"version": "1.0.0-arm-test.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -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 true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
|
||||
@@ -17,11 +17,9 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.1.1",
|
||||
"@heroicons/react": "^1.0.1",
|
||||
"@headlessui/react": "^0.3.1",
|
||||
"@supercharge/request-ip": "^1.1.2",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@tanem/react-nprogress": "^3.0.64",
|
||||
"ace-builds": "^1.4.12",
|
||||
"axios": "^0.21.1",
|
||||
"bcrypt": "^5.0.1",
|
||||
@@ -29,35 +27,32 @@
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"country-flag-icons": "^1.2.10",
|
||||
"country-flag-icons": "^1.2.9",
|
||||
"csurf": "^1.11.0",
|
||||
"email-templates": "^8.0.4",
|
||||
"email-templates": "^8.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.12.9",
|
||||
"express-rate-limit": "^5.2.6",
|
||||
"express-openapi-validator": "^4.12.4",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.6",
|
||||
"gravatar-url": "3.1.0",
|
||||
"gravatar-url": "^3.1.0",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "10.1.3",
|
||||
"next": "10.0.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-schedule": "^2.0.0",
|
||||
"nodemailer": "^6.6.0",
|
||||
"openpgp": "^5.0.0-2",
|
||||
"nodemailer": "^6.5.0",
|
||||
"nookies": "^2.5.2",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.2",
|
||||
"react": "17.0.2",
|
||||
"react": "17.0.1",
|
||||
"react-ace": "^9.3.0",
|
||||
"react-animate-height": "^2.0.23",
|
||||
"react-dom": "17.0.2",
|
||||
"react-intersection-observer": "^8.31.1",
|
||||
"react-intl": "5.17.4",
|
||||
"react-markdown": "^6.0.1",
|
||||
"react-select": "^4.3.0",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.13.2",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.4",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-truncate-markup": "^5.1.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
@@ -65,78 +60,76 @@
|
||||
"secure-random-password": "^0.2.2",
|
||||
"sqlite3": "^5.0.2",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"swr": "^0.5.6",
|
||||
"typeorm": "^0.2.32",
|
||||
"swr": "^0.5.1",
|
||||
"typeorm": "^0.2.31",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.4.4",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.3",
|
||||
"winston-daily-rotate-file": "^4.5.1",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.13.16",
|
||||
"@commitlint/cli": "^12.1.1",
|
||||
"@commitlint/config-conventional": "^12.1.1",
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@commitlint/cli": "^12.0.1",
|
||||
"@commitlint/config-conventional": "^12.0.1",
|
||||
"@semantic-release/changelog": "^5.0.1",
|
||||
"@semantic-release/commit-analyzer": "^8.0.1",
|
||||
"@semantic-release/exec": "^5.0.0",
|
||||
"@semantic-release/git": "^9.0.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.2.0",
|
||||
"@tailwindcss/forms": "^0.3.2",
|
||||
"@tailwindcss/forms": "^0.2.1",
|
||||
"@tailwindcss/typography": "^0.4.0",
|
||||
"@types/bcrypt": "^3.0.1",
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/country-flag-icons": "^1.2.0",
|
||||
"@types/csurf": "^1.11.1",
|
||||
"@types/email-templates": "^8.0.3",
|
||||
"@types/csurf": "^1.11.0",
|
||||
"@types/email-templates": "^8.0.2",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-rate-limit": "^5.1.1",
|
||||
"@types/express-session": "^1.17.3",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^15.0.1",
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.1",
|
||||
"@types/react": "^17.0.4",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"@types/react-select": "^4.0.15",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@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/web-push": "^3.3.0",
|
||||
"@types/xml2js": "^0.4.8",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
"@typescript-eslint/parser": "^4.22.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-formatjs": "^2.14.10",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-formatjs": "^2.12.7",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "4.3.8",
|
||||
"lint-staged": "^10.5.4",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^8.2.13",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.4.2",
|
||||
"semantic-release-docker-buildx": "^1.0.1",
|
||||
"tailwindcss": "^2.1.2",
|
||||
"semantic-release": "^17.4.1",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"semantic-release-docker-buildx": "^0.0.0-development",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.4"
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^5.1.0"
|
||||
@@ -156,10 +149,12 @@
|
||||
"lint-staged": {
|
||||
"**/*.{ts,tsx,js}": [
|
||||
"prettier --write",
|
||||
"eslint"
|
||||
"eslint",
|
||||
"git add"
|
||||
],
|
||||
"**/*.{json,md}": [
|
||||
"prettier --write"
|
||||
"prettier --write",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
@@ -201,7 +196,11 @@
|
||||
]
|
||||
],
|
||||
"branches": [
|
||||
"master"
|
||||
"master",
|
||||
{
|
||||
"name": "arm-test",
|
||||
"prerelease": true
|
||||
}
|
||||
],
|
||||
"npmPublish": false,
|
||||
"publish": [
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: ['tailwindcss', 'postcss-preset-env'],
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
1
public/images/radarr_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
public/images/sonarr_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/> <path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/> <g clip-rule="evenodd"> <path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/> <path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/> <path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/> </g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,69 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<title>You are offline</title>
|
||||
|
||||
<!-- Inline the page's stylesheet. -->
|
||||
<style>
|
||||
body {
|
||||
font-family: helvetica, arial, sans-serif;
|
||||
margin: 2em;
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #6366F1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>You are offline</h1>
|
||||
|
||||
<button type="button">⤾ Reload</button>
|
||||
|
||||
<!-- Inline the page's JavaScript file. -->
|
||||
<script>
|
||||
// Manual reload feature.
|
||||
document.querySelector("button").addEventListener("click", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Listen to changes in the network state, reload when online.
|
||||
// This handles the case when the device is completely offline.
|
||||
window.addEventListener('online', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Check if the server is responding and reload the page if it is.
|
||||
// This handles the case when the device is online, but the server
|
||||
// is offline or misbehaving.
|
||||
async function checkNetworkAndReload() {
|
||||
try {
|
||||
const response = await fetch('.');
|
||||
// Verify we get a valid response from the server
|
||||
if (response.status >= 200 && response.status < 500) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Unable to connect to the server, ignore.
|
||||
}
|
||||
window.setTimeout(checkNetworkAndReload, 2500);
|
||||
}
|
||||
|
||||
checkNetworkAndReload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 1.6 MiB |
@@ -4,77 +4,17 @@
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./android-chrome-192x192.png",
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-192x192_maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-512x512.png",
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./android-chrome-512x512_maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"theme_color": "#2d3748",
|
||||
"background_color": "#2d3748",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
136
public/sw.js
@@ -1,136 +0,0 @@
|
||||
// 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.
|
||||
self.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.
|
||||
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,
|
||||
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') {
|
||||
self.clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'approve') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
self.clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'decline') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
self.clients.openWindow(notificationData.actionUrl);
|
||||
} else if (notificationData.actionUrl) {
|
||||
self.clients.openWindow(notificationData.actionUrl);
|
||||
}
|
||||
}, false);
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,133 +0,0 @@
|
||||
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<GitHubRelease[]> {
|
||||
try {
|
||||
const data = await this.get<GitHubRelease[]>(
|
||||
'/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<GithubCommit[]> {
|
||||
try {
|
||||
const data = await this.get<GithubCommit[]>(
|
||||
'/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;
|
||||
@@ -118,7 +118,7 @@ class PlexAPI {
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Overseerr',
|
||||
deviceName: 'Overseerr',
|
||||
deviceName: settings.main.applicationTitle,
|
||||
platform: 'Overseerr',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logger from '../../logger';
|
||||
import ServarrBase from './base';
|
||||
import cacheManager from '../lib/cache';
|
||||
import { RadarrSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RadarrMovieOptions {
|
||||
title: string;
|
||||
qualityProfileId: number;
|
||||
minimumAvailability: string;
|
||||
tags: number[];
|
||||
profileId: number;
|
||||
year: number;
|
||||
rootFolderPath: string;
|
||||
@@ -31,9 +32,65 @@ export interface RadarrMovie {
|
||||
hasFile: boolean;
|
||||
}
|
||||
|
||||
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
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}`;
|
||||
}
|
||||
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
|
||||
super(
|
||||
url,
|
||||
{
|
||||
apikey: apiKey,
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('radarr').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||
@@ -72,8 +129,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving movie by TMDb ID', {
|
||||
label: 'Radarr API',
|
||||
errorMessage: e.message,
|
||||
tmdbId: id,
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
@@ -90,13 +146,12 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
'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<RadarrMovie>(`/movie`, {
|
||||
...movie,
|
||||
@@ -107,7 +162,6 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
minimumAvailability: options.minimumAvailability,
|
||||
tmdbId: options.tmdbId,
|
||||
year: options.year,
|
||||
tags: options.tags,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
monitored: options.monitored,
|
||||
addOptions: {
|
||||
@@ -117,25 +171,16 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
if (response.data.monitored) {
|
||||
logger.info(
|
||||
'Found existing title in Radarr and set it to monitored.',
|
||||
{
|
||||
label: 'Radarr',
|
||||
movieId: response.data.id,
|
||||
movieTitle: response.data.title,
|
||||
}
|
||||
'Found existing title in Radarr and set it to monitored. Returning success',
|
||||
{ label: 'Radarr' }
|
||||
);
|
||||
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,
|
||||
});
|
||||
@@ -161,7 +206,6 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
year: options.year,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
monitored: options.monitored,
|
||||
tags: options.tags,
|
||||
addOptions: {
|
||||
searchForMovie: options.searchNow,
|
||||
},
|
||||
@@ -195,25 +239,43 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
}
|
||||
};
|
||||
|
||||
public async searchMovie(movieId: number): Promise<void> {
|
||||
logger.info('Executing movie search command', {
|
||||
label: 'Radarr API',
|
||||
movieId,
|
||||
});
|
||||
|
||||
public getProfiles = async (): Promise<RadarrProfile[]> => {
|
||||
try {
|
||||
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,
|
||||
}
|
||||
const data = await this.getRolling<RadarrProfile[]>(
|
||||
`/qualityProfile`,
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
|
||||
try {
|
||||
const data = await this.getRolling<RadarrRootFolder[]>(
|
||||
`/rootfolder`,
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getQueue = async (): Promise<QueueItem[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse>(`/queue`);
|
||||
|
||||
return response.data.records;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default RadarrAPI;
|
||||
@@ -1,169 +0,0 @@
|
||||
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<QueueItemAppendT> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortKey: string;
|
||||
sortDirection: string;
|
||||
totalRecords: number;
|
||||
records: (QueueItem & QueueItemAppendT)[];
|
||||
}
|
||||
|
||||
class ServarrBase<QueueItemAppendT> 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<QualityProfile[]> => {
|
||||
try {
|
||||
const data = await this.getRolling<QualityProfile[]>(
|
||||
`/qualityProfile`,
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve profiles: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public getRootFolders = async (): Promise<RootFolder[]> => {
|
||||
try {
|
||||
const data = await this.getRolling<RootFolder[]>(
|
||||
`/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<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`
|
||||
);
|
||||
|
||||
return response.data.records;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public getTags = async (): Promise<Tag[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<Tag[]>(`/tag`);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
||||
try {
|
||||
const response = await this.axios.post<Tag>(`/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<string, unknown>
|
||||
): Promise<void> {
|
||||
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;
|
||||
@@ -1,5 +1,7 @@
|
||||
import logger from '../../logger';
|
||||
import ServarrBase from './base';
|
||||
import cacheManager from '../lib/cache';
|
||||
import { SonarrSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
@@ -47,7 +49,7 @@ export interface SonarrSeries {
|
||||
titleSlug: string;
|
||||
certification: string;
|
||||
genres: string[];
|
||||
tags: number[];
|
||||
tags: string[];
|
||||
added: string;
|
||||
ratings: {
|
||||
votes: number;
|
||||
@@ -63,6 +65,49 @@ 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;
|
||||
@@ -71,7 +116,6 @@ interface AddSeriesOptions {
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
tags?: number[];
|
||||
seriesType: SonarrSeries['seriesType'];
|
||||
monitored?: boolean;
|
||||
searchNow?: boolean;
|
||||
@@ -82,9 +126,23 @@ export interface LanguageProfile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
class SonarrAPI extends ExternalAPI {
|
||||
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
|
||||
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
|
||||
}
|
||||
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||
super(
|
||||
url,
|
||||
{
|
||||
apikey: apiKey,
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('sonarr').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getSeries(): Promise<SonarrSeries[]> {
|
||||
@@ -93,7 +151,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
|
||||
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +171,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by series title', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
title,
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('No series found');
|
||||
}
|
||||
@@ -136,8 +193,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by tvdb ID', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
tvdbId: id,
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('Series not found');
|
||||
}
|
||||
@@ -149,7 +205,6 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
|
||||
// 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);
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
@@ -158,21 +213,16 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
);
|
||||
|
||||
if (newSeriesResponse.data.id) {
|
||||
logger.info('Updated existing series in Sonarr.', {
|
||||
logger.info('Sonarr accepted request. Updated existing series', {
|
||||
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',
|
||||
@@ -180,6 +230,8 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
});
|
||||
throw new Error('Failed to update series in Sonarr');
|
||||
}
|
||||
|
||||
return newSeriesResponse.data;
|
||||
}
|
||||
|
||||
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||
@@ -197,7 +249,6 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
monitored: false,
|
||||
}))
|
||||
),
|
||||
tags: options.tags,
|
||||
seasonFolder: options.seasonFolder,
|
||||
monitored: options.monitored,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
@@ -228,13 +279,53 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
error: e,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
throw new Error('Failed to add series');
|
||||
}
|
||||
}
|
||||
|
||||
public async getProfiles(): Promise<SonarrProfile[]> {
|
||||
try {
|
||||
const data = await this.getRolling<SonarrProfile[]>(
|
||||
'/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<SonarrRootFolder[]> {
|
||||
try {
|
||||
const data = await this.getRolling<SonarrRootFolder[]>(
|
||||
'/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<LanguageProfile[]> {
|
||||
try {
|
||||
const data = await this.getRolling<LanguageProfile[]>(
|
||||
@@ -249,7 +340,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
'Something went wrong while retrieving Sonarr language profiles.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
message: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -258,22 +349,29 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
}
|
||||
|
||||
public async searchSeries(seriesId: number): Promise<void> {
|
||||
logger.info('Executing series search command.', {
|
||||
logger.info('Executing series search command', {
|
||||
label: 'Sonarr API',
|
||||
seriesId,
|
||||
});
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
commandName: string,
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
await this.axios.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong while executing Sonarr series search.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
seriesId,
|
||||
}
|
||||
);
|
||||
logger.error('Something went wrong attempting to run a Sonarr command.', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
throw new Error('Failed to run Sonarr command.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +399,16 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
|
||||
public getQueue = async (): Promise<QueueItem[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse>(`/queue`);
|
||||
|
||||
return response.data.records;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default SonarrAPI;
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
TmdbNetwork,
|
||||
TmdbPersonCombinedCredits,
|
||||
TmdbPersonDetail,
|
||||
TmdbProductionCompany,
|
||||
TmdbRegion,
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbSearchMultiResponse,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbProductionCompany,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -614,7 +614,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
return tvshow;
|
||||
}
|
||||
|
||||
throw new Error(`No show returned from API for ID ${tvdbId}`);
|
||||
throw new Error(
|
||||
`[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
@@ -715,34 +717,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
if (
|
||||
!language.startsWith('en') &&
|
||||
data.genres.some((genre) => !genre.name)
|
||||
) {
|
||||
const englishData = await this.get<TmdbGenresResult>(
|
||||
'/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'
|
||||
);
|
||||
const movieGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return movieGenres;
|
||||
} catch (e) {
|
||||
@@ -766,34 +741,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
if (
|
||||
!language.startsWith('en') &&
|
||||
data.genres.some((genre) => !genre.name)
|
||||
) {
|
||||
const englishData = await this.get<TmdbGenresResult>(
|
||||
'/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'
|
||||
);
|
||||
const tvGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return tvGenres;
|
||||
} catch (e) {
|
||||
|
||||
@@ -254,7 +254,6 @@ export interface TmdbTvDetails {
|
||||
}[];
|
||||
seasons: TmdbTvSeasonResult[];
|
||||
status: string;
|
||||
tagline?: string;
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
@@ -306,7 +305,6 @@ export interface TmdbKeyword {
|
||||
export interface TmdbPersonDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
birthday: string;
|
||||
deathday: string;
|
||||
known_for_department: string;
|
||||
also_known_as?: string[];
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
AfterLoad,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
In,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
Index,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterLoad,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
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';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -168,7 +168,10 @@ class Media {
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
|
||||
: RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
|
||||
: RadarrAPI.buildRadarrUrl(
|
||||
server,
|
||||
`/movie/${this.externalServiceSlug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +184,7 @@ class Media {
|
||||
if (server) {
|
||||
this.serviceUrl4k = server.externalUrl
|
||||
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
|
||||
: RadarrAPI.buildUrl(
|
||||
: RadarrAPI.buildRadarrUrl(
|
||||
server,
|
||||
`/movie/${this.externalServiceSlug4k}`
|
||||
);
|
||||
@@ -199,7 +202,10 @@ class Media {
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/series/${this.externalServiceSlug}`
|
||||
: SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`);
|
||||
: SonarrAPI.buildSonarrUrl(
|
||||
server,
|
||||
`/series/${this.externalServiceSlug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +218,7 @@ class Media {
|
||||
if (server) {
|
||||
this.serviceUrl4k = server.externalUrl
|
||||
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
|
||||
: SonarrAPI.buildUrl(
|
||||
: SonarrAPI.buildSonarrUrl(
|
||||
server,
|
||||
`/series/${this.externalServiceSlug4k}`
|
||||
);
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import {
|
||||
AfterInsert,
|
||||
AfterRemove,
|
||||
AfterUpdate,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
ManyToOne,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
AfterUpdate,
|
||||
AfterInsert,
|
||||
getRepository,
|
||||
OneToMany,
|
||||
AfterRemove,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
|
||||
import { User } from './User';
|
||||
import Media from './Media';
|
||||
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import Media from './Media';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import { User } from './User';
|
||||
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
export class MediaRequest {
|
||||
@@ -62,9 +60,6 @@ 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,
|
||||
@@ -86,37 +81,6 @@ 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<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -142,11 +106,10 @@ export class MediaRequest {
|
||||
if (this.type === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
});
|
||||
@@ -155,11 +118,10 @@ export class MediaRequest {
|
||||
if (this.type === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
@@ -214,12 +176,10 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
}
|
||||
@@ -233,12 +193,10 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
@@ -282,7 +240,11 @@ export class MediaRequest {
|
||||
media[this.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||
if (this.is4k) {
|
||||
media.status4k = MediaStatus.PROCESSING;
|
||||
} else {
|
||||
media.status = MediaStatus.PROCESSING;
|
||||
}
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
@@ -290,7 +252,11 @@ export class MediaRequest {
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
if (this.is4k) {
|
||||
media.status4k = MediaStatus.UNKNOWN;
|
||||
} else {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
@@ -306,9 +272,9 @@ export class MediaRequest {
|
||||
media.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
).length === 0 &&
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||
media.status === MediaStatus.PENDING
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
@@ -359,7 +325,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 server configured',
|
||||
'Skipped radarr request as there is no radarr configured',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
@@ -387,9 +353,7 @@ export class MediaRequest {
|
||||
logger.info(
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Radarr server configured. Did you set any of your ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Radarr servers as default?`,
|
||||
}radarr configured. Did you set any of your Radarr servers as default?`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
@@ -397,7 +361,6 @@ export class MediaRequest {
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -420,18 +383,10 @@ 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.buildUrl(radarrSettings, '/api/v3'),
|
||||
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
@@ -461,7 +416,6 @@ export class MediaRequest {
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
tags,
|
||||
searchNow: !radarrSettings.preventSearch,
|
||||
})
|
||||
.then(async (radarrMovie) => {
|
||||
@@ -482,7 +436,7 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added movie request failed to add to Radarr, marking as unknown',
|
||||
@@ -490,12 +444,15 @@ 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}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: movie.overview,
|
||||
subject: movie.title,
|
||||
message: 'Movie failed to add to Radarr',
|
||||
notifyUser: admin,
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: this,
|
||||
@@ -503,7 +460,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,
|
||||
@@ -523,7 +480,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 server configured',
|
||||
'Skipped sonarr request as there is no sonarr configured',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
@@ -551,9 +508,7 @@ export class MediaRequest {
|
||||
logger.info(
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Sonarr server configured. Did you set any of your ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Sonarr servers as default?`,
|
||||
}sonarr configured. Did you set any of your Sonarr servers as default?`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
@@ -577,7 +532,7 @@ export class MediaRequest {
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
@@ -614,11 +569,6 @@ export class MediaRequest {
|
||||
? sonarrSettings.activeAnimeLanguageProfileId
|
||||
: sonarrSettings.activeLanguageProfileId;
|
||||
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
: sonarrSettings.tags;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
@@ -650,14 +600,6 @@ 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({
|
||||
@@ -669,7 +611,6 @@ export class MediaRequest {
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
tags,
|
||||
monitored: true,
|
||||
searchNow: !sonarrSettings.preventSearch,
|
||||
})
|
||||
@@ -692,7 +633,7 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added series request failed to add to Sonarr, marking as unknown',
|
||||
@@ -700,14 +641,14 @@ 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}${
|
||||
series.first_air_date
|
||||
? ` (${series.first_air_date.slice(0, 4)})`
|
||||
: ''
|
||||
}`,
|
||||
message: series.overview,
|
||||
subject: series.name,
|
||||
message: 'Series failed to add to Sonarr',
|
||||
notifyUser: admin,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
|
||||
media,
|
||||
extra: [
|
||||
@@ -723,7 +664,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,
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
import { default as generatePassword } from 'secure-random-password';
|
||||
import {
|
||||
AfterLoad,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
MoreThan,
|
||||
Not,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
RelationCount,
|
||||
AfterLoad,
|
||||
OneToOne,
|
||||
} from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { UserType } from '../constants/user';
|
||||
import { QuotaResponse } from '../interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
hasPermission,
|
||||
PermissionCheckOptions,
|
||||
} from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import { UserPushSubscription } from './UserPushSubscription';
|
||||
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 { UserSettings } from './UserSettings';
|
||||
|
||||
@Entity()
|
||||
@@ -87,18 +80,6 @@ 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,
|
||||
@@ -106,9 +87,6 @@ export class User {
|
||||
})
|
||||
public settings?: UserSettings;
|
||||
|
||||
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
|
||||
public pushSubscriptions: UserPushSubscription[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -161,8 +139,7 @@ export class User {
|
||||
logger.info(`Sending generated password email for ${this.email}`, {
|
||||
label: 'User Management',
|
||||
});
|
||||
|
||||
const email = new PreparedEmail(getSettings().notifications.agents.email);
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/generatedpassword'),
|
||||
message: {
|
||||
@@ -198,7 +175,7 @@ export class User {
|
||||
logger.info(`Sending reset password email for ${this.email}`, {
|
||||
label: 'User Management',
|
||||
});
|
||||
const email = new PreparedEmail(getSettings().notifications.agents.email);
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/resetpassword'),
|
||||
message: {
|
||||
@@ -222,100 +199,4 @@ export class User {
|
||||
public setDisplayName(): void {
|
||||
this.displayName = this.username || this.plexUsername;
|
||||
}
|
||||
|
||||
public async getQuota(): Promise<QuotaResponse> {
|
||||
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
|
||||
? movieQuotaLimit - movieQuotaUsed
|
||||
: undefined,
|
||||
restricted:
|
||||
movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0
|
||||
? true
|
||||
: false,
|
||||
},
|
||||
tv: {
|
||||
days: tvQuotaDays,
|
||||
limit: tvQuotaLimit,
|
||||
used: tvQuotaUsed,
|
||||
remaining: tvQuotaLimit ? tvQuotaLimit - tvQuotaUsed : undefined,
|
||||
restricted:
|
||||
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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<UserPushSubscription>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,8 @@ 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<UserSettings>) {
|
||||
@@ -27,17 +20,8 @@ export class UserSettings {
|
||||
@JoinColumn()
|
||||
public user: User;
|
||||
|
||||
@Column({ default: '' })
|
||||
public locale?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public region?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public originalLanguage?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pgpKey?: string;
|
||||
@Column({ default: true })
|
||||
public enableNotifications: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public discordId?: string;
|
||||
@@ -48,67 +32,9 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public telegramSendSilently?: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
transformer: {
|
||||
from: (value: string | null): Partial<NotificationAgentTypes> => {
|
||||
const defaultTypes = {
|
||||
email: ALL_NOTIFICATIONS,
|
||||
discord: 0,
|
||||
pushbullet: 0,
|
||||
pushover: 0,
|
||||
slack: 0,
|
||||
telegram: 0,
|
||||
webhook: 0,
|
||||
webpush: ALL_NOTIFICATIONS,
|
||||
};
|
||||
if (!value) {
|
||||
return defaultTypes;
|
||||
}
|
||||
@Column({ nullable: true })
|
||||
public region?: string;
|
||||
|
||||
const values = JSON.parse(value) as Partial<NotificationAgentTypes>;
|
||||
|
||||
// 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<NotificationAgentTypes>): 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<NotificationAgentTypes>;
|
||||
|
||||
public hasNotificationType(key: NotificationAgentKey, type: Notification) {
|
||||
return hasNotificationType(type, this.notificationTypes[key] ?? 0);
|
||||
}
|
||||
@Column({ nullable: true })
|
||||
public originalLanguage?: string;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import bodyParser from 'body-parser';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csurf from 'csurf';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import session, { Store } from 'express-session';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { createConnection, getRepository } from 'typeorm';
|
||||
import routes from './routes';
|
||||
import bodyParser from 'body-parser';
|
||||
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 * as OpenApiValidator from 'express-openapi-validator';
|
||||
import { Session } from './entity/Session';
|
||||
import { getSettings } from './lib/settings';
|
||||
import logger from './logger';
|
||||
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 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';
|
||||
import SlackAgent from './lib/notifications/agents/slack';
|
||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
@@ -54,13 +52,11 @@ app
|
||||
notificationManager.registerAgents([
|
||||
new DiscordAgent(),
|
||||
new EmailAgent(),
|
||||
new LunaSeaAgent(),
|
||||
new PushbulletAgent(),
|
||||
new PushoverAgent(),
|
||||
new SlackAgent(),
|
||||
new TelegramAgent(),
|
||||
new WebhookAgent(),
|
||||
new WebPushAgent(),
|
||||
]);
|
||||
|
||||
// Start Jobs
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface GenreSliderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
backdrops: string[];
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface PlexConnection {
|
||||
local: boolean;
|
||||
status?: number;
|
||||
message?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PlexDevice {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
||||
import { LanguageProfile } from '../../api/servarr/sonarr';
|
||||
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
|
||||
import { LanguageProfile } from '../../api/sonarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
@@ -12,14 +12,11 @@ export interface ServiceCommonServer {
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeDirectory?: string;
|
||||
activeAnimeLanguageProfileId?: number;
|
||||
activeTags: number[];
|
||||
activeAnimeTags?: number[];
|
||||
}
|
||||
|
||||
export interface ServiceCommonServerWithDetails {
|
||||
server: ServiceCommonServer;
|
||||
profiles: QualityProfile[];
|
||||
rootFolders: Partial<RootFolder>[];
|
||||
profiles: RadarrProfile[];
|
||||
rootFolders: Partial<RadarrRootFolder>[];
|
||||
languageProfiles?: LanguageProfile[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export type LogMessage = {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
label: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface LogsResultsResponse extends PaginatedResponse {
|
||||
results: LogMessage[];
|
||||
}
|
||||
|
||||
export interface SettingsAboutResponse {
|
||||
version: string;
|
||||
totalRequests: number;
|
||||
@@ -28,11 +14,6 @@ export interface PublicSettingsResponse {
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
partialRequestsEnabled: boolean;
|
||||
cacheImages: boolean;
|
||||
vapidPublic: string;
|
||||
enablePushRegistration: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
@@ -46,10 +27,3 @@ export interface CacheItem {
|
||||
vsize: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
version: string;
|
||||
commitTag: string;
|
||||
updateAvailable: boolean;
|
||||
commitsBehind: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type { User } from '../../entity/User';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
@@ -9,16 +9,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
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<NotificationAgentKey, number>;
|
||||
export interface UserSettingsNotificationsResponse {
|
||||
emailEnabled?: boolean;
|
||||
pgpKey?: string;
|
||||
discordEnabled?: boolean;
|
||||
discordId?: string;
|
||||
telegramEnabled?: boolean;
|
||||
enableNotifications: boolean;
|
||||
telegramBotUsername?: string;
|
||||
discordId?: string;
|
||||
telegramChatId?: string;
|
||||
telegramSendSilently?: boolean;
|
||||
webPushEnabled?: boolean;
|
||||
notificationTypes: Partial<NotificationAgentTypes>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'github';
|
||||
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -44,10 +44,6 @@ class CacheManager {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
github: new Cache('github', 'GitHub API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import SonarrAPI from '../api/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.buildUrl(server, '/api/v3'),
|
||||
url: RadarrAPI.buildRadarrUrl(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.buildUrl(server, '/api/v3'),
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import Email from 'email-templates';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { URL } from 'url';
|
||||
import { getSettings, NotificationAgentEmail } from '../settings';
|
||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||
|
||||
import Email from 'email-templates';
|
||||
import { getSettings } from '../settings';
|
||||
class PreparedEmail extends Email {
|
||||
public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
|
||||
const { applicationUrl } = getSettings().main;
|
||||
public constructor() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
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,
|
||||
@@ -28,18 +22,6 @@ class PreparedEmail extends Email {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (pgpKey) {
|
||||
transport.use(
|
||||
'stream',
|
||||
openpgpEncrypt({
|
||||
signingKey: settings.options.pgpPrivateKey,
|
||||
password: settings.options.pgpPassword,
|
||||
encryptionKeys: [pgpKey],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
super({
|
||||
message: {
|
||||
from: {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import crypto 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<void> => {
|
||||
// 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.Key | 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.readKey({
|
||||
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_' + crypto.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),
|
||||
}),
|
||||
publicKeys: validPublicKeys,
|
||||
privateKeys: 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);
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
export interface NotificationPayload {
|
||||
subject: string;
|
||||
notifyUser?: User;
|
||||
notifyUser: User;
|
||||
media?: Media;
|
||||
image?: string;
|
||||
message?: string;
|
||||
@@ -21,9 +21,15 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
}
|
||||
|
||||
protected abstract getSettings(): T;
|
||||
|
||||
protected userNotificationTypes: Notification[] = [
|
||||
Notification.MEDIA_APPROVED,
|
||||
Notification.MEDIA_DECLINED,
|
||||
Notification.MEDIA_AVAILABLE,
|
||||
];
|
||||
}
|
||||
|
||||
export interface NotificationAgent {
|
||||
shouldSend(type: Notification): boolean;
|
||||
shouldSend(type: Notification, payload: NotificationPayload): boolean;
|
||||
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentDiscord,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { getSettings, NotificationAgentDiscord } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
enum EmbedColors {
|
||||
@@ -114,7 +107,7 @@ class DiscordAgent
|
||||
if (payload.request) {
|
||||
fields.push({
|
||||
name: 'Requested By',
|
||||
value: payload.request.requestedBy.displayName,
|
||||
value: payload.notifyUser.displayName ?? '',
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
@@ -163,14 +156,15 @@ class DiscordAgent
|
||||
break;
|
||||
}
|
||||
|
||||
const url =
|
||||
settings.main.applicationUrl && payload.media
|
||||
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
fields.push({
|
||||
name: `Open in ${settings.main.applicationTitle}`,
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: payload.subject,
|
||||
url,
|
||||
description: payload.message,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -208,14 +202,7 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending Discord notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
let content = undefined;
|
||||
|
||||
logger.debug('Sending Discord notification', { label: 'Notifications' });
|
||||
try {
|
||||
const {
|
||||
botUsername,
|
||||
@@ -227,34 +214,16 @@ class DiscordAgent
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
const mentionedUsers: string[] = [];
|
||||
let content = undefined;
|
||||
|
||||
content = users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
user.settings?.discordId
|
||||
)
|
||||
.map((user) => `<@${user.settings?.discordId}>`)
|
||||
.join(' ');
|
||||
if (
|
||||
this.userNotificationTypes.includes(type) &&
|
||||
payload.notifyUser.settings?.enableNotifications &&
|
||||
payload.notifyUser.settings?.discordId
|
||||
) {
|
||||
mentionedUsers.push(payload.notifyUser.settings.discordId);
|
||||
content = `<@${payload.notifyUser.settings.discordId}>`;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
@@ -262,19 +231,18 @@ class DiscordAgent
|
||||
avatar_url: 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',
|
||||
mentions: content,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
message: e.message,
|
||||
response: e.response.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import PreparedEmail from '../../email';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentEmail,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import path from 'path';
|
||||
import { getSettings, NotificationAgentEmail } from '../../settings';
|
||||
import logger from '../../../logger';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
import PreparedEmail from '../../email';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -27,12 +22,13 @@ class EmailAgent
|
||||
return settings.notifications.agents.email;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
public shouldSend(type: Notification, payload: NotificationPayload): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
hasNotificationType(type, this.getSettings().types) &&
|
||||
(payload.notifyUser.settings?.enableNotifications ?? true)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -40,202 +36,339 @@ class EmailAgent
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildMessage(
|
||||
type: Notification,
|
||||
payload: NotificationPayload,
|
||||
toEmail: string
|
||||
): EmailOptions | undefined {
|
||||
private async sendMediaRequestEmail(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();
|
||||
|
||||
if (type === Notification.TEST_NOTIFICATION) {
|
||||
return {
|
||||
// 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 a new ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
}!`,
|
||||
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 ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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 new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} could not be added to ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : '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 ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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 ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} 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: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaAutoApprovedEmail(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 new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been automatically 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: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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 ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} 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: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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: `The following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} you requested 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: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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({
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
message: {
|
||||
to: toEmail,
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: payload.message,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
},
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email 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<boolean> {
|
||||
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,
|
||||
});
|
||||
logger.debug('Sending email notification', { label: 'Notifications' });
|
||||
|
||||
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))
|
||||
)
|
||||
.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;
|
||||
}
|
||||
})
|
||||
);
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
this.sendMediaRequestEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
this.sendMediaApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
this.sendMediaAutoApprovedEmail(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;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||