Compare commits

..

35 Commits

Author SHA1 Message Date
semantic-release-bot
7e4175ff21 chore(release): 1.13.0 2020-12-23 12:44:01 +00:00
sct
4157432aa7 Merge branch 'develop' 2020-12-23 21:39:00 +09:00
semantic-release-bot
741b2c4e9f chore(release): 1.12.1 2020-12-22 03:17:58 +00:00
sct
6dbe9ea814 Merge branch 'develop' 2020-12-22 12:12:25 +09:00
semantic-release-bot
96328ab5c6 chore(release): 1.12.0 2020-12-22 02:26:31 +00:00
sct
c31709681a Merge branch 'develop' 2020-12-22 11:21:19 +09:00
semantic-release-bot
0a3d7c5915 chore(release): 1.11.0 2020-12-20 01:18:39 +00:00
sct
912c63e562 Merge branch 'develop' 2020-12-20 10:12:59 +09:00
semantic-release-bot
077513151e chore(release): 1.10.0 2020-12-19 04:54:22 +00:00
sct
191f39e8bd Merge branch 'develop' 2020-12-19 13:49:05 +09:00
semantic-release-bot
048b6c1d90 chore(release): 1.9.1 2020-12-18 16:08:58 +00:00
sct
a6a065ac0b Merge branch 'develop' 2020-12-19 01:03:48 +09:00
semantic-release-bot
abb0b2d763 chore(release): 1.9.0 2020-12-18 12:05:35 +00:00
sct
d2668d3f49 Merge branch 'develop' 2020-12-18 21:00:48 +09:00
semantic-release-bot
bee6eb288f chore(release): 1.8.0 2020-12-17 15:54:40 +00:00
sct
3473aed27e Merge branch 'develop' 2020-12-17 15:49:18 +00:00
semantic-release-bot
a9144a2167 chore(release): 1.7.0 2020-12-17 14:09:58 +00:00
sct
59822d6e42 Merge branch 'develop' 2020-12-17 14:05:24 +00:00
semantic-release-bot
0bd1bee1fa chore(release): 1.6.0 2020-12-16 05:56:57 +00:00
sct
46326f9d16 Merge branch 'develop' 2020-12-16 05:52:26 +00:00
semantic-release-bot
54b96cc451 chore(release): 1.5.0 2020-12-15 09:05:37 +00:00
sct
7d2a187865 Merge branch 'develop' 2020-12-15 09:00:44 +00:00
semantic-release-bot
17df5dd072 chore(release): 1.4.0 2020-12-15 00:53:05 +00:00
sct
4b48a36ad3 Merge branch 'develop' 2020-12-15 00:48:28 +00:00
semantic-release-bot
16bd3e9fd5 chore(release): 1.3.2 2020-12-14 13:11:15 +00:00
sct
6360c82898 Merge branch 'develop' 2020-12-14 13:06:22 +00:00
semantic-release-bot
f1df9eeb2f chore(release): 1.3.1 2020-12-14 08:03:51 +00:00
sct
a3973791fb Merge branch 'develop' 2020-12-14 07:59:18 +00:00
semantic-release-bot
157ab80be1 chore(release): 1.3.0 2020-12-14 05:32:31 +00:00
sct
ac0f95b773 Merge branch 'develop' 2020-12-14 05:27:01 +00:00
semantic-release-bot
45ebfb937e chore(release): 1.2.0 2020-12-11 22:55:12 +00:00
sct
85325cda7f Merge branch 'develop' 2020-12-11 22:50:33 +00:00
semantic-release-bot
5fc02d0d94 chore(release): 1.1.0 2020-12-08 08:21:58 +00:00
sct
16902f85fb Merge branch 'develop' 2020-12-08 08:16:24 +00:00
semantic-release-bot
17b5a0bf9c chore(release): 1.0.0 2020-12-06 01:58:28 +00:00
358 changed files with 9290 additions and 36763 deletions

View File

@@ -196,8 +196,7 @@
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
"profile": "https://github.com/danshilm",
"contributions": [
"code",
"doc"
"code"
]
},
{
@@ -217,165 +216,6 @@
"contributions": [
"code"
]
},
{
"login": "ankarhem",
"name": "Jakob Ankarhem",
"avatar_url": "https://avatars1.githubusercontent.com/u/14110063?v=4",
"profile": "https://github.com/ankarhem",
"contributions": [
"doc",
"code",
"translation"
]
},
{
"login": "jayesh100",
"name": "Jayesh",
"avatar_url": "https://avatars1.githubusercontent.com/u/8022175?v=4",
"profile": "https://github.com/jayesh100",
"contributions": [
"code"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc"
]
},
{
"login": "hirenshah",
"name": "hirenshah",
"avatar_url": "https://avatars2.githubusercontent.com/u/418112?v=4",
"profile": "https://github.com/hirenshah",
"contributions": [
"doc"
]
},
{
"login": "TheCatLady",
"name": "TheCatLady",
"avatar_url": "https://avatars0.githubusercontent.com/u/52870424?v=4",
"profile": "https://github.com/TheCatLady",
"contributions": [
"code",
"translation",
"doc"
]
},
{
"login": "chriscpritchard",
"name": "Chris Pritchard",
"avatar_url": "https://avatars1.githubusercontent.com/u/1839074?v=4",
"profile": "https://github.com/chriscpritchard",
"contributions": [
"code",
"doc"
]
},
{
"login": "Tamberlox",
"name": "Tamberlox",
"avatar_url": "https://avatars3.githubusercontent.com/u/56069014?v=4",
"profile": "https://github.com/Tamberlox",
"contributions": [
"translation"
]
},
{
"login": "hmnd",
"name": "David",
"avatar_url": "https://avatars.githubusercontent.com/u/12853597?v=4",
"profile": "https://hmnd.io",
"contributions": [
"code"
]
},
{
"login": "douglasparker",
"name": "Douglas Parker",
"avatar_url": "https://avatars.githubusercontent.com/u/18235822?v=4",
"profile": "https://www.douglas-parker.com",
"contributions": [
"doc"
]
},
{
"login": "dancarter",
"name": "Daniel Carter",
"avatar_url": "https://avatars.githubusercontent.com/u/4387516?v=4",
"profile": "https://github.com/dancarter",
"contributions": [
"code"
]
},
{
"login": "NuroDev",
"name": "nuro",
"avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4",
"profile": "https://nuro.dev",
"contributions": [
"doc"
]
},
{
"login": "onedr0p",
"name": "ᗪєνιη ᗷυнʟ",
"avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4",
"profile": "https://github.com/onedr0p",
"contributions": [
"infra"
]
},
{
"login": "JonnyWong16",
"name": "JonnyWong16",
"avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4",
"profile": "https://github.com/JonnyWong16",
"contributions": [
"doc"
]
},
{
"login": "Roxedus",
"name": "Roxedus",
"avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4",
"profile": "https://github.com/Roxedus",
"contributions": [
"doc"
]
},
{
"login": "WoisWoi",
"name": "WoisWoi",
"avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4",
"profile": "https://github.com/WoisWoi",
"contributions": [
"translation"
]
},
{
"login": "HubDuck",
"name": "HubDuck",
"avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
"profile": "https://github.com/HubDuck",
"contributions": [
"translation"
]
},
{
"login": "costaht",
"name": "costaht",
"avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4",
"profile": "https://github.com/costaht",
"contributions": [
"doc",
"translation"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

@@ -1,23 +1,15 @@
**/*.md
**/.gitkeep
**/.vscode
.all-contributorsrc
.dockerignore
.editorconfig
.eslintrc.js
node_modules
.next
.git
.gitbook.yaml
.gitconfig
.gitignore
.github
.next
.all-contributorsrc
.editorconfig
.prettierignore
**/README.md
**/.vscode
config/db/db.sqlite3
config/db/logs/overseerr.log
Dockerfile*
docker-compose.yml
docs
LICENSE
node_modules
snap
stylelint.config.js
Dockerfil**
**.md

View File

@@ -32,7 +32,6 @@ module.exports = {
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'jsx-a11y/no-onchange': 'off',
},
overrides: [
{

View File

@@ -1,5 +0,0 @@
root: ./docs
structure:
readme: README.md
summary: SUMMARY.md

12
.github/CODEOWNERS vendored
View File

@@ -1,12 +0,0 @@
# Global code ownership
* @sct
# Documentation
docs/ @TheCatLady @samwiseg0
# Snap-related files
.github/workflows/snap.yaml @samwiseg0
snap/ @samwiseg0
# i18n locale files
src/i18n/locale/ @sct @TheCatLady

View File

@@ -1,45 +1,45 @@
---
name: Bug report
about: Submit a report to help us improve
about: Create a report to help us improve
title: ''
labels: 'awaiting-triage, type:bug'
assignees: ''
---
#### Description
**Describe the bug**
A clear and concise description of what the bug is.
Please provide a clear and concise description of the bug or issue.
**What version of Overseerr are you running?**
Please fill in the version you are currently running.
#### Version
You can find it under: Settings -> About -> Version
What version of Overseerr are you running? (You can find this in Settings → About → Version.)
**To Reproduce**
Steps to reproduce the behavior:
#### Steps to Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Please tell us how we can reproduce the undesired behavior.
**Expected behavior**
A clear and concise description of what you expected to happen.
1. Go to [...]
2. Click on [...]
3. Scroll down to [...]
4. See error in [...]
**Screenshots**
If applicable, add screenshots to help explain your problem.
#### Expected Behavior
**Desktop (please complete the following information):**
Please provide a clear and concise description of what you expected to happen.
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
#### Screenshots
**Smartphone (please complete the following information):**
If applicable, please provide screenshots depicting the problem.
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
#### Device
What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug.
- **Platform:** [e.g., desktop, smartphone, tablet]
- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab]
- **OS:** [e.g., iOS 8.1, Windows 10, Android 11]
- **Browser:** [e.g., Chrome, Safari, Edge, Firefox]
#### Additional Context
Please provide any additional information that may be relevant or helpful.
**Additional context**
Add any other context about the problem here.

View File

@@ -6,14 +6,14 @@ labels: 'awaiting-triage, type:enhancement'
assignees: ''
---
#### Description
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]."
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
#### Desired Behavior
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
Provide a clear and concise description of what you want to happen.
#### Additional Context
Provide any additional information or screenshots that may be relevant or helpful.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -1,13 +1,13 @@
#### Description
#### Screenshot (if UI-related)
#### Screenshot (if UI related)
#### To-Dos
#### Todos
- [ ] Successful build `yarn build`
- [ ] Translation keys `yarn i18n:extract`
- [ ] Database migration (if required)
- [ ] Sucessfully builds `yarn build`
- [ ] Translation Keys `yarn i18n:extract`
- [ ] Database migration created (if required)
#### Issues Fixed or Closed
#### Issues Fixed or Closed by this PR
- Fixes #XXXX

View File

@@ -6,9 +6,3 @@ updates:
interval: daily
time: '20:00'
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: daily
time: '20:00'
open-pull-requests-limit: 10

2
.github/stale.yml vendored
View File

@@ -8,7 +8,7 @@ exemptLabels:
- security
- dependencies
# Label to use when marking an issue as stale
staleLabel: stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had

View File

@@ -10,8 +10,7 @@ on:
jobs:
test:
name: Lint & Test Build
runs-on: ubuntu-20.04
runs-on: ubuntu-18.04
container: node:12.18-alpine
steps:
- name: checkout
@@ -24,12 +23,11 @@ jobs:
run: yarn lint
- name: build
run: yarn build
build_and_push:
name: Build & Publish to Docker Hub
name: Build and push Docker image to Docker Hub
needs: test
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
@@ -37,13 +35,6 @@ jobs:
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2.1.4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
@@ -60,7 +51,6 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
@@ -69,37 +59,3 @@ jobs:
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
discord:
name: Send Discord Notification
needs: build_and_push
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View File

@@ -1,23 +0,0 @@
name: Deploy API Docs
on:
push:
branches:
- develop
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Generate Swagger UI
uses: Legion2/swagger-ui-action@v1
with:
output: swagger-ui
spec-file: overseerr-api.yml
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: swagger-ui
cname: api-docs.overseerr.dev

View File

@@ -6,14 +6,14 @@ on:
jobs:
support:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v2
with:
github-token: ${{ github.token }}
support-label: 'invalid:template-incomplete'
issue-comment: >
:wave: @{issue-author}, please follow the template provided.
close-issue: true
lock-issue: true
:wave: @{issue-author}, please edit your issue and follow the template provided.
close-issue: false
lock-issue: false
issue-lock-reason: 'resolved'

View File

@@ -4,52 +4,35 @@ on:
push:
branches:
- master
- arm-test
jobs:
# 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
test:
runs-on: ubuntu-18.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
runs-on: ubuntu-20.04
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v1
with:
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: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Install dependencies
run: yarn
- name: Release
@@ -58,88 +41,3 @@ 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
# - 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
if: always()
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View File

@@ -1,111 +0,0 @@
name: Publish Snap
on:
push:
branches: [develop]
jobs:
jobs:
name: Job Check
runs-on: ubuntu-20.04
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.8.0
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
test:
name: Lint & Test Build
needs: jobs
runs-on: ubuntu-20.04
container: node:12.18-alpine
steps:
- name: checkout
uses: actions/checkout@v2
- name: install dependencies
env:
HUSKY_SKIP_INSTALL: 1
run: yarn
- name: lint
run: yarn lint
- name: build
run: yarn build
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: test
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
architecture:
- amd64
- arm64
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Prepare
id: prepare
run: |
git fetch --prune --unshallow --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
else
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
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
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2.1.5
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

View File

@@ -6,7 +6,7 @@ on:
jobs:
support:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v2
with:
@@ -19,8 +19,7 @@ jobs:
to get help with Overseerr.
- [Discord](https://discord.gg/PkCWJSeCk7)
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
close-issue: true
lock-issue: true
lock-issue: false
issue-lock-reason: 'off-topic'

11
.gitignore vendored
View File

@@ -32,23 +32,14 @@ yarn-error.log*
.vercel
# database
config/db/*.sqlite3
config/db/db.sqlite3
config/settings.json
# logs
config/logs/*.log*
config/logs/*.json
config/logs/*.log.gz
config/logs/*-audit.json
# anidb mapping file
config/anime-list.xml
# dist files
dist
# sqlite journal
config/db/db.sqlite3-journal
# VS Code
.vscode/launch.json

View File

@@ -1,9 +0,0 @@
{
"formats": {
"openapi": {
"rootDir": ".",
"include": ["**"]
}
},
"exclude": ["docs"]
}

View File

@@ -19,9 +19,6 @@
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss",
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
"heybourn.headwind"
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -1,483 +1,357 @@
# 1.0.0-arm-test.1 (2021-03-12)
# [1.13.0](https://github.com/sct/overseerr/compare/v1.12.1...v1.13.0) (2020-12-23)
### Bug Fixes
* correctly send auto-approval notifictions for series ([8634081](https://github.com/sct/overseerr/commit/8634081c869a2078793ecf06b1b7e249bba0a2f8))
* try custom buildx semantic-release package ([bffb84d](https://github.com/sct/overseerr/commit/bffb84d08ba3faa1552b2425d439a98334deb939))
* **api:** check correct permissions for auto approve when requests are created ([3c1a72b](https://github.com/sct/overseerr/commit/3c1a72b038fd178b4be4dc082cd1496474148d7e))
* **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a))
* **api:** improve rottentomatoes rating matching for movies ([7db62ab](https://github.com/sct/overseerr/commit/7db62ab824eefc42e6db16e42d52f4266b136f82)), closes [#494](https://github.com/sct/overseerr/issues/494)
* **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705)
* **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79))
* **build:** remove cross import from client to server for UserType ([23624bd](https://github.com/sct/overseerr/commit/23624bd144af5df4c31995b68ce48105b95b20f6))
* **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542))
* **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834)
* **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011))
* **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d))
* **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3))
* **frontend:** check for ID instead of email after initial setup Plex login ([#1097](https://github.com/sct/overseerr/issues/1097)) ([778dda6](https://github.com/sct/overseerr/commit/778dda67d54df87347dd79577ef1bdc88d3c1d3f))
* **frontend:** check if swr is validating to determine if we should fetch new data ([e5f5bdb](https://github.com/sct/overseerr/commit/e5f5bdb95c62eba31a3321a7457d354f0226bf85)), closes [#719](https://github.com/sct/overseerr/issues/719)
* **frontend:** clarify which fields are required in radarr/sonarr modals ([860d71e](https://github.com/sct/overseerr/commit/860d71ed69a69a1a3f74b79290ef471e04f57a6b)), closes [#575](https://github.com/sct/overseerr/issues/575)
* **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38))
* **frontend:** do not show failed media status on request list for declined requests ([00944b1](https://github.com/sct/overseerr/commit/00944b1ec2db8ddc5742448f6448f7364c473a98)), closes [#664](https://github.com/sct/overseerr/issues/664)
* **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5))
* **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e))
* **frontend:** fix request button height ([a262727](https://github.com/sct/overseerr/commit/a2627270784bdef8644875fa5c5a7349a0b7fd81))
* **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498))
* **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721)
* **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7))
* **frontend:** never hide available content in search results ([d48edeb](https://github.com/sct/overseerr/commit/d48edeb5a9bd8e2edce8bca0fea50e300bb7a1ae))
* **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5))
* **frontend:** show auto approval on series request modal only with correct permissions ([8927c6d](https://github.com/sct/overseerr/commit/8927c6d2e39dbda2b1121095a7273f5cab1c9b74)), closes [#687](https://github.com/sct/overseerr/issues/687)
* **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b))
* **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a))
* **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0))
* **lang:** add missing i18n strings ([6072e8a](https://github.com/sct/overseerr/commit/6072e8aa9a0f84e50c44a92af303aad15b5f3021))
* **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f))
* **lang:** add missing see more i18n string for SeeMoreCard ([d9919ab](https://github.com/sct/overseerr/commit/d9919abb8998d28558ddec35b8e60ab2af75d5b7))
* **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb))
* **lang:** change email auth user/pass strings to SMTP Username/Password ([a77a2aa](https://github.com/sct/overseerr/commit/a77a2aa3ebb1be353d534db5b07647ac26c60e15))
* **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c))
* **lang:** edit new Telegram-related strings to conform to style guide ([#1093](https://github.com/sct/overseerr/issues/1093)) ([bdf67e7](https://github.com/sct/overseerr/commit/bdf67e732b6c77cbae768a25edfc9a663ef0108b))
* **lang:** fix singular form of season count ([#1080](https://github.com/sct/overseerr/issues/1080)) ([b57645d](https://github.com/sct/overseerr/commit/b57645d382361c856281e7a74295afe16c5390f2))
* **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2))
* **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e))
* **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65))
* **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4))
* **notifications:** correctly compare seasons before sending series notifications ([f17fa2a](https://github.com/sct/overseerr/commit/f17fa2a2db8144bac89936f588627e8dd37bf54a))
* **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041)
* **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770)
* **notifications:** send media declined email ([eb6fc8a](https://github.com/sct/overseerr/commit/eb6fc8a19099469794d471db0b48a258c2866633)), closes [#679](https://github.com/sct/overseerr/issues/679)
* **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2))
* **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990)
* **plex-sync:** store plex added date and sort recently added by it ([d688a96](https://github.com/sct/overseerr/commit/d688a967596afcba9799b8133089bebb5add27cf))
* **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05))
* **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79))
* **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1))
* **requests:** add plex url to request item ([#1088](https://github.com/sct/overseerr/issues/1088)) ([420038d](https://github.com/sct/overseerr/commit/420038d5ffdd4070df03e5c5cb6ef8d6208fddb5))
* **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690)
* **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9))
* **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688)
* **server:** support absolute paths for CONFIG_DIRECTORY ([51d8fba](https://github.com/sct/overseerr/commit/51d8fba9162b9e148a35ced69e7e035438c8b0f1))
* **sonarr:** correctly search when updating existing sonarr series ([ed0a7fb](https://github.com/sct/overseerr/commit/ed0a7fbdf5122a26fa936e83b76a97c55781782d)), closes [#588](https://github.com/sct/overseerr/issues/588)
* **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762)
* **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796)
* **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777)
* **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f))
* **ui:** add alt prop to studio/network logos & fix blinking text cursor ([#1095](https://github.com/sct/overseerr/issues/1095)) ([0c4637f](https://github.com/sct/overseerr/commit/0c4637f779d8904037b9cbd5fe9166cf05a891c5))
* **ui:** add link to poster image on request items ([7289872](https://github.com/sct/overseerr/commit/7289872937d5bb94d027424760ee1ceb94095604))
* **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe))
* **ui:** correct language usage re: "sync" vs. "scan" ([#1079](https://github.com/sct/overseerr/issues/1079)) ([e98f2b9](https://github.com/sct/overseerr/commit/e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06))
* **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb))
* **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4))
* **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372))
* **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56))
* **ui:** fix request list UI behavior when season list is too long ([#1106](https://github.com/sct/overseerr/issues/1106)) ([8507691](https://github.com/sct/overseerr/commit/85076919c6ccbf052699b7d5f4ba8b6e5e5af74d))
* **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001))
* **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3))
* **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66))
* **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447))
* **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3))
* **ui:** list all movie studios instead of just the first result ([#1110](https://github.com/sct/overseerr/issues/1110)) ([239202d](https://github.com/sct/overseerr/commit/239202d9c11f27410b0fa084bcc4c824b7136081))
* **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c))
* **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451))
* **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac))
* **ui:** request list button sizes ([#1152](https://github.com/sct/overseerr/issues/1152)) ([fc73592](https://github.com/sct/overseerr/commit/fc73592b69c38191f91a68a020868b8e5ec2e2e2))
* fix language filter link on movie detail pages ([#1142](https://github.com/sct/overseerr/issues/1142)) ([60d453b](https://github.com/sct/overseerr/commit/60d453b0bbba5e2060f72f40d1dde85ec6b05af4))
* **frontend:** status, requested by, and modified alignment fix ([#1109](https://github.com/sct/overseerr/issues/1109)) ([1a7dc1a](https://github.com/sct/overseerr/commit/1a7dc1acf57888d3d0285b58c1c97a824a232216))
* **ui:** display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting ([#1127](https://github.com/sct/overseerr/issues/1127)) ([45886cc](https://github.com/sct/overseerr/commit/45886ccef1bee57dc555060a491834567e45b59c))
* remove language/region filtering on studio/network results ([#1129](https://github.com/sct/overseerr/issues/1129)) ([109aca8](https://github.com/sct/overseerr/commit/109aca8229dc7b81cac314d84591f1c04c12ac2e))
* **ui:** don't show "Password" user settings tab if current user lacks perms to modify the password ([#1063](https://github.com/sct/overseerr/issues/1063)) ([b146d11](https://github.com/sct/overseerr/commit/b146d11e2ffecedae76472b0491a4662ca4a4a4e))
* **ui:** fix Radarr logo alignment ([#1068](https://github.com/sct/overseerr/issues/1068)) ([0fa005a](https://github.com/sct/overseerr/commit/0fa005a99cd868b5a235ae9ce65b4c64b05d0f47))
* **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1))
* **ui:** improve responsive design on new request list UI ([#1105](https://github.com/sct/overseerr/issues/1105)) ([1f8b03f](https://github.com/sct/overseerr/commit/1f8b03ff6f67ce76051667de05166da54ed3dc89))
* add correct permission checks to modifying user password/permissions ([ddfc5e6](https://github.com/sct/overseerr/commit/ddfc5e6aa8fc636931f495d6f23d56367466e3b5))
* **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37))
* **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5))
* **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27))
* **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b))
* **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a))
* **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7))
* add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c))
* added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4))
* allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013)
* **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a))
* **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462)
* **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
* **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
* **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
* **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
* **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
* clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
* **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
### Features
* **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
* **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
* **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
* **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
* **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
* **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
* **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
## [1.12.1](https://github.com/sct/overseerr/compare/v1.12.0...v1.12.1) (2020-12-22)
### Bug Fixes
* **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
# [1.12.0](https://github.com/sct/overseerr/compare/v1.11.0...v1.12.0) (2020-12-22)
### Bug Fixes
* **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f))
* **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682))
* **api:** return 202 when same seasons are requested again ([5c84702](https://github.com/sct/overseerr/commit/5c847026aad79fcac4d020786ded9f867696c226))
* **build:** fixes build to include commit tag for app build step ([289864a](https://github.com/sct/overseerr/commit/289864af1a995ce04834bf8a220cc238e1954d19))
* **docs:** fix typo in build instructions ([#503](https://github.com/sct/overseerr/issues/503)) ([2b27a71](https://github.com/sct/overseerr/commit/2b27a715b07c27200ba1e5e9623629a34389276d))
* **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
* **frontend:** add i18n for request text on titlecard ([a524b9c](https://github.com/sct/overseerr/commit/a524b9c4c8968f6823d33eb270dc26069fe4a725))
* **frontend:** add localized strings for status checker ([2dcda39](https://github.com/sct/overseerr/commit/2dcda39d40d820419e098bd6f1101eb820e5b42d))
* **frontend:** add margin to ButtonWithDropdown component on movie/tv details page ([06fc98b](https://github.com/sct/overseerr/commit/06fc98b6b958221fa180f57f702c348f15b31f1c))
* **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81))
* **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
* **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416)
* **frontend:** adjust titlecard badge styling ([effc809](https://github.com/sct/overseerr/commit/effc80977a4ed732092254248f82363e52233171))
* **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430)
* **frontend:** apply same titlecard hover effect to personcard ([67f2b57](https://github.com/sct/overseerr/commit/67f2b57f00216ded3b34965629d6fdd2f16bc25f))
* **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
* **frontend:** center text in movie auto-approve modal on small screens ([#510](https://github.com/sct/overseerr/issues/510)) ([1438b08](https://github.com/sct/overseerr/commit/1438b08cf0b358d79c6688c64be99f1718ec2d23)), closes [#507](https://github.com/sct/overseerr/issues/507)
* **frontend:** change titlecard to only have a request button ([b5a3a7a](https://github.com/sct/overseerr/commit/b5a3a7a89fcaf86dd794dc419711677b53646577))
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
* **frontend:** combine duplicate credits on a persons detail page ([d188f6f](https://github.com/sct/overseerr/commit/d188f6ffadff1564c47d5f33138e35498bed29fd)), closes [#504](https://github.com/sct/overseerr/issues/504)
* **frontend:** correctly position title card hover section ([#486](https://github.com/sct/overseerr/issues/486)) ([4b7af86](https://github.com/sct/overseerr/commit/4b7af86111a0300e1a137f23fa4ad1639fa55feb))
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
* **frontend:** disable pointer-events on titlecard badges ([ce06879](https://github.com/sct/overseerr/commit/ce0687922a94588b3492e8ddf2e84f54dd1a0d4e))
* **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
* **frontend:** fix button styling on details page on small screen sizes ([d9e0c90](https://github.com/sct/overseerr/commit/d9e0c90e76d80aef0c67318e00e997804805f46e))
* **frontend:** fix count of requests in request list ([f124d73](https://github.com/sct/overseerr/commit/f124d732a2911abdccb5abc11471efe61cc20f7a))
* **frontend:** fix missing styles for alert component ([de3d288](https://github.com/sct/overseerr/commit/de3d288949b60d3a3af889d69a62bea2bc799ed7))
* **frontend:** fix mobile dropdown in notifications settings ([6353cda](https://github.com/sct/overseerr/commit/6353cda5825f442dd539886c7b9ba437edf27ac4))
* **frontend:** fix scaling titlecard content position ([bd94740](https://github.com/sct/overseerr/commit/bd947409e6e8ff313011b77adc76ccd5f9112c78))
* **frontend:** fix sliders overflowing on firefox ([67ac9e0](https://github.com/sct/overseerr/commit/67ac9e075f0ca1cfe7e4766d9168815d7ab600fa)), closes [#566](https://github.com/sct/overseerr/issues/566)
* **frontend:** full season request modal fits on a smaller mobile UI ([#535](https://github.com/sct/overseerr/issues/535)) ([12db7a0](https://github.com/sct/overseerr/commit/12db7a065ad566b47d46de4b949343290894f153))
* **frontend:** handle currentLibrary possibly being null on first manual sync ([93b57a7](https://github.com/sct/overseerr/commit/93b57a76f10a823615ca11ff59f523b67aa30fad))
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
* **frontend:** improve flex header on movie/tv details page ([d7b1c28](https://github.com/sct/overseerr/commit/d7b1c2840690c144ebf29a360defcbd6fdb21354))
* **frontend:** increase titlecard status badge size on larger screens ([ba106c4](https://github.com/sct/overseerr/commit/ba106c447d76db2f9ac70a60c5b38cc60ab554fe))
* **frontend:** invalid dom-nesting title card fix ([#482](https://github.com/sct/overseerr/issues/482)) ([f2ebba7](https://github.com/sct/overseerr/commit/f2ebba7b1df775d33d2af6abc3ee2c9de5f2e57a)), closes [#476](https://github.com/sct/overseerr/issues/476)
* **frontend:** only animate titlecard when showDetail is true ([0ab4c3c](https://github.com/sct/overseerr/commit/0ab4c3c36fe2c1ded142b6931111516f7f990a41))
* **frontend:** remove vote permission for now ([5d06a34](https://github.com/sct/overseerr/commit/5d06a347311bd10c05d8f58068ca7104e265dcca))
* **frontend:** request dropdown menu now properly shows up over collection button ([b491be1](https://github.com/sct/overseerr/commit/b491be1b1e7f6aa588274230e695e4c5302b961e))
* **frontend:** search clear button now correctly triggers routing ([343f466](https://github.com/sct/overseerr/commit/343f466788abc308b91a414ef61bba816ac8875c))
* **frontend:** set locale cookie expiration to be much longer ([fae4818](https://github.com/sct/overseerr/commit/fae481895736eab81d52eb93788beb00669fb355))
* **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
* **frontend:** show correct request status on request cards for 4k requests ([1aa0005](https://github.com/sct/overseerr/commit/1aa0005b4298fc1af9c1d0bf1f357738c0fa2673))
* **frontend:** show movie/series badges always ([8cbf39a](https://github.com/sct/overseerr/commit/8cbf39a9d12eaee7720fa4721c350c1ef9dee856))
* **frontend:** sort person detail credits by tmdb votes ([17518db](https://github.com/sct/overseerr/commit/17518dbe7f545100770a892d03d1f8508adc3650))
* **frontend:** status badge Unavailable renamed to Requested ([ed94a0f](https://github.com/sct/overseerr/commit/ed94a0f335c59de526dd812aea7616313fe002fd)), closes [#374](https://github.com/sct/overseerr/issues/374)
* **frontend:** update login/setup images ([058fb65](https://github.com/sct/overseerr/commit/058fb65495baa08a0bd4c9e0aef320c6fc7d017b))
* **frontend:** update titlecard status badge to new requested colors ([8f292d5](https://github.com/sct/overseerr/commit/8f292d538b937ea133175089979ef02599f6fef4))
* **frontend:** use hardware acceleration for titlecard scale ([88810bf](https://github.com/sct/overseerr/commit/88810bf0a4ef74299f6541b60fa91cea3610f99c))
* **holiday:** remove special holiday slider ([8c09033](https://github.com/sct/overseerr/commit/8c0903393cf2cb2a929ba70a8ab6ddcc4cba0574))
* **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412))
* **logs:** rotate logs on a daily basis instead of incrementing log filename ([395cbb2](https://github.com/sct/overseerr/commit/395cbb2be6c62f1d7573593e49a93615eaf22853))
* **notifications:** only send one available notification for standard media ([fc6f7cc](https://github.com/sct/overseerr/commit/fc6f7ccea586165a30022b6d5554911c66ece6df))
* **plex-sync:** do not run plex sync if no admin exists ([493d82b](https://github.com/sct/overseerr/commit/493d82b6b066d77609cf66e005fd1f1472b8e011))
* **plex-sync:** improve plex sync error handling. add session id to fix stuck runs ([a740b07](https://github.com/sct/overseerr/commit/a740b07f06f892b72a651b928af28ce71cb495ee))
* **requests:** select the correct radarr/sonarr server when sending request to service ([e0d9f89](https://github.com/sct/overseerr/commit/e0d9f891e797c3839f976b75a871903b6f2e55f1))
* only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
* **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
* **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
### Features
* **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
* **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
* default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
* import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
* **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
* **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
### Reverts
* **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))
# [1.11.0](https://github.com/sct/overseerr/compare/v1.10.0...v1.11.0) (2020-12-20)
### Features
* **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
* **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
* **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
* **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
* **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
* **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
* **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19)
### Bug Fixes
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
### Features
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18)
### Bug Fixes
* change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
* clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
* correctly deal with tmdb id duplicates between movies/series ([721ed9a](https://github.com/sct/overseerr/commit/721ed9a93087a57ae749388bddcacf26022e3df6)), closes [#526](https://github.com/sct/overseerr/issues/526)
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
* **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18)
### Features
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17)
### Features
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
### Bug Fixes
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
### Features
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
### Bug Fixes
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
### Features
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
### Bug Fixes
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
### Features
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
### Bug Fixes
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
### Features
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
### Bug Fixes
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
### Bug Fixes
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)
### Bug Fixes
* **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
### Features
* **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
* **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
* **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
* **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
* **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
* **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
* **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
* **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
* **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
* **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
# [1.2.0](https://github.com/sct/overseerr/compare/v1.1.0...v1.2.0) (2020-12-11)
### Bug Fixes
* **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7))
* **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53))
* **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220)
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
* fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721))
* missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e))
* **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
* **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
### Features
* **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
* person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
* **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
* **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
* **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
* **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
* **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
* **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
* **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
* **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
* **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
# [1.1.0](https://github.com/sct/overseerr/compare/v1.0.0...v1.1.0) (2020-12-08)
### Bug Fixes
* fix a few misc unused imports and useless assignments/conditionals ([8e6daf7](https://github.com/sct/overseerr/commit/8e6daf7bd271ce5bebf4a00f5bb1144bd6b60aa5))
* **frontend:** dont show delete button in request list for users without correct permission ([83fde46](https://github.com/sct/overseerr/commit/83fde46a59c6f1910806a6106b5526b8adbc386c))
* **frontend:** push updated i18n locale files ([b4002d7](https://github.com/sct/overseerr/commit/b4002d71323a04e7991198cedc263660e872df8d))
### Features
* generate real api key ([a839370](https://github.com/sct/overseerr/commit/a8393707fec85a9262af5ba8c03d205190b2235b))
* **frontend:** add i18n strings for request list and request item ([6c4022f](https://github.com/sct/overseerr/commit/6c4022fb236583ad20d4c4c6693c1339e165b4af))
* **frontend:** initial version of the requests page (no filtering/sorting) ([1ba027b](https://github.com/sct/overseerr/commit/1ba027b4357e078c3f177d9d07208049f0c1ce65))
* **frontend:** only load request/tmdb cards when in the browser view ([2d51efd](https://github.com/sct/overseerr/commit/2d51efd71612ec969b83c62d6aa0dac6df9391a3))
# 1.0.0 (2020-12-06)
### Bug Fixes
* **api:** fix scheduling for plex full sync (maybe) ([7287a6a](https://github.com/sct/overseerr/commit/7287a6a95703b23acc0c4f6eb3beb9ec2295e33f))
* **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825))
* **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d))
* **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f))
* **frontend:** always show request modal option for tv ([2b46268](https://github.com/sct/overseerr/commit/2b462688243531b4be620a942f59defd4e0534d0))
* **frontend:** canceled movie request should set parent movie status back to unknown ([#198](https://github.com/sct/overseerr/issues/198)) ([139871f](https://github.com/sct/overseerr/commit/139871f218812a15f742aa66408db12704e0b9b5))
* **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b))
* **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14))
* **frontend:** close request modals when complete ([85ae499](https://github.com/sct/overseerr/commit/85ae4998f0ba8d4869b9b244f2c440b9df1310d2))
* **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059))
* **frontend:** dont show delete button in request list for users without correct permission ([83fde46](https://github.com/sct/overseerr/commit/83fde46a59c6f1910806a6106b5526b8adbc386c))
* **frontend:** dont show runtime if there is no runtime data ([e0c39ae](https://github.com/sct/overseerr/commit/e0c39aeca119b822f2a54ff05a97f91780ddd052))
* **frontend:** fix missing data for request modal title i18n ([a56fd16](https://github.com/sct/overseerr/commit/a56fd16ab6638d4649fe9f8b9d75e7cae7742f73))
* **frontend:** fix missing import for ReactNode type in Slider ([b26a234](https://github.com/sct/overseerr/commit/b26a2347e7b0f7ff8720a204d9faefd501ba886c))
* **frontend:** fix modal design and rename some text for adding servers ([46d99b0](https://github.com/sct/overseerr/commit/46d99b02b1c992c7b8dde2150217ed9ce326b7a5))
* **frontend:** fix opening popups on safari ([364d9d1](https://github.com/sct/overseerr/commit/364d9d105ca3690fcd5f635485d7c025353bb9f1))
* **frontend:** fix request card placeholder sizes for mobile ([ef62c67](https://github.com/sct/overseerr/commit/ef62c67480ed52d753ea6db8205f035b2e9da272))
* **frontend:** show a badge on requestcard for partially available status ([59056c4](https://github.com/sct/overseerr/commit/59056c44f942a37df536ff947b5faccc27f32246))
* dont cross import SyncStatus type ([e032e38](https://github.com/sct/overseerr/commit/e032e385a5253d215490255c676f42ee48f39428))
* fix type import from server side crashing build process ([89be56d](https://github.com/sct/overseerr/commit/89be56d8403ebc60c411e7cb357593edd9c79bb2))
* **frontend:** fix title detail background image to be centered ([b92f64f](https://github.com/sct/overseerr/commit/b92f64fa6e167bc89168d8f5c0f2eb12efa0b6f0))
* **frontend:** fixed similar/recommendations showing when empty ([#180](https://github.com/sct/overseerr/issues/180)) ([a3ca9b4](https://github.com/sct/overseerr/commit/a3ca9b40c552e6cc5effc2f57f7562ff6f723e42))
* **frontend:** have tvDetail use the new RequestModal ([6aca826](https://github.com/sct/overseerr/commit/6aca82607b97d4a4ad74e2ea843d52fba4689e6a))
* **frontend:** remove requestId from tilecard request modal component ([61b6152](https://github.com/sct/overseerr/commit/61b6152e8915c99585b944756a61d33b8c8a0307))
* **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
* **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
* fix a few misc unused imports and useless assignments/conditionals ([8e6daf7](https://github.com/sct/overseerr/commit/8e6daf7bd271ce5bebf4a00f5bb1144bd6b60aa5))
* improve apple-touch-icon and android app icons ([329a814](https://github.com/sct/overseerr/commit/329a814a8fb791122266c0b04b05848c71d68ba1))
* **frontend:** fix request card placeholder sizes for mobile ([ef62c67](https://github.com/sct/overseerr/commit/ef62c67480ed52d753ea6db8205f035b2e9da272))
* **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52))
* **frontend:** push updated i18n locale files ([b4002d7](https://github.com/sct/overseerr/commit/b4002d71323a04e7991198cedc263660e872df8d))
* **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3))
* **frontend:** show a badge on requestcard for partially available status ([59056c4](https://github.com/sct/overseerr/commit/59056c44f942a37df536ff947b5faccc27f32246))
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
* **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0))
* **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b))
* **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4))
* **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746))
* **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8))
* **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
* **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623))
* **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4))
* **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf))
* **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
* **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898)
* **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
* **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd))
* **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06))
* **search:** Handle search errors and escape * ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813))
* **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f))
* **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233))
* **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872)
* **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541))
* **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06))
* **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7))
* **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971)
* **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb))
* **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82))
* **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d))
* **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21))
* **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262))
* **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72))
* **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a))
* **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21))
* **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5))
* **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3))
* dont cross import SyncStatus type ([e032e38](https://github.com/sct/overseerr/commit/e032e385a5253d215490255c676f42ee48f39428))
* fix type import from server side crashing build process ([89be56d](https://github.com/sct/overseerr/commit/89be56d8403ebc60c411e7cb357593edd9c79bb2))
* lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d))
* only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
* show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03))
* use new commit tag file for app version as well ([d00e470](https://github.com/sct/overseerr/commit/d00e470b55327489b49d770144b7cfdb24045be6))
* **frontend:** reinitalize plex form after data loads ([97e3036](https://github.com/sct/overseerr/commit/97e30367fb5d2d27efc42c1d76b0d051b6f1da76))
* **frontend:** remove requestId from tilecard request modal component ([61b6152](https://github.com/sct/overseerr/commit/61b6152e8915c99585b944756a61d33b8c8a0307))
* **frontend:** run initial props for children components after getting the user ([fdf9f38](https://github.com/sct/overseerr/commit/fdf9f38776b6d4c08b3505c03b354639cebb011f))
* **frontend:** when there were no results in the list view, it would call fetch more infinitely ([c0ce87b](https://github.com/sct/overseerr/commit/c0ce87b6f65bf0ab1301c7ca61090d779709529f))
* **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
* **user edit:** fix user edit not being able to be saved ([#651](https://github.com/sct/overseerr/issues/651)) ([b04d00e](https://github.com/sct/overseerr/commit/b04d00ef509d6f13c1f9677b3f318331782c0086))
* fixed an issue with eslint-prettier on windows ([#32](https://github.com/sct/overseerr/issues/32)) ([b673ea1](https://github.com/sct/overseerr/commit/b673ea1b18ca0f432996bb9e4e5d148af0247170))
* fixes next.js build to not include server files ([de8ee9b](https://github.com/sct/overseerr/commit/de8ee9ba85e0160b0b472cab44f92c01796efec8))
### Features
* **api:** /request/count endpoint ([#682](https://github.com/sct/overseerr/issues/682)) ([192cfd8](https://github.com/sct/overseerr/commit/192cfd8a8ea9ab942d5bb265d42050917a2f5a04))
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
* **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60))
* **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0))
* **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702))
* **email:** add sendername to email notification ([#506](https://github.com/sct/overseerr/issues/506)) ([0185bb1](https://github.com/sct/overseerr/commit/0185bb1a7084c1faeb61fb1c63e34e26732711c8))
* **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
* **frontend:** add clear-field-icon to search field ([#498](https://github.com/sct/overseerr/issues/498)) ([7434a26](https://github.com/sct/overseerr/commit/7434a26f76b5e9f74918f3e1a34443d20ecfcbe4))
* **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
* **frontend:** add documentation link to about page ([c034496](https://github.com/sct/overseerr/commit/c034496f557a031aed35cd28dc7221d8cdf36643))
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
* **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
* **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
* **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae))
* **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
* **frontend:** add see more card to media sliders ([587e8db](https://github.com/sct/overseerr/commit/587e8db15e9c19b4c58406e3e4215d8bf87d8762))
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
* **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
* **frontend:** add telegram integration ([#491](https://github.com/sct/overseerr/issues/491)) ([c8d4d67](https://github.com/sct/overseerr/commit/c8d4d674f412082ad9e9da09abd79660365cf728))
* **frontend:** add template variable help button to custom webhook settings page ([29c5bc4](https://github.com/sct/overseerr/commit/29c5bc40975e7ab0a2e08bb77294f164f0c60769))
* **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
* **frontend:** filter/sorting for request list ([5add44c](https://github.com/sct/overseerr/commit/5add44cfb0379aa6fed7c3b867230292feacc684)), closes [#431](https://github.com/sct/overseerr/issues/431)
* **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
* **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
* **lang:** add support for Chinese (Traditional) language ([686c4f7](https://github.com/sct/overseerr/commit/686c4f71bf930625af082ac5e14dc5f79f5c42eb))
* **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
* **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
* **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576))
* **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b))
* **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36))
* **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84))
* **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c))
* **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711))
* **lang:** translations update from Weblate ([#1051](https://github.com/sct/overseerr/issues/1051)) ([69bf817](https://github.com/sct/overseerr/commit/69bf817f598babed99964f073259f827b60bd014))
* **lang:** Translations update from Weblate ([#1131](https://github.com/sct/overseerr/issues/1131)) ([e4686d6](https://github.com/sct/overseerr/commit/e4686d664b52448e32488ff1c4236f72e01e9a29))
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
* **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
* **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
* **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
* **lang:** translations update from Weblate ([#479](https://github.com/sct/overseerr/issues/479)) ([c8c74b0](https://github.com/sct/overseerr/commit/c8c74b0ae54fcc524aa8b2edf5a5c5e5db6c1638))
* **lang:** translations update from Weblate ([#495](https://github.com/sct/overseerr/issues/495)) ([b04eda6](https://github.com/sct/overseerr/commit/b04eda6c8a3bfcaa2a14b8a29612fdf690c9fba0))
* **lang:** Translations update from Weblate ([#580](https://github.com/sct/overseerr/issues/580)) ([2bfe0f2](https://github.com/sct/overseerr/commit/2bfe0f2bf66956763ab26d5c54f26e6c456f59f7))
* **lang:** Translations update from Weblate ([#604](https://github.com/sct/overseerr/issues/604)) ([801e765](https://github.com/sct/overseerr/commit/801e76524d6ea0887249f1630402e9c3a3430b44))
* **login:** add local users functionality ([#591](https://github.com/sct/overseerr/issues/591)) ([492e19d](https://github.com/sct/overseerr/commit/492e19df4014e67dc6a2de5903a33c25e13fcf45))
* **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
* **notif:** add "Media Automatically Approved" notification type ([#1137](https://github.com/sct/overseerr/issues/1137)) ([f7d2723](https://github.com/sct/overseerr/commit/f7d2723fab2c30564fd23945709cd39b178a6eef))
* **notif:** add settings for Discord bot username & avatar URL ([#1113](https://github.com/sct/overseerr/issues/1113)) ([3384eb1](https://github.com/sct/overseerr/commit/3384eb1c479114c0246cb22f9a933aa79fb95fcf))
* **notif:** include poster image in Telegram notifications ([#1112](https://github.com/sct/overseerr/issues/1112)) ([48387e5](https://github.com/sct/overseerr/commit/48387e5b2f26c0c33acd436c6e1cf902d6c32101))
* **notifications:** add notification for declined requests ([2f97f61](https://github.com/sct/overseerr/commit/2f97f61a6e8846975774aa16950a39ada2b1a016)), closes [#663](https://github.com/sct/overseerr/issues/663)
* **notifications:** add pushover integration ([#574](https://github.com/sct/overseerr/issues/574)) ([ee5d018](https://github.com/sct/overseerr/commit/ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55))
* **notifications:** add slack notification agent ([1163e81](https://github.com/sct/overseerr/commit/1163e81adc7da1e8334155ebee5b4672a22143db)), closes [#365](https://github.com/sct/overseerr/issues/365)
* **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
* **notifications:** control notifcation types per agent ([8af6a1f](https://github.com/sct/overseerr/commit/8af6a1f566769c583af7dd9e18d162717835b7cc)), closes [#513](https://github.com/sct/overseerr/issues/513)
* **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
* **notifications:** Webhook Notifications ([#632](https://github.com/sct/overseerr/issues/632)) ([a7cc7c5](https://github.com/sct/overseerr/commit/a7cc7c59753dd9649b2ec37eb9d46fe4fa8e1e1c))
* **requests:** Request Overrides & Request Editing ([#653](https://github.com/sct/overseerr/issues/653)) ([bdb3372](https://github.com/sct/overseerr/commit/bdb33722e6df09dd6d8caa36b104b61c6b8dc00d))
* **scan:** add support for new plex tv agent ([#1144](https://github.com/sct/overseerr/issues/1144)) ([a51d2a2](https://github.com/sct/overseerr/commit/a51d2a24d51d092a0c6da608e3322f19a37c2d28))
* **server:** add CONFIG_DIRECTORY env var to control config directory location ([fa8f112](https://github.com/sct/overseerr/commit/fa8f112c31ccb5ee6244f776bc97e76d81958539))
* **ui:** add user ID to profile header ([6e95c8b](https://github.com/sct/overseerr/commit/6e95c8b7a10e3467bfd2c3df84ccf886fe01ca5c))
* 4K Requests ([#559](https://github.com/sct/overseerr/issues/559)) ([6b2df24](https://github.com/sct/overseerr/commit/6b2df24a2e8f96dd2277a814d7e02015d1f80cdc))
* add collections ([#484](https://github.com/sct/overseerr/issues/484)) ([a333a09](https://github.com/sct/overseerr/commit/a333a095820ce3f10857026ba4770a2fffeed7cb)), closes [#418](https://github.com/sct/overseerr/issues/418)
* add genre/studio/network view to Discover results ([#1067](https://github.com/sct/overseerr/issues/1067)) ([f28112f](https://github.com/sct/overseerr/commit/f28112f057df2589f31ae0d0b14e8b50e479fdb7))
* add language-filtered Discover pages ([#1111](https://github.com/sct/overseerr/issues/1111)) ([7501161](https://github.com/sct/overseerr/commit/75011610e57f03098c8be9375d0c9ba1e3647e9b))
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
* add separate auto approve permissions for Movies/Series ([4809257](https://github.com/sct/overseerr/commit/480925781691de456abc427fbbba161be11a3a8a)), closes [#268](https://github.com/sct/overseerr/issues/268)
* add studio/network sliders to discover ([1c6914f](https://github.com/sct/overseerr/commit/1c6914f5ce5c0d171c4609813915b50233a8e3ad))
* **docker:** add tini to docker image ([#1017](https://github.com/sct/overseerr/issues/1017)) ([1629d02](https://github.com/sct/overseerr/commit/1629d02f3d8368bfd5f6fed05382974ae6fce51f))
* **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a))
* **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec))
* **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf))
* **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6))
* **lang:** localize job names ([#1043](https://github.com/sct/overseerr/issues/1043)) ([594aad9](https://github.com/sct/overseerr/commit/594aad9d3ae9b323677f3af8c434d7664526593d))
* **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743))
* **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a))
* **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a))
* **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb))
* **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7))
* **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17))
* **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1))
* **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a))
* **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f))
* **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660))
* **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc))
* **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4))
* **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45))
* **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317))
* **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5))
* **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0))
* **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300))
* **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267)
* **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f))
* **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e))
* **telegram:** add support for individual chat notifications ([#1027](https://github.com/sct/overseerr/issues/1027)) ([f6d00d8](https://github.com/sct/overseerr/commit/f6d00d8d1559879189f83739193c6e2acafde51d))
* **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775)
* **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f))
* **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c))
* **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121))
* **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7))
* **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9))
* **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b))
* **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c))
* **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467))
* **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b))
* **ui:** display "Owner" role instead of "Admin" for user ID 1 ([#1050](https://github.com/sct/overseerr/issues/1050)) ([1b55d2d](https://github.com/sct/overseerr/commit/1b55d2dfbc06d900e7370a4ddfd81789a25bf00c))
* **ui:** display season count on TV details page ([#1078](https://github.com/sct/overseerr/issues/1078)) ([4365231](https://github.com/sct/overseerr/commit/436523139e8f1594c352b17032734b4498d3994f))
* **ui:** in Settings > Services, make Radarr/Sonarr server names and logos clickable links ([#1008](https://github.com/sct/overseerr/issues/1008)) ([6a1e389](https://github.com/sct/overseerr/commit/6a1e3891aa5f84b6adb1e475a6658a8cd4e34c22))
* **ui:** request list redesign ([#1099](https://github.com/sct/overseerr/issues/1099)) ([cd21865](https://github.com/sct/overseerr/commit/cd21865c4d5be00c13c372e0b7a058f61ec855a2))
* ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1))
* add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672)
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
* default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
* import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
* map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs ([#538](https://github.com/sct/overseerr/issues/538)) ([0600ac7](https://github.com/sct/overseerr/commit/0600ac7c3a1bc0cdd906634d5f77ea3e99b10e94)), closes [#453](https://github.com/sct/overseerr/issues/453)
* new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840)
* pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8))
* Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a))
* request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d))
* simple failed request handling ([#474](https://github.com/sct/overseerr/issues/474)) ([02969d5](https://github.com/sct/overseerr/commit/02969d5426245062a2f53475d83c4a8639632c9d))
* status checker to prompt users to reload their frontend when app version changes ([75a4264](https://github.com/sct/overseerr/commit/75a426437a4182e21da13684066966dd5bf8fc5e))
* user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349))
* **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0))
* **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178))
* YouTube Movie/TV Trailers ([#454](https://github.com/sct/overseerr/issues/454)) ([e88dc83](https://github.com/sct/overseerr/commit/e88dc83aeba0475e3ad421d5ab130cea4fc9a806))
* **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
* **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510))
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
* add migration for delete cascades on season requests/seasons ([c688cf6](https://github.com/sct/overseerr/commit/c688cf60c710f0cf0b2da5ba6b0c18a2d137e7f9))
* **api:** email notification agent ([0962392](https://github.com/sct/overseerr/commit/0962392e3930c7fdcb3164b9143cc8faca38bdfa))
* **api:** plex tv sync and recently added sync ([1390cc1](https://github.com/sct/overseerr/commit/1390cc1f130bb3975996e84b12ac833f55f2f753))
* **frontend:** add french language file ([cd6d8a8](https://github.com/sct/overseerr/commit/cd6d8a8216e7ae183b046d26cd22f3c1dc1d2b35))
* **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
* **frontend:** add header styling to movie/tv recommendation and similar list views ([f5f2545](https://github.com/sct/overseerr/commit/f5f2545520a43daa23e1276d24ff60d794ebbc6e))
* **frontend:** add i18n strings for request list and request item ([6c4022f](https://github.com/sct/overseerr/commit/6c4022fb236583ad20d4c4c6693c1339e165b4af))
* **frontend:** add links to detail pages from new request card ([6ad3384](https://github.com/sct/overseerr/commit/6ad3384a78f7bcb03f409cce8b35cc61d634d6b2))
* **frontend:** add translatable strings for request card ([0d2f360](https://github.com/sct/overseerr/commit/0d2f360c22cd9bb50ae04f00a25e5fcc6c21bcdd))
* **frontend:** added more localized strings ([659a601](https://github.com/sct/overseerr/commit/659a6018777718f7a90141307678d8dadcfd77f8))
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
* **frontend:** improved settings menu design for mobile ([16221a4](https://github.com/sct/overseerr/commit/16221a46a7d57c77f53aa0186263aa27267d9863))
* **frontend:** initial version of the requests page (no filtering/sorting) ([1ba027b](https://github.com/sct/overseerr/commit/1ba027b4357e078c3f177d9d07208049f0c1ce65))
* **frontend:** new design for request card ([93738e1](https://github.com/sct/overseerr/commit/93738e154c41fd11d5c6cf3d35573daf54ead471))
* **frontend:** only load request/tmdb cards when in the browser view ([2d51efd](https://github.com/sct/overseerr/commit/2d51efd71612ec969b83c62d6aa0dac6df9391a3))
* **frontend:** recently added on discover ([06dc606](https://github.com/sct/overseerr/commit/06dc606bcfeb50b7be1c35ac180c10738bade458))
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
* **frontend:** update favicon ([886389a](https://github.com/sct/overseerr/commit/886389a361da54c616da3bdfeee9a85e9d12bcf3))
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
* **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
* **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
* **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
* **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
* **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
* **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
* **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
* **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
* **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
* **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
* **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
* **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
* **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
* **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
* **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
* **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
* **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
* actually include email templates in built server files ([a28a8b3](https://github.com/sct/overseerr/commit/a28a8b37b0afc79583e4a7191a91f73ff6d3adad))
* add application url config to main settings ui ([a359672](https://github.com/sct/overseerr/commit/a359672ebafffef742858814f0faa918e0341aa3))
* add filtering for requests api ([cb9ae25](https://github.com/sct/overseerr/commit/cb9ae25d94f21e97113dfea3ca45c7002089e344))
* add migration for delete cascades on season requests/seasons ([c688cf6](https://github.com/sct/overseerr/commit/c688cf60c710f0cf0b2da5ba6b0c18a2d137e7f9))
* add trending to discover page ([ff8b9d8](https://github.com/sct/overseerr/commit/ff8b9d8e7ed228a153c2da4d237f7a4f99a79321))
* force setup if app is not initialized ([a99705f](https://github.com/sct/overseerr/commit/a99705f6a5674b436ae28cbc558f4ee6e99ac910))
* notification framework ([d8e542e](https://github.com/sct/overseerr/commit/d8e542e5fe2ed76dcb20fb6dfc5f59430cd4245d))
* notifications for media_available and media_approved ([a6c5e65](https://github.com/sct/overseerr/commit/a6c5e65bbfc196545471e99fe2e5b7194f9dd387))
* person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
* **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
* **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
* generate real api key ([a839370](https://github.com/sct/overseerr/commit/a8393707fec85a9262af5ba8c03d205190b2235b))
* initial user list (no edit/delete yet) and job schedules ([24a0423](https://github.com/sct/overseerr/commit/24a0423f3b14303cfb0e83aef6e9e3bb273c5ba9))
* manage series slideover added (and approve/decline/delete hooked up) ([236c4e5](https://github.com/sct/overseerr/commit/236c4e5e6126d2424a4badc08b7f7e6d1d70f401))
* media delete option in manage media slideover ([250f484](https://github.com/sct/overseerr/commit/250f48492c95d74e40d95d3f026d2952157bc6e1))
* other email notifications for approved/available ([0d73d88](https://github.com/sct/overseerr/commit/0d73d88f35b03e993f305873dc72672003c7d9e5))
* rotten tomatoes scores on movie/tv details pages ([1694f60](https://github.com/sct/overseerr/commit/1694f60e8aa475ceeb7f170a783ec0ba70bd4bce))
* radarr edit/create modal/backend functionality ([c4ac357](https://github.com/sct/overseerr/commit/c4ac357ef4cdd7a2c610260db46a4f0c325cd785))
* season creation migration ([978f92a](https://github.com/sct/overseerr/commit/978f92a1c589ac404a3cb1103a68a8a5ffb0dd7d))
* sonarr edit/delete modal ([3204326](https://github.com/sct/overseerr/commit/320432657e6ccf4d255238098e03590f28267bdb))
* throw 404 when movie/tv show doesnt exist ([0601b44](https://github.com/sct/overseerr/commit/0601b446873e2eaf042044dd6a995b713586b0cc))
* **api:** sonarr api wrapper / send to sonarr ([9385592](https://github.com/sct/overseerr/commit/9385592362eeba1dba05c5aa8fc7a2de1d054d74))
* **frontend:** add header styling to movie/tv recommendation and similar list views ([f5f2545](https://github.com/sct/overseerr/commit/f5f2545520a43daa23e1276d24ff60d794ebbc6e))
* **frontend:** add links to detail pages from new request card ([6ad3384](https://github.com/sct/overseerr/commit/6ad3384a78f7bcb03f409cce8b35cc61d634d6b2))
* **frontend:** new design for request card ([93738e1](https://github.com/sct/overseerr/commit/93738e154c41fd11d5c6cf3d35573daf54ead471))
* **frontend:** update favicon ([886389a](https://github.com/sct/overseerr/commit/886389a361da54c616da3bdfeee9a85e9d12bcf3))
* notification framework ([d8e542e](https://github.com/sct/overseerr/commit/d8e542e5fe2ed76dcb20fb6dfc5f59430cd4245d))
* notifications for media_available and media_approved ([a6c5e65](https://github.com/sct/overseerr/commit/a6c5e65bbfc196545471e99fe2e5b7194f9dd387))
* rotten tomatoes scores on movie/tv details pages ([1694f60](https://github.com/sct/overseerr/commit/1694f60e8aa475ceeb7f170a783ec0ba70bd4bce))
* upcoming movies on discover ([67290dd](https://github.com/sct/overseerr/commit/67290dd502571a22dcf8559ac07f42e855275bd0))
* upcoming/trending list views and larger title cards ([94eaaf9](https://github.com/sct/overseerr/commit/94eaaf96b4302a832c52ccb72009b3593452c779))
* upgrade tailwindcss to 2.0.1 ([fb5c791](https://github.com/sct/overseerr/commit/fb5c791b0b6b7593a472bf01713999a001f92dc7))
* **.editorconfig:** add .editorconfig ([b982066](https://github.com/sct/overseerr/commit/b982066327525156f8dd0d32818d3fe7cb28f9c8))
* user edit functionality (managing permissions) ([185ac26](https://github.com/sct/overseerr/commit/185ac2648fd21c4bf9692ac5ac055e9c740065ca))
* **api:** plex tv sync and recently added sync ([1390cc1](https://github.com/sct/overseerr/commit/1390cc1f130bb3975996e84b12ac833f55f2f753))
* **frontend:** allow permission check for showing nav items ([0b239f0](https://github.com/sct/overseerr/commit/0b239f0bdfb1394897bce5c50b0d112abfbb4ad7))
* **frontend:** alpha notice ([33da7e9](https://github.com/sct/overseerr/commit/33da7e9df3a2546b0f208bd3b1d1f268e343cead))
* **frontend:** buttonWithDropdown component added (no hookups yet) ([4975841](https://github.com/sct/overseerr/commit/4975841b5d4ba4ed1ba8cacaa5a063eeb3b8c311))
* **frontend:** cancel movie request modal ([1f9cbbf](https://github.com/sct/overseerr/commit/1f9cbbfdf1ac98e54de5b8777c52c7bfc69c7e20))
* **frontend:** improved settings menu design for mobile ([16221a4](https://github.com/sct/overseerr/commit/16221a46a7d57c77f53aa0186263aa27267d9863))
* **frontend:** initial Settings design ([8742da0](https://github.com/sct/overseerr/commit/8742da0ebb92d2f78309a998de0f67e788e14376))
* **frontend:** plex library scan ([1bc3f7b](https://github.com/sct/overseerr/commit/1bc3f7be4b07211563a1e254c28ce51e1bc337a2))
* **frontend:** plex settings page ([47714b6](https://github.com/sct/overseerr/commit/47714b698cf4351c1ee38bdf0b672d9f0baed03a))
* **frontend:** radarr delete modal ([877a518](https://github.com/sct/overseerr/commit/877a5184158fb4aa371fa2ea2107032543c9aa37))
* radarr edit/create modal/backend functionality ([c4ac357](https://github.com/sct/overseerr/commit/c4ac357ef4cdd7a2c610260db46a4f0c325cd785))
* **frontend:** recently added on discover ([06dc606](https://github.com/sct/overseerr/commit/06dc606bcfeb50b7be1c35ac180c10738bade458))
* **frontend:** slideover initial work ([14b9cb6](https://github.com/sct/overseerr/commit/14b9cb610c0dcfef939ebec328f371e1cdfb689d))
* tv request modal status hookup ([5f8114f](https://github.com/sct/overseerr/commit/5f8114f730b067eb710704952824057e7b5b8fbf))
* **.editorconfig:** add .editorconfig ([b982066](https://github.com/sct/overseerr/commit/b982066327525156f8dd0d32818d3fe7cb28f9c8))
* **api:** add external ids to movie/tv response ([4aa7431](https://github.com/sct/overseerr/commit/4aa74319e0adcc19041239e57a00bc40fb127826))
* **api:** add movie details endpoint ([b176148](https://github.com/sct/overseerr/commit/b1761484cb2861329763d51a868f37dd3098760d))
* **api:** add tmdb discover api wrapper ([#67](https://github.com/sct/overseerr/issues/67)) ([839448f](https://github.com/sct/overseerr/commit/839448fcc8cc14ea83092af82e2ba3d0d92c9b73))
@@ -489,7 +363,6 @@
* **api:** public settings route ([#57](https://github.com/sct/overseerr/issues/57)) ([c0166e7](https://github.com/sct/overseerr/commit/c0166e7ecb5df110a4167f33338ed6406bf47f41))
* **api:** radarr api wrapper / send to radarr when requests approved ([#93](https://github.com/sct/overseerr/issues/93)) ([48d62c3](https://github.com/sct/overseerr/commit/48d62c3178488d0d51831155ddd35cc31867db2b))
* **api:** request api ([#80](https://github.com/sct/overseerr/issues/80)) ([f4c2c47](https://github.com/sct/overseerr/commit/f4c2c47e569e7faea7f99664966cb98b321ce952))
* **api:** sonarr api wrapper / send to sonarr ([9385592](https://github.com/sct/overseerr/commit/9385592362eeba1dba05c5aa8fc7a2de1d054d74))
* **api:** tmdb api wrapper / multi search route ([#62](https://github.com/sct/overseerr/issues/62)) ([c702c17](https://github.com/sct/overseerr/commit/c702c17cee00a52b23f685206e2d5d0c2eddf5a2))
* **api:** tmdb trending api wrapper ([#68](https://github.com/sct/overseerr/issues/68)) ([ba34e54](https://github.com/sct/overseerr/commit/ba34e54d77d142d211df58d6ce9f53b6e673e004))
* **api:** tv details endpoint ([a3beeed](https://github.com/sct/overseerr/commit/a3beeede7e72e99c7595673a27e38611ca4bb0cd))
@@ -498,24 +371,14 @@
* **components/plexloginbutton:** added PlexLoginButton ([0abf743](https://github.com/sct/overseerr/commit/0abf743b17c664b58da18bdbf176f4a55ddc4179))
* **extensions.json:** added recommended extensions for VSCode ([5dc9b51](https://github.com/sct/overseerr/commit/5dc9b510b8049516ad889c9d76a2f84daa0d2718))
* **frontend:** add cancel request modal for titlecards ([f22f8c5](https://github.com/sct/overseerr/commit/f22f8c5d734be5cc0b1dcca869458a7321cd43a2))
* **frontend:** allow permission check for showing nav items ([0b239f0](https://github.com/sct/overseerr/commit/0b239f0bdfb1394897bce5c50b0d112abfbb4ad7))
* **frontend:** alpha notice ([33da7e9](https://github.com/sct/overseerr/commit/33da7e9df3a2546b0f208bd3b1d1f268e343cead))
* **frontend:** approve/decline request well added to movie detail ([8f21358](https://github.com/sct/overseerr/commit/8f21358f797ed55923d90ba43acf1126856e9dfd))
* **frontend:** basic discover page (only movies) ([#74](https://github.com/sct/overseerr/issues/74)) ([bbfe349](https://github.com/sct/overseerr/commit/bbfe349b52d308620796b37aaf986a0ed1ff0006))
* **frontend:** buttonWithDropdown component added (no hookups yet) ([4975841](https://github.com/sct/overseerr/commit/4975841b5d4ba4ed1ba8cacaa5a063eeb3b8c311))
* **frontend:** cancel movie request modal ([1f9cbbf](https://github.com/sct/overseerr/commit/1f9cbbfdf1ac98e54de5b8777c52c7bfc69c7e20))
* **frontend:** design updates for responsive titlecards ([31809d9](https://github.com/sct/overseerr/commit/31809d952c8bafde3f63e2c1d952cc013149940e))
* **frontend:** discover tv/movies full page ([be0003a](https://github.com/sct/overseerr/commit/be0003a85dc4e91799e85019aeb1110bd524a026))
* **frontend:** initial search functionality ([#78](https://github.com/sct/overseerr/issues/78)) ([342d1a3](https://github.com/sct/overseerr/commit/342d1a3c75b32b172a51ca7d82fdfde8510abedf))
* **frontend:** initial Settings design ([8742da0](https://github.com/sct/overseerr/commit/8742da0ebb92d2f78309a998de0f67e788e14376))
* **frontend:** loading spinner ([de84658](https://github.com/sct/overseerr/commit/de84658b48985e24b0f92a1690387f6d59d0bc16))
* **frontend:** logo updates ([5a43ec5](https://github.com/sct/overseerr/commit/5a43ec5405855deb244e8085484a9d2b743caba6))
* **frontend:** modal component and basic request hookup ([#91](https://github.com/sct/overseerr/issues/91)) ([626099a](https://github.com/sct/overseerr/commit/626099a2c98fb30d0cb53d8ccf79a6bf75a00059))
* **frontend:** plex library scan ([1bc3f7b](https://github.com/sct/overseerr/commit/1bc3f7be4b07211563a1e254c28ce51e1bc337a2))
* **frontend:** plex settings page ([47714b6](https://github.com/sct/overseerr/commit/47714b698cf4351c1ee38bdf0b672d9f0baed03a))
* **frontend:** slideover initial work ([14b9cb6](https://github.com/sct/overseerr/commit/14b9cb610c0dcfef939ebec328f371e1cdfb689d))
* logout route/sign out button ([#54](https://github.com/sct/overseerr/issues/54)) ([cb9098f](https://github.com/sct/overseerr/commit/cb9098f457f79b71734959fd924b6c72ca77d61d))
* tv request modal status hookup ([5f8114f](https://github.com/sct/overseerr/commit/5f8114f730b067eb710704952824057e7b5b8fbf))
* **frontend:** discover tv/movies full page ([be0003a](https://github.com/sct/overseerr/commit/be0003a85dc4e91799e85019aeb1110bd524a026))
* **frontend:** new dashboard concept ([#82](https://github.com/sct/overseerr/issues/82)) ([eae38bb](https://github.com/sct/overseerr/commit/eae38bb9ec8588856f319387d2f262d7ee3f7e9c))
* **frontend:** refresh indicator for titlecards / toasts ([4638fae](https://github.com/sct/overseerr/commit/4638fae336edc62a539796b3f55277a238683603))
* **frontend:** request card / recent requests ([371e433](https://github.com/sct/overseerr/commit/371e43356d2c057e52368c32ffe2af1744311d91))
@@ -526,6 +389,7 @@
* **frontend/api:** movie recommendations/similar request and frontend detail page update ([6398e36](https://github.com/sct/overseerr/commit/6398e3645a1e4ddbb9de9f4fda0a0659b4cac4d0))
* **frontend/api:** tv details page ([02cbb5b](https://github.com/sct/overseerr/commit/02cbb5b030a3af5d62ab6c4cafdd4d800b4f61f4))
* **frontend/api:** tv request modal (no status. only request) ([608b966](https://github.com/sct/overseerr/commit/608b96600a926adf16331b36e77789afa5d67069))
* logout route/sign out button ([#54](https://github.com/sct/overseerr/issues/54)) ([cb9098f](https://github.com/sct/overseerr/commit/cb9098f457f79b71734959fd924b6c72ca77d61d))
* user avatars from plex ([#53](https://github.com/sct/overseerr/issues/53)) ([e6349c1](https://github.com/sct/overseerr/commit/e6349c13a0eb0489289aa7663fcc64fa7d2906e6))
* **layout:** created Layout component ([1f497e8](https://github.com/sct/overseerr/commit/1f497e8913146ceb9748d667e638141b2ca4612a))
* **login component/route:** add: Login Component and Route ([6e47be2](https://github.com/sct/overseerr/commit/6e47be2fa865bcd51582ce30ebee6fd820c5f9dd))
@@ -533,10 +397,3 @@
* **pass pageprops to loginpage:** pass page props to loginPage ([1597188](https://github.com/sct/overseerr/commit/159718891fb363001c650ac8b7e1446a1520ce4a))
* **plex/utils:** added Plex OAuth class ([72f9624](https://github.com/sct/overseerr/commit/72f9624f1db721fe0324b7be9f0f811d2ae02389))
* bootstrap the basic app structure ([89a6017](https://github.com/sct/overseerr/commit/89a6017c7f6f7637fe249ac0d667a652f44e02bb))
* user edit functionality (managing permissions) ([185ac26](https://github.com/sct/overseerr/commit/185ac2648fd21c4bf9692ac5ac055e9c740065ca))
### Reverts
* **deps:** revert back to next@10.0.3 until sharp optional dependency bug is fixed ([7962964](https://github.com/sct/overseerr/commit/79629645aacc1a042919834da79bff0c1f69c9d6))
* **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))

View File

@@ -1,105 +1,48 @@
# Contributing to Overseerr
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started:
## Development
### Tools Required
- HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
- [Yarn](https://yarnpkg.com/)
- [Git](https://git-scm.com/downloads)
### Getting Started
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch `git checkout -b BRANCH_NAME`
```bash
git clone https://github.com/YOUR_USERNAME/overseerr.git
cd overseerr/
```
- Its recommended to name the branch something relevant to the feature or fix you are working on.
- An example of this would be `fix-title-cards` or `feature-new-system`.
- Bad examples would be `patch` or `bug`.
2. Add the remote `upstream`:
3. Install dependencies `yarn`
4. `yarn dev` to build and watch for changes
```bash
git remote add upstream https://github.com/sct/overseerr.git
```
3. Create a new branch:
```bash
git checkout -b BRANCH_NAME develop
```
- It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
- Good examples:
- `docs-docker`
- `feature-new-system`
- `fix-title-cards`
- Bad examples:
- `bug`
- `docs`
- `feature`
- `fix`
- `patch`
4. Run the development environment:
```bash
yarn
yarn dev
```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes.
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
- Should you need to update your fork, you can do so by rebasing from `upstream`:
```bash
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
You can also run the development environment in [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
### Contributing Code
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- It is okay to squash your pull request down into a single commit that fits this standard.
- Pull requests with commits not following this standard will **not** be merged.
- Please make meaningful commits, or squash them.
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
- You can create a "draft" pull request early to get feedback on your work.
- Your code **must** be formatted correctly, or the tests will fail.
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7).
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
### UI Text Style
When adding new UI text, please try to adhere to the following guidelines:
1. Be concise and clear, and use as few words as possible to make your point.
2. Use the Oxford comma where appropriate.
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols.
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'.
5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized).
6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation.
7. Ensure that toast notification strings are complete sentences ending in punctuation.
8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`.
9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name.
10. Do your best to check for spelling errors and grammatical mistakes.
11. Do not misspell "Overseerr."
- It is okay if you squash your PR down to be a single commit that fits this standard.
- PRs with commits not following this standard will not be merged.
- Please make meaningful commits, or squash them
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
- You can create a Draft pull request early to get feedback on your work.
- Your code must be formatted correctly or the tests will fail.
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed.
## Translation
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
## Attribution

View File

@@ -1,43 +1,30 @@
FROM node:14.16-alpine AS BUILD_IMAGE
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
FROM node:12.18-alpine AS BUILD_IMAGE
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
RUN yarn --frozen-lockfile --network-timeout 1000000 && \
RUN yarn --frozen-lockfile && \
yarn build
# remove development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
RUN yarn cache clean
RUN rm -rf src && \
rm -rf server
FROM node:12.18-alpine
RUN touch config/DOCKER
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
RUN apk add tzdata
FROM node:14.16-alpine
RUN apk add --no-cache tzdata tini
# copy from build image
COPY --from=BUILD_IMAGE /app /app
COPY . /app
WORKDIR /app
ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "yarn", "start" ]
# copy from build image
COPY --from=BUILD_IMAGE /app/dist ./dist
COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
CMD yarn start
EXPOSE 5055

View File

@@ -1,4 +1,4 @@
FROM node:14.16-alpine
FROM node:12.18-alpine
COPY . /app
WORKDIR /app

141
README.md
View File

@@ -6,48 +6,52 @@
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/PkCWJSeCk7"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://discord.gg/PkCWJSeCk7">
<img src="https://img.shields.io/discord/783137440809746482" alt="Discord">
</a>
<img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls">
<a href="https://hosted.weblate.org/engage/overseerr/">
<img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" />
</a>
<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>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- 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-40-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-23-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Plex](https://www.plex.tv/)**!
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **Sonarr**, **Radarr** and **Plex**!
## Current Features
- Full Plex integration. Authenticate and manage user access with Plex!
- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come!
- Plex library sync, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
- Full Plex integration. Login and manage user access with Plex!
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
- Syncs to your Plex library to know what titles you already have.
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
- Granular permission system
- Mobile friendly design, for when you need to approve requests on the go!
## In Development
- User profiles.
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
- Version update notifications in-app.
- 4K requests (Includes multi-radarr/sonarr management for media)
## Planned Features
- Additional notification types.
- More notification types (Slack/Telegram/etc.).
- 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
Check out our documentation for instructions on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
- Local user system (for those who don't use Plex).
- Compatibility APIs (to work with existing tools in your system).
## Running Overseerr
Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with:
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
```
docker run -d \
--name overseerr \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
@@ -56,40 +60,35 @@ docker run -d \
sctx/overseerr
```
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
For more information 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! ⚠️
⚠️ 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! ⚠️
## Preview
<img src="./public/preview.jpg">
<img src="https://i.imgur.com/Mjbyruv.png">
## Support
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
## API Documentation
Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev
You can also access the API documentation from your local Overseerr install at http://localhost:5055/api-docs
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
## Community
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/PkCWJSeCk7)!
If you would like to chat with community members you can join the [Overseerr Discord](https://discord.gg/PkCWJSeCk7).
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
## Contributing
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
@@ -100,60 +99,38 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
<td align="center"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<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></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/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->

View File

@@ -1,24 +0,0 @@
# Introduction
Welcome to the Overseerr Documentation.
## Features
- **Full Plex integration**. Login and manage user access with Plex.
- **Syncs to your Plex library** to show what titles you already have.
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
- **Simple request management UI**. Don't dig through the app to approve recent requests.
- **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system.
- Localization into other languages.
## Motivation
The primary motivation for starting this project was to have an incredibly performant and easy to use application. There is a heavy focus on the user experience for both the server owner and the users. We feel requesting should be **effortless for the user**. Find the media you want, click request, and branch off efficiently into other titles that interest you, all in one seamless flow. For the server owner, Overseerr takes all the hassle out of approving your users' requests.
## We need your help!
Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md).

View File

@@ -1,22 +0,0 @@
# Table of Contents
- [Introduction](README.md)
## Getting Started
- [Installation](getting-started/installation.md)
## Using Overseerr
- [Notifications](using-overseerr/notifications/README.md)
- [Custom Webhooks](using-overseerr/notifications/webhooks.md)
## Support
- [Frequently Asked Questions (FAQ)](support/faq.md)
- [Asking for Support](support/asking-for-support.md)
## Extending Overseerr
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
- [Fail2ban Filter](extending-overseerr/fail2ban.md)

View File

@@ -1,14 +0,0 @@
# Fail2ban Filter
{% hint style="warning" %}
If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**.
{% endhint %}
To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition:
```
[Definition]
failregex = .*\[info\]\[Auth\]\: Failed sign-in attempt.*"ip":"<HOST>"
```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

@@ -1,168 +0,0 @@
# Reverse Proxy Examples
{% hint style="warning" %}
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). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
```nginx
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name overseerr.*;
include /config/nginx/ssl.conf;
client_max_body_size 0;
location / {
include /config/nginx/proxy.conf;
resolver 127.0.0.11 valid=30s;
set $upstream_app overseerr;
set $upstream_port 5055;
set $upstream_proto http;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
}
}
```
## Traefik \(v2\)
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
```text
labels:
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.overseerr-rtr.entrypoints=https"
- "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
- "traefik.http.routers.overseerr-rtr.tls=true"
## HTTP Services
- "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
```
For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
## Nginx
{% tabs %}
{% tab title="Subdomain" %}
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
```nginx
server {
listen 80;
server_name overseerr.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name overseerr.example.com;
ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
proxy_set_header Referer $http_referer;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-Host $host:$remote_port;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Ssl on;
real_ip_header CF-Connecting-IP;
# Control the behavior of the Referer header (Referrer-Policy)
add_header Referrer-Policy "no-referrer";
# HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
# Prevent some categories of XSS attacks (X-XSS-Protection)
add_header X-XSS-Protection "1; mode=block" always;
# Provide clickjacking protection (X-Frame-Options)
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent Sniff Mimetype (X-Content-Type-Options)
add_header X-Content-Type-Options "nosniff" always;
# Tell crawling bots to not index the site
add_header X-Robots-Tag "noindex, nofollow" always;
access_log /var/log/nginx/overseerr.example.com-access.log;
error_log /var/log/nginx/overseerr.example.com-error.log;
location / {
proxy_pass http://127.0.0.1:5055;
}
}
```
Then, create a symlink to `/etc/nginx/sites-enabled`:
```bash
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
```
{% endtab %}
{% tab title="Subfolder" %}
{% hint style="warning" %}
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.
```nginx
location ^~ /overseerr {
set $app 'overseerr';
# Remove /overseerr path to pass to the app
rewrite ^/overseerr/?(.*)$ /$1 break;
proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
# Redirect location headers
proxy_redirect ^ /$app;
proxy_redirect /setup /$app/setup;
proxy_redirect /login /$app/login;
# Sub filters to replace hardcoded paths
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types *;
sub_filter 'href="/"' 'href="/$app"';
sub_filter 'href="/login"' 'href="/$app/login"';
sub_filter 'href:"/"' 'href:"/$app"';
sub_filter '/_next' '/$app/_next';
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
sub_filter '/logo.png' '/$app/logo.png';
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
}
```
{% endtab %}
{% endtabs %}
Next, test the configuration:
```bash
sudo nginx -t
```
Finally, reload `nginx` for the new configuration to take effect:
```bash
sudo systemctl reload nginx
```

View File

@@ -1,214 +0,0 @@
# Installation
{% hint style="danger" %}
Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`** instead!
{% endhint %}
{% hint style="info" %}
After running Overseerr for the first time, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps.
{% endhint %}
## Docker
{% tabs %}
{% tab title="Basic" %}
```bash
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
```
{% endtab %}
{% tab title="Compose" %}
**docker-compose.yml:**
```yaml
---
version: "3"
services:
overseerr:
image: sctx/overseerr:latest
container_name: overseerr
environment:
- LOG_LEVEL=info
- TZ=Asia/Tokyo
ports:
- 5055:5055
volumes:
- /path/to/appdata/config:/app/config
restart: unless-stopped
```
{% endtab %}
{% tab title="UID/GID" %}
```text
docker run -d \
--name overseerr \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="Manual Update" %}
```text
# Stop the Overseerr container
docker stop overseerr
# Remove the Overseerr container
docker rm overseerr
# Pull the latest update
docker pull sctx/overseerr
# Run the Overseerr container with the same parameters as before
docker run -d ...
```
{% endtab %}
{% endtabs %}
{% hint style="info" %}
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.
{% endhint %}
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**\(Appdata\) as needed.
5. Click apply and access "Overseerr" at your `<ServerIP:HostPort>` in a web browser.
## Windows
Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
{% hint style="danger" %}
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed!**
{% endhint %}
```bash
docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/here:/app/config" --restart unless-stopped sctx/overseerr
```
{% hint style="info" %}
Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
{% endhint %}
## Linux
{% hint style="info" %}
The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd).
{% endhint %}
**To install:**
```
sudo snap install overseerr
```
**Updating:**
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
```
sudo snap refresh
```
**To install the development build:**
```
sudo snap install overseerr --edge
```
{% hint style="danger" %}
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
## Third Party
{% tabs %}
{% tab title="Gentoo" %}
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay).
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
**To enable:**
To enable using `eselect repository`, run:
```bash
eselect repository enable overseerr-overlay
```
**To install:**
Once complete, you can just run:
```bash
emerge www-apps/overseerr
```
**To install the development build:**
A live ebuild (`=www-apps/overseerr-9999`) is also available. To use this, you will need to modify accept_keywords for this package:
```bash
emerge --autounmask --autounmask-write "=www-apps/overseerr-9999"
```
Once installed, you will not be notified of updates, so you can update with:
```bash
emerge @live-rebuild
```
or use `app-portage/smart-live-rebuild`
{% hint style="danger" %}
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
{% endtab %}
{% tab title="Swizzin" %}
{% hint style="danger" %}
This implementation is not yet merged to master due to missing functionality. You can beta test the limited implementation or follow the status on [the pull request](https://github.com/swizzin/swizzin/pull/567).
{% endhint %}
The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used.
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
To install, run the following:
```bash
box install overseerr
```
To upgrade, run the following:
```bash
box upgrade overseerr
```
{% endtab %}
{% endtabs %}

View File

@@ -1,33 +0,0 @@
# Asking for Support
## Before Asking for Support
Before seeking help, please make sure you have tried these following first:
- **Update** to the latest version.
- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
- **Analyze** your logs, you just might find the solution yourself!
- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
## What should I include when asking for support?
When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
- What command did you enter?
- What did you click on?
- What settings did you change?
- Provide a step-by-step list of what you tried.
- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
- Did something happen?
- Did something not happen?
- Are there any error messages showing?
- Provide screenshots to help us see what you are seeing.
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
## How can I share my logs?
1. Locate the log file at `<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).

View File

@@ -1,97 +0,0 @@
# Frequently Asked Questions (FAQ)
{% hint style="info" %}
If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
{% endhint %}
## General
### I receive 409 or 400 errors when requesting a movie or TV series!
**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions.
### How do I keep Overseerr up-to-date?
**A:** Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
### How can I access Overseerr outside my home network?
**A:** The easy and least secure method is to forward an external port \(`5055`\) on your router to the internal port used by Overseerr \(default is TCP `5055`\). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You will then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
The 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, 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?
**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
### Where can I find the changelog?
**A:** You can find the changelog in the **Settings &rarr; About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
### Can I make 4K requests?
**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings &rarr; 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!
**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.
**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. 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.
3. Run a full scan in Overseerr to see if that item is now matched properly.
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
5. Run a full scan on Overseerr after refreshing all unmatched items.
You can also perform the following to verify the media item has a GUID Overseerr can match:
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
2. Verify that the media item's GUID follows one of the below formats:
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
### TV series requests are failing after I updated Overseerr!
**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings &rarr; Services**.
### Where can I find the logs?
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
## 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 &rarr; General Settings** page beforehand.
### Can I create local users in Overseerr?
**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
### Is is possible to set user roles in Overseerr?
**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
## Requests
### I approved a requested movie and Radarr didn't search for it!
**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
### Help! My request still shows "requested" even though it is in Plex!
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
## Notifications
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).

View File

@@ -1,29 +0,0 @@
# Notifications
Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it.
## Currently Supported Notification Agents
- Discord
- Email
- Pushbullet
- Pushover
- Slack
- Telegram
- [Webhooks](./webhooks.md)
## Setting Up Notifications
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
Some agents may have specific configuration "gotchas" covered in their documentation pages.
{% hint style="danger" %}
You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled.
{% endhint %}
## Requesting New Notification Agents
If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!

View File

@@ -1,57 +0,0 @@
# Webhooks
Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes.
## Configuration
The following configuration options are available:
### Webhook URL (required)
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
### Authorization Header
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
### 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.
## Template Variables
### General
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
### User
These variables are usually the target user of the notification.
- `{{notifyuser_username}}` Target user's username.
- `{{notifyuser_email}}` Target user's email.
- `{{notifyuser_avatar}}` Target user's avatar.
- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
### Media
These variables are only included in media related notifications, such as requests.
- `{{media_type}}` Media type. Either `movie` or `tv`.
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
### 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.

View File

@@ -1,7 +1,4 @@
module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,

View File

@@ -1,8 +1,6 @@
const devConfig = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3',
database: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: false,
@@ -17,9 +15,7 @@ const devConfig = {
const prodConfig = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3',
database: 'config/db/db.sqlite3',
synchronize: false,
logging: false,
entities: ['dist/entity/**/*.js'],

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "overseerr",
"version": "1.0.0-arm-test.1",
"version": "1.13.0",
"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 false \"./src/**/!(*.test).{ts,tsx}\"",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
@@ -17,122 +17,102 @@
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^0.3.1",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"axios": "^0.21.0",
"body-parser": "^1.19.0",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"country-flag-icons": "^1.2.9",
"csurf": "^1.11.0",
"email-templates": "^8.0.3",
"email-templates": "^8.0.2",
"express": "^4.17.1",
"express-openapi-validator": "^4.12.4",
"express-openapi-validator": "^4.9.4",
"express-session": "^1.17.1",
"formik": "^2.2.6",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
"next": "10.0.3",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.5.0",
"nookies": "^2.5.2",
"lodash": "^4.17.20",
"next": "^10.0.3",
"node-schedule": "^1.3.2",
"nodemailer": "^6.4.17",
"nookies": "^2.5.0",
"plex-api": "^5.3.1",
"pug": "^3.0.2",
"pug": "^3.0.0",
"react": "17.0.1",
"react-ace": "^9.3.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.13.2",
"react-intl": "^5.10.9",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.3",
"react-toast-notifications": "^2.4.0",
"react-transition-group": "^4.4.1",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"react-truncate-markup": "^5.0.1",
"react-use-clipboard": "1.0.2",
"reflect-metadata": "^0.1.13",
"secure-random-password": "^0.2.2",
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.1.6",
"swr": "^0.5.1",
"typeorm": "^0.2.31",
"sqlite3": "^5.0.0",
"swagger-ui-express": "^4.1.5",
"swr": "^0.3.11",
"typeorm": "^0.2.29",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.1",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.9"
"yup": "^0.32.8"
},
"devDependencies": {
"@babel/cli": "^7.13.10",
"@commitlint/cli": "^12.0.1",
"@commitlint/config-conventional": "^12.0.1",
"@babel/cli": "^7.12.10",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@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.2.1",
"@tailwindcss/typography": "^0.4.0",
"@types/bcrypt": "^3.0.0",
"@tailwindcss/typography": "^0.3.1",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.0",
"@types/email-templates": "^8.0.2",
"@types/express": "^4.17.11",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.33",
"@types/email-templates": "^7.1.0",
"@types/express": "^4.17.9",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.165",
"@types/node": "^14.14.14",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-toast-notifications": "^2.4.0",
"@types/react-transition-group": "^4.4.1",
"@types/secure-random-password": "^0.2.0",
"@types/react-transition-group": "^4.4.0",
"@types/swagger-ui-express": "^4.1.2",
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.8",
"@types/xml2js": "^0.4.7",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"autoprefixer": "^10.2.5",
"babel-plugin-react-intl": "^8.2.25",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"autoprefixer": "^9",
"babel-plugin-react-intl": "^8.2.22",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.3",
"commitizen": "^4.2.2",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.21.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-formatjs": "^2.12.7",
"eslint": "^7.16.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-formatjs": "^2.9.11",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-prettier": "^3.3.0",
"eslint-plugin-react": "^7.21.5",
"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.8",
"husky": "^4.3.6",
"lint-staged": "^10.5.3",
"nodemon": "^2.0.6",
"postcss": "^7",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.2.1",
"semantic-release": "^17.4.1",
"semantic-release": "^17.3.0",
"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.3"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
"typescript": "^4.1.3"
},
"config": {
"commitizen": {
@@ -187,7 +167,13 @@
"message": "chore(release): ${nextRelease.version}"
}
],
"semantic-release-docker-buildx",
[
"@semantic-release/exec",
{
"prepareCmd": "docker build -t sctx/overseerr ."
}
],
"semantic-release-docker",
[
"@semantic-release/github",
{
@@ -196,28 +182,13 @@
]
],
"branches": [
"master",
{
"name": "arm-test",
"prerelease": true
}
"master"
],
"npmPublish": false,
"publish": [
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GITHUB_SHA"
},
"imageNames": [
"sctx/overseerr",
"ghcr.io/sct/overseerr"
],
"platforms": [
"linux/amd64",
"linux/arm64",
"linux/arm/v7"
]
"path": "semantic-release-docker",
"name": "sctx/overseerr"
},
"@semantic-release/github"
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
public/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -14,7 +14,7 @@
"type": "image/png"
}
],
"theme_color": "#2d3748",
"background_color": "#2d3748",
"theme_color": "#ffffff",
"background_color": "#1e2937",
"display": "standalone"
}

View File

@@ -1,225 +0,0 @@
import axios from 'axios';
import xml2js from 'xml2js';
import fs, { promises as fsp } from 'fs';
import path from 'path';
import logger from '../logger';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
const MAPPING_URL =
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
const LOCAL_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/anime-list.xml`
: path.join(__dirname, '../../config/anime-list.xml');
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
// https://github.com/Anime-Lists/anime-lists/
interface AnimeMapping {
$: {
anidbseason: string;
tvdbseason: string;
};
_: string;
}
interface Anime {
$: {
anidbid: number;
tvdbid?: string;
defaulttvdbseason?: string;
tmdbid?: number;
imdbid?: string;
};
'mapping-list'?: {
mapping: AnimeMapping[];
}[];
}
interface AnimeList {
'anime-list': {
anime: Anime[];
};
}
export interface AnidbItem {
tvdbId?: number;
tmdbId?: number;
imdbId?: string;
}
class AnimeListMapping {
private syncing = false;
private mapping: { [anidbId: number]: AnidbItem } = {};
// mapping file modification date when it was loaded
private mappingModified: Date | null = null;
// each episode in season 0 from TVDB can map to movie
private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {};
public isLoaded = () => Object.keys(this.mapping).length !== 0;
private loadFromFile = async () => {
logger.info('Loading mapping file', { label: 'Anime-List Sync' });
try {
const mappingStat = await fsp.stat(LOCAL_PATH);
const file = await fsp.readFile(LOCAL_PATH);
const xml = (await xml2js.parseStringPromise(file)) as AnimeList;
this.mapping = {};
this.specials = {};
for (const anime of xml['anime-list'].anime) {
// tvdbId can be nonnumber, like 'movie' string
let tvdbId: number | undefined;
if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) {
tvdbId = Number(anime.$.tvdbid);
} else {
tvdbId = undefined;
}
let imdbIds: (string | undefined)[];
if (anime.$.imdbid) {
// if there are multiple imdb entries, then they map to different movies
imdbIds = anime.$.imdbid.split(',');
} else {
// in case there is no imdbid, that's ok as there will be tmdbid
imdbIds = [undefined];
}
const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined;
const anidbId = Number(anime.$.anidbid);
this.mapping[anidbId] = {
// for season 0 ignore tvdbid, because this must be movie/OVA
tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId,
tmdbId: tmdbId,
imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping
};
if (tvdbId) {
const mappingList = anime['mapping-list'];
if (mappingList && mappingList.length != 0) {
let imdbIndex = 0;
for (const mapping of mappingList[0].mapping) {
const text = mapping._;
if (text && mapping.$.tvdbseason === '0') {
let matches;
while ((matches = mappingRegexp.exec(text)) !== null) {
const episode = Number(matches[1]);
if (!this.specials[tvdbId]) {
this.specials[tvdbId] = {};
}
// map next available imdbid to episode in s0
const imdbId =
imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex];
if (tmdbId || imdbId) {
this.specials[tvdbId][episode] = {
tmdbId: tmdbId,
imdbId: imdbId,
};
imdbIndex++;
}
}
}
}
} else {
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
// movies must have imdbId or tmdbId
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
if (!this.specials[tvdbId]) {
this.specials[tvdbId] = {};
}
// map each imdbid to episode in s0, episode index starts with 1
for (let idx = 0; idx < imdbIds.length; idx++) {
this.specials[tvdbId][idx + 1] = {
tmdbId: tmdbId,
imdbId: imdbIds[idx],
};
}
}
}
}
}
this.mappingModified = mappingStat.mtime;
logger.info(
`Loaded ${
Object.keys(this.mapping).length
} AniDB items from mapping file`,
{ label: 'Anime-List Sync' }
);
} catch (e) {
throw new Error(`Failed to load Anime-List mappings: ${e.message}`);
}
};
private downloadFile = async () => {
logger.info('Downloading latest mapping file', {
label: 'Anime-List Sync',
});
try {
const response = await axios.get(MAPPING_URL, {
responseType: 'stream',
});
await new Promise<void>((resolve) => {
const writer = fs.createWriteStream(LOCAL_PATH);
writer.on('finish', resolve);
response.data.pipe(writer);
});
} catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
}
};
public sync = async () => {
// make sure only one sync runs at a time
if (this.syncing) {
return;
}
this.syncing = true;
try {
// check if local file is not "expired" yet
if (fs.existsSync(LOCAL_PATH)) {
const now = new Date();
const stat = await fsp.stat(LOCAL_PATH);
if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) {
if (!this.isLoaded()) {
// no need to download, but make sure file is loaded
await this.loadFromFile();
} else if (
this.mappingModified &&
stat.mtime.getTime() > this.mappingModified.getTime()
) {
// if file has been modified externally since last load, reload it
await this.loadFromFile();
}
return;
}
}
await this.downloadFile();
await this.loadFromFile();
} finally {
this.syncing = false;
}
};
public getFromAnidbId = (anidbId: number): AnidbItem | undefined => {
return this.mapping[anidbId];
};
public getSpecialEpisode = (
tvdbId: number,
episode: number
): AnidbItem | undefined => {
const episodes = this.specials[tvdbId];
return episodes ? episodes[episode] : undefined;
};
}
const animeList = new AnimeListMapping();
export default animeList;

View File

@@ -1,102 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
}
class ExternalAPI {
protected axios: AxiosInstance;
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
}
export default ExternalAPI;

View File

@@ -1,5 +1,5 @@
import NodePlexAPI from 'plex-api';
import { getSettings, PlexSettings } from '../lib/settings';
import { getSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
@@ -9,8 +9,6 @@ export interface PlexLibraryItem {
guid: string;
parentGuid?: string;
grandparentGuid?: string;
addedAt: number;
updatedAt: number;
type: 'movie' | 'show' | 'season' | 'episode';
}
@@ -50,25 +48,6 @@ export interface PlexMetadata {
parentIndex?: number;
leafCount: number;
viewedLeafCount: number;
addedAt: number;
updatedAt: number;
Media: Media[];
}
interface Media {
id: number;
duration: number;
bitrate: number;
width: number;
height: number;
aspectRatio: number;
audioChannels: number;
audioCodec: string;
videoCodec: string;
videoResolution: string;
container: string;
videoFrameRate: string;
videoProfile: string;
}
interface PlexMetadataResponse {
@@ -80,26 +59,13 @@ interface PlexMetadataResponse {
class PlexAPI {
private plexClient: NodePlexAPI;
constructor({
plexToken,
plexSettings,
timeout,
}: {
plexToken?: string;
plexSettings?: PlexSettings;
timeout?: number;
}) {
constructor({ plexToken }: { plexToken?: string }) {
const settings = getSettings();
let settingsPlex: PlexSettings | undefined;
plexSettings
? (settingsPlex = plexSettings)
: (settingsPlex = getSettings().plex);
this.plexClient = new NodePlexAPI({
hostname: settingsPlex.ip,
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
hostname: settings.plex.ip,
port: settings.plex.port,
https: settings.plex.useSsl,
token: plexToken,
authenticator: {
authenticate: (
@@ -118,13 +84,12 @@ class PlexAPI {
options: {
identifier: settings.clientId,
product: 'Overseerr',
deviceName: settings.main.applicationTitle,
deviceName: 'Overseerr',
platform: 'Overseerr',
},
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public async getStatus() {
return await this.plexClient.query('/');
}
@@ -158,14 +123,6 @@ class PlexAPI {
return response.MediaContainer.Metadata[0];
}
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}/children`
);
return response.MediaContainer.Metadata;
}
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>(
`/library/sections/${id}/recentlyAdded`

View File

@@ -1,6 +1,5 @@
import axios, { AxiosInstance } from 'axios';
import xml2js from 'xml2js';
import { PlexDevice } from '../interfaces/api/plexInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
@@ -30,45 +29,6 @@ interface PlexUser {
entitlements: string[];
}
interface ConnectionResponse {
$: {
protocol: string;
address: string;
port: string;
uri: string;
local: string;
};
}
interface DeviceResponse {
$: {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: string;
lastSeenAt: string;
provides: string;
owned: string;
accessToken?: string;
publicAddress?: string;
httpsRequired?: string;
synced?: string;
relay?: string;
dnsRebindingProtection?: string;
natLoopbackSupported?: string;
publicAddressMatches?: string;
presence?: string;
ownerID?: string;
home?: string;
sourceTitle?: string;
};
Connection: ConnectionResponse[];
}
interface ServerResponse {
$: {
id: string;
@@ -127,62 +87,6 @@ class PlexTvAPI {
});
}
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
product: pxml.$.product,
productVersion: pxml.$.productVersion,
platform: pxml.$?.platform,
platformVersion: pxml.$?.platformVersion,
device: pxml.$?.device,
clientIdentifier: pxml.$.clientIdentifier,
createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000),
lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000),
provides: pxml.$.provides.split(','),
owned: pxml.$.owned == '1' ? true : false,
accessToken: pxml.$?.accessToken,
publicAddress: pxml.$?.publicAddress,
publicAddressMatches:
pxml.$?.publicAddressMatches == '1' ? true : false,
httpsRequired: pxml.$?.httpsRequired == '1' ? true : false,
synced: pxml.$?.synced == '1' ? true : false,
relay: pxml.$?.relay == '1' ? true : false,
dnsRebindingProtection:
pxml.$?.dnsRebindingProtection == '1' ? true : false,
natLoopbackSupported:
pxml.$?.natLoopbackSupported == '1' ? true : false,
presence: pxml.$?.presence == '1' ? true : false,
ownerID: pxml.$?.ownerID,
home: pxml.$?.home == '1' ? true : false,
sourceTitle: pxml.$?.sourceTitle,
connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({
protocol: conn.$.protocol,
address: conn.$.address,
port: parseInt(conn.$.port, 10),
uri: conn.$.uri,
local: conn.$.local == '1' ? true : false,
})),
}));
} catch (e) {
logger.error('Something went wrong getting the devices from plex.tv', {
label: 'Plex.tv API',
errorMessage: e.message,
});
throw new Error('Invalid auth token');
}
}
public async getUser(): Promise<PlexUser> {
try {
const account = await this.axios.get<PlexAccountResponse>(
@@ -192,7 +96,7 @@ class PlexTvAPI {
return account.data.user;
} catch (e) {
logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`,
`Something went wrong getting the account from plex.tv: ${e.message}`,
{ label: 'Plex.tv API' }
);
throw new Error('Invalid auth token');
@@ -212,7 +116,7 @@ class PlexTvAPI {
return parsedXml;
}
public async checkUserAccess(userId: number): Promise<boolean> {
public async checkUserAccess(authUser: PlexUser): Promise<boolean> {
const settings = getSettings();
try {
@@ -224,11 +128,11 @@ class PlexTvAPI {
const users = friends.MediaContainer.User;
const user = users.find((u) => Number(u.$.id) === userId);
const user = users.find((u) => Number(u.$.id) === authUser.id);
if (!user) {
throw new Error(
"This user does not exist on the main Plex account's shared list"
'This user does not exist on the main plex accounts shared list'
);
}

View File

@@ -1,7 +1,5 @@
import cacheManager from '../lib/cache';
import { RadarrSettings } from '../lib/settings';
import Axios, { AxiosInstance } from 'axios';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface RadarrMovieOptions {
title: string;
@@ -15,13 +13,12 @@ interface RadarrMovieOptions {
searchNow?: boolean;
}
export interface RadarrMovie {
interface RadarrMovie {
id: number;
title: string;
isAvailable: boolean;
monitored: boolean;
tmdbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
@@ -32,7 +29,7 @@ export interface RadarrMovie {
hasFile: boolean;
}
export interface RadarrRootFolder {
interface RadarrRootFolder {
id: number;
path: string;
freeSpace: number;
@@ -43,54 +40,20 @@ export interface RadarrRootFolder {
}[];
}
export interface RadarrProfile {
interface RadarrProfile {
id: number;
name: string;
}
interface QueueItem {
movieId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
class RadarrAPI extends ExternalAPI {
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
}
class RadarrAPI {
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super(
url,
{
this.axios = Axios.create({
baseURL: url,
params: {
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('radarr').data,
}
);
});
}
public getMovies = async (): Promise<RadarrMovie[]> => {
@@ -113,89 +76,8 @@ class RadarrAPI extends ExternalAPI {
}
};
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
});
if (!response.data[0]) {
throw new Error('Movie not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDb ID', {
label: 'Radarr API',
message: e.message,
});
throw new Error('Movie not found');
}
}
public addMovie = async (
options: RadarrMovieOptions
): Promise<RadarrMovie> => {
try {
const movie = await this.getMovieByTmdbId(options.tmdbId);
if (movie.downloaded) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Radarr',
}
);
return movie;
}
// movie exists in radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
titleSlug: options.tmdbId.toString(),
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
searchForMovie: options.searchNow,
},
});
if (response.data.monitored) {
logger.info(
'Found existing title in Radarr and set it to monitored. Returning success',
{ label: 'Radarr' }
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: response.data,
});
return response.data;
} else {
logger.error('Failed to update existing movie in Radarr', {
label: 'Radarr',
options,
});
throw new Error('Failed to update existing movie in Radarr');
}
}
if (movie.id) {
logger.info(
'Movie is already monitored in Radarr. Skipping add and returning success',
{ label: 'Radarr' }
);
return movie;
}
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
@@ -222,9 +104,7 @@ class RadarrAPI extends ExternalAPI {
label: 'Radarr',
options,
});
throw new Error('Failed to add movie to Radarr');
}
return response.data;
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
@@ -232,22 +112,16 @@ class RadarrAPI extends ExternalAPI {
label: 'Radarr',
errorMessage: e.message,
options,
response: e?.response?.data,
}
);
throw new Error('Failed to add movie to Radarr');
}
};
public getProfiles = async (): Promise<RadarrProfile[]> => {
try {
const data = await this.getRolling<RadarrProfile[]>(
`/qualityProfile`,
undefined,
3600
);
const response = await this.axios.get<RadarrProfile[]>(`/profile`);
return data;
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
@@ -255,27 +129,13 @@ class RadarrAPI extends ExternalAPI {
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
try {
const data = await this.getRolling<RadarrRootFolder[]>(
`/rootfolder`,
undefined,
3600
);
const response = await this.axios.get<RadarrRootFolder[]>(`/rootfolder`);
return data;
return response.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;

View File

@@ -1,5 +1,4 @@
import cacheManager from '../lib/cache';
import ExternalAPI from './externalapi';
import axios, { AxiosInstance } from 'axios';
interface RTMovieOldSearchResult {
id: number;
@@ -56,19 +55,17 @@ export interface RTRating {
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes extends ExternalAPI {
class RottenTomatoes {
private axios: AxiosInstance;
constructor() {
super(
'https://www.rottentomatoes.com/api/private',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('rt').data,
}
);
this.axios = axios.create({
baseURL: 'https://www.rottentomatoes.com/api/private',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
/**
@@ -88,31 +85,14 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMovieSearchResponse>('/v1.0/movies', {
params: { q: name },
});
// First, attempt to match exact name and year
let movie = data.movies.find(
(movie) => movie.year === year && movie.title === name
const response = await this.axios.get<RTMovieSearchResponse>(
'/v1.0/movies',
{
params: { q: name },
}
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = data.movies.find(
(movie) => movie.year === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = data.movies.find((movie) => movie.year === year);
}
// One last try, try exact name match only
if (!movie) {
movie = data.movies.find((movie) => movie.title === name);
}
const movie = response.data.movies.find((movie) => movie.year === year);
if (!movie) {
return null;
@@ -139,14 +119,19 @@ class RottenTomatoes extends ExternalAPI {
year?: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
});
const response = await this.axios.get<RTMultiSearchResponse>(
'/v2.0/search/',
{
params: { q: name, limit: 10 },
}
);
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
if (year) {
tvshow = data.tvSeries.find((series) => series.startYear === year);
tvshow = response.data.tvSeries.find(
(series) => series.startYear === year
);
}
if (!tvshow) {

View File

@@ -1,19 +1,9 @@
import cacheManager from '../lib/cache';
import { SonarrSettings } from '../lib/settings';
import Axios, { AxiosInstance } from 'axios';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
previousAiring?: string;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
percentOfEpisodes: number;
};
}
export interface SonarrSeries {
@@ -65,33 +55,6 @@ export interface SonarrSeries {
};
}
interface QueueItem {
seriesId: number;
episodeId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
interface SonarrProfile {
id: number;
name: string;
@@ -112,7 +75,6 @@ interface AddSeriesOptions {
tvdbid: number;
title: string;
profileId: number;
languageProfileId?: number;
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
@@ -121,60 +83,15 @@ interface AddSeriesOptions {
searchNow?: boolean;
}
export interface LanguageProfile {
id: number;
name: string;
}
class SonarrAPI extends ExternalAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
class SonarrAPI {
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super(
url,
{
this.axios = Axios.create({
baseURL: url,
params: {
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('sonarr').data,
}
);
}
public async getSeries(): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series');
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
});
if (!response.data[0]) {
throw new Error('No series found');
}
return response.data;
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('No series found');
}
});
}
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
@@ -207,6 +124,11 @@ class SonarrAPI extends ExternalAPI {
if (series.id) {
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
series.addOptions = {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
};
const newSeriesResponse = await this.axios.put<SonarrSeries>(
'/series',
series
@@ -216,19 +138,15 @@ class SonarrAPI extends ExternalAPI {
logger.info('Sonarr accepted request. Updated existing series', {
label: 'Sonarr',
});
logger.debug('Sonarr update details', {
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
} else {
logger.error('Failed to update series in Sonarr', {
logger.error('Failed to add movie to Sonarr', {
label: 'Sonarr',
options,
});
throw new Error('Failed to update series in Sonarr');
}
return newSeriesResponse.data;
@@ -239,8 +157,7 @@ class SonarrAPI extends ExternalAPI {
{
tvdbId: options.tvdbid,
title: options.title,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
profileId: options.profileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
@@ -271,16 +188,14 @@ class SonarrAPI extends ExternalAPI {
label: 'Sonarr',
options,
});
throw new Error('Failed to add series to Sonarr');
}
return createdSeriesResponse.data;
} catch (e) {
logger.error('Something went wrong while adding a series to Sonarr.', {
logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API',
errorMessage: e.message,
error: e,
response: e?.response?.data,
});
throw new Error('Failed to add series');
}
@@ -288,15 +203,11 @@ class SonarrAPI extends ExternalAPI {
public async getProfiles(): Promise<SonarrProfile[]> {
try {
const data = await this.getRolling<SonarrProfile[]>(
'/qualityProfile',
undefined,
3600
);
const response = await this.axios.get<SonarrProfile[]>('/profile');
return data;
return response.data;
} catch (e) {
logger.error('Something went wrong while retrieving Sonarr profiles.', {
logger.error('Something went wrong retrieving Sonarr profiles', {
label: 'Sonarr API',
message: e.message,
});
@@ -306,72 +217,15 @@ class SonarrAPI extends ExternalAPI {
public async getRootFolders(): Promise<SonarrRootFolder[]> {
try {
const data = await this.getRolling<SonarrRootFolder[]>(
'/rootfolder',
undefined,
3600
);
const response = await this.axios.get<SonarrRootFolder[]>('/rootfolder');
return data;
return response.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[]>(
'/languageprofile',
undefined,
3600
);
return data;
} catch (e) {
logger.error(
'Something went wrong while retrieving Sonarr language profiles.',
{
label: 'Sonarr API',
message: e.message,
}
);
throw new Error('Failed to get language profiles');
}
}
public async searchSeries(seriesId: number): Promise<void> {
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.axios.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
logger.error('Something went wrong attempting to run a Sonarr command.', {
logger.error('Something went wrong retrieving Sonarr root folders', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('Failed to run Sonarr command.');
throw new Error('Failed to get root folders');
}
}
@@ -399,16 +253,6 @@ class SonarrAPI extends ExternalAPI {
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;

871
server/api/themoviedb.ts Normal file
View File

@@ -0,0 +1,871 @@
import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: {
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: {
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
class TheMovieDb {
private apiKey = 'db55323b8d3e4154498498a75642b381';
private axios: AxiosInstance;
constructor() {
this.axios = axios.create({
baseURL: 'https://api.themoviedb.org/3',
params: {
api_key: this.apiKey,
},
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en-US',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return response.data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const response = await this.axios.get<TmdbPersonDetail>(
`/person/${personId}`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en-US',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const response = await this.axios.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en-US',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: { language, append_to_response: 'credits,external_ids' },
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en-US',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: {
language,
append_to_response: 'credits,external_ids,keywords',
},
});
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const response = await this.axios.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en-US',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en-US',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/tv/${tvId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en-US',
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
'/discover/movie',
{
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en-US',
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
'/discover/tv',
{
params: {
sort_by: sortBy,
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en-US',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const response = await this.axios.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en-US',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const response = await this.axios.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const response = await this.axios.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en-US',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const response = await this.axios.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en-US',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[TMDB] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en-US',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
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 by external tvdb ID: ${e.message}`
);
}
}
}
export default TheMovieDb;

View File

@@ -1 +0,0 @@
export const ANIME_KEYWORD_ID = 210024;

View File

@@ -1,753 +0,0 @@
import { sortBy } from 'lodash';
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import {
TmdbCollection,
TmdbExternalIdResponse,
TmdbGenre,
TmdbGenresResult,
TmdbLanguage,
TmdbMovieDetails,
TmdbNetwork,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
TmdbRegion,
TmdbSearchMovieResponse,
TmdbSearchMultiResponse,
TmdbSearchTvResponse,
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbProductionCompany,
} from './interfaces';
interface SearchOptions {
query: string;
page?: number;
includeAdult?: boolean;
language?: string;
}
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
originalLanguage?: string;
genre?: number;
studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
page?: number;
language?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: number;
network?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
class TheMovieDb extends ExternalAPI {
private region?: string;
private originalLanguage?: string;
constructor({
region,
originalLanguage,
}: { region?: string; originalLanguage?: string } = {}) {
super(
'https://api.themoviedb.org/3',
{
api_key: 'db55323b8d3e4154498498a75642b381',
},
{
nodeCache: cacheManager.getCache('tmdb').data,
}
);
this.region = region;
this.originalLanguage = originalLanguage;
}
public searchMulti = async ({
query,
page = 1,
includeAdult = false,
language = 'en',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
});
return data;
} catch (e) {
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};
public getPerson = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
try {
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
params: { language },
});
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
}
};
public getPersonCombinedCredits = async ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonCombinedCredits> => {
try {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
}
);
return data;
} catch (e) {
throw new Error(
`[TMDb] Failed to fetch person combined credits: ${e.message}`
);
}
};
public getMovie = async ({
movieId,
language = 'en',
}: {
movieId: number;
language?: string;
}): Promise<TmdbMovieDetails> => {
try {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response: 'credits,external_ids,videos,release_dates',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
}
};
public getTvShow = async ({
tvId,
language = 'en',
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> => {
try {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings',
},
},
43200
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
public getTvSeason = async ({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> => {
try {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
}
);
return data;
} catch (e) {
throw new Error(
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
);
}
}
public async getTvSimilar({
tvId,
page = 1,
language = 'en',
}: {
tvId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: {
page,
language,
},
});
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en',
primaryReleaseDateGte,
primaryReleaseDateLte,
originalLanguage,
genre,
studio,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
region: this.region,
with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
},
});
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
};
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en-US',
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
originalLanguage,
genre,
network,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
region: this.region,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
},
});
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
}
};
public getUpcomingMovies = async ({
page = 1,
language = 'en',
}: {
page: number;
language: string;
}): Promise<TmdbUpcomingMoviesResponse> => {
try {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
region: this.region,
originalLanguage: this.originalLanguage,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
}
};
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en',
}: {
page?: number;
timeWindow?: 'day' | 'week';
language?: string;
} = {}): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
region: this.region,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
public getMovieTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
public getTvTrending = async ({
page = 1,
timeWindow = 'day',
}: {
page?: number;
timeWindow?: 'day' | 'week';
} = {}): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
public async getByExternalId({
externalId,
type,
language = 'en',
}:
| {
externalId: string;
type: 'imdb';
language?: string;
}
| {
externalId: number;
type: 'tvdb';
language?: string;
}): Promise<TmdbExternalIdResponse> {
try {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
}
}
public async getMovieByImdbId({
imdbId,
language = 'en',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
type: 'imdb',
});
if (extResponse.movie_results[0]) {
const movie = await this.getMovie({
movieId: extResponse.movie_results[0].id,
language,
});
return movie;
}
throw new Error(
'[TMDb] Failed to find a title with the provided IMDB id'
);
} catch (e) {
throw new Error(
`[TMDb] Failed to get movie by external imdb ID: ${e.message}`
);
}
}
public async getShowByTvdbId({
tvdbId,
language = 'en',
}: {
tvdbId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
const tvshow = await this.getTvShow({
tvId: extResponse.tv_results[0].id,
language,
});
return tvshow;
}
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}`
);
}
}
public async getCollection({
collectionId,
language = 'en',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
}
}
public async getRegions(): Promise<TmdbRegion[]> {
try {
const data = await this.get<TmdbRegion[]>(
'/configuration/countries',
{},
86400 // 24 hours
);
const regions = sortBy(data, 'english_name');
return regions;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
}
}
public async getLanguages(): Promise<TmdbLanguage[]> {
try {
const data = await this.get<TmdbLanguage[]>(
'/configuration/languages',
{},
86400 // 24 hours
);
const languages = sortBy(data, 'english_name');
return languages;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
}
}
public async getStudio(studioId: number): Promise<TmdbProductionCompany> {
try {
const data = await this.get<TmdbProductionCompany>(
`/company/${studioId}`
);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
}
}
public async getNetwork(networkId: number): Promise<TmdbNetwork> {
try {
const data = await this.get<TmdbNetwork>(`/network/${networkId}`);
return data;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
}
}
public async getMovieGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try {
const data = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language,
},
},
86400 // 24 hours
);
const movieGenres = sortBy(data.genres, 'name');
return movieGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
}
}
public async getTvGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try {
const data = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language,
},
},
86400 // 24 hours
);
const tvGenres = sortBy(data.genres, 'name');
return tvGenres;
} catch (e) {
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
}
}
}
export default TheMovieDb;

View File

@@ -1,401 +0,0 @@
interface TmdbMediaResult {
id: number;
media_type: string;
popularity: number;
poster_path?: string;
backdrop_path?: string;
vote_count: number;
vote_average: number;
genre_ids: number[];
overview: string;
original_language: string;
}
export interface TmdbMovieResult extends TmdbMediaResult {
media_type: 'movie';
title: string;
original_title: string;
release_date: string;
adult: boolean;
video: boolean;
}
export interface TmdbTvResult extends TmdbMediaResult {
media_type: 'tv';
name: string;
original_name: string;
origin_country: string[];
first_air_date: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
popularity: number;
profile_path?: string;
adult: boolean;
media_type: 'person';
known_for: (TmdbMovieResult | TmdbTvResult)[];
}
interface TmdbPaginatedResponse {
page: number;
total_results: number;
total_pages: number;
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
results: TmdbMovieResult[];
}
export interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
results: TmdbTvResult[];
}
export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
dates: {
maximum: string;
minimum: string;
};
results: TmdbMovieResult[];
}
export interface TmdbExternalIdResponse {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
}
export interface TmdbCreditCast {
cast_id: number;
character: string;
credit_id: string;
gender?: number;
id: number;
name: string;
order: number;
profile_path?: string;
}
export interface TmdbAggregateCreditCast extends TmdbCreditCast {
roles: {
credit_id: string;
character: string;
episode_count: number;
}[];
}
export interface TmdbCreditCrew {
credit_id: string;
gender?: number;
id: number;
name: string;
profile_path?: string;
job: string;
department: string;
}
export interface TmdbExternalIds {
imdb_id?: string;
freebase_mid?: string;
freebase_id?: string;
tvdb_id?: number;
tvrage_id?: string;
facebook_id?: string;
instagram_id?: string;
twitter_id?: string;
}
export interface TmdbProductionCompany {
id: number;
logo_path?: string;
name: string;
origin_country: string;
homepage?: string;
headquarters?: string;
description?: string;
}
export interface TmdbMovieDetails {
id: number;
imdb_id?: string;
adult: boolean;
backdrop_path?: string;
poster_path?: string;
budget: number;
genres: {
id: number;
name: string;
}[];
homepage?: string;
original_language: string;
original_title: string;
overview?: string;
popularity: number;
production_companies: TmdbProductionCompany[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
release_date: string;
release_dates: TmdbMovieReleaseResult;
revenue: number;
runtime?: number;
spoken_languages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
credits: {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
episode_number: number;
name: string;
overview: string;
production_code: string;
season_number: number;
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
}
export interface TmdbTvSeasonResult {
id: number;
air_date: string;
episode_count: number;
name: string;
overview: string;
poster_path?: string;
season_number: number;
}
export interface TmdbTvDetails {
id: number;
backdrop_path?: string;
content_ratings: TmdbTvRatingResult;
created_by: {
id: number;
credit_id: string;
name: string;
gender: number;
profile_path?: string;
}[];
episode_run_time: number[];
first_air_date: string;
genres: {
id: number;
name: string;
}[];
homepage: string;
in_production: boolean;
languages: string[];
last_air_date: string;
last_episode_to_air?: TmdbTvEpisodeResult;
name: string;
next_episode_to_air?: TmdbTvEpisodeResult;
networks: TmdbNetwork[];
number_of_episodes: number;
number_of_seasons: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path?: string;
production_companies: {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[];
status: string;
type: string;
vote_average: number;
vote_count: number;
aggregate_credits: {
cast: TmdbAggregateCreditCast[];
};
credits: {
crew: TmdbCreditCrew[];
};
external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbTvRatingResult {
results: TmdbRating[];
}
export interface TmdbRating {
iso_3166_1: string;
rating: string;
}
export interface TmdbMovieReleaseResult {
results: TmdbRelease[];
}
export interface TmdbRelease extends TmdbRating {
release_dates: {
certification: string;
iso_639_1?: string;
note?: string;
release_date: string;
type: number;
}[];
}
export interface TmdbKeyword {
id: number;
name: string;
}
export interface TmdbPersonDetail {
id: number;
name: string;
deathday: string;
known_for_department: string;
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
place_of_birth?: string;
profile_path?: string;
adult: boolean;
imdb_id?: string;
homepage?: string;
}
export interface TmdbPersonCredit {
id: number;
original_language: string;
episode_count: number;
overview: string;
origin_country: string[];
original_name: string;
vote_count: number;
name: string;
media_type?: string;
popularity: number;
credit_id: string;
backdrop_path?: string;
first_air_date: string;
vote_average: number;
genre_ids?: number[];
poster_path?: string;
original_title: string;
video?: boolean;
title: string;
adult: boolean;
release_date: string;
}
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
character: string;
}
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
department: string;
job: string;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: TmdbPersonCreditCast[];
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}
export interface TmdbRegion {
iso_3166_1: string;
english_name: string;
}
export interface TmdbLanguage {
iso_639_1: string;
english_name: string;
name: string;
}
export interface TmdbGenresResult {
genres: TmdbGenre[];
}
export interface TmdbGenre {
id: number;
name: string;
}
export interface TmdbNetwork {
id: number;
name: string;
headquarters?: string;
homepage?: string;
logo_path?: string;
origin_country?: string;
}

View File

@@ -2,6 +2,7 @@ export enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
AVAILABLE,
}
export enum MediaType {

View File

@@ -1,4 +0,0 @@
export enum UserType {
PLEX = 1,
LOCAL = 2,
}

View File

@@ -8,16 +8,11 @@ import {
UpdateDateColumn,
getRepository,
In,
AfterLoad,
} from 'typeorm';
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 {
@@ -45,15 +40,12 @@ class Media {
}
}
public static async getMedia(
id: number,
mediaType: MediaType
): Promise<Media | undefined> {
public static async getMedia(id: number): Promise<Media | undefined> {
const mediaRepository = getRepository(Media);
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
where: { tmdbId: id },
relations: ['requests'],
});
@@ -70,7 +62,7 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column()
@Column({ unique: true })
@Index()
public tmdbId: number;
@@ -78,16 +70,13 @@ class Media {
@Index()
public tvdbId?: number;
@Column({ nullable: true })
@Column({ unique: true, nullable: true })
@Index()
public imdbId?: string;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
public requests: MediaRequest[];
@@ -106,173 +95,9 @@ class Media {
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date;
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@AfterLoad()
public setPlexUrls(): void {
const machineId = getSettings().plex.machineId;
if (this.ratingKey) {
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
}
if (this.ratingKey4k) {
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
}
}
@AfterLoad()
public setServiceUrl(): void {
if (this.mediaType === MediaType.MOVIE) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
}
}
}
if (this.mediaType === MediaType.TV) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug4k}`
);
}
}
}
}
@AfterLoad()
public getDownloadingItem(): void {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
}
}
export default Media;

View File

@@ -15,8 +15,7 @@ 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 TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
@@ -66,33 +65,12 @@ export class MediaRequest {
})
public seasons: SeasonRequest[];
@Column({ default: false })
public is4k: boolean;
@Column({ nullable: true })
public serverId: number;
@Column({ nullable: true })
public profileId: number;
@Column({ nullable: true })
public rootFolder: string;
@Column({ nullable: true })
public languageProfileId: number;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
}
@AfterInsert()
public async notifyNewRequest(): Promise<void> {
private async _notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
@@ -111,7 +89,6 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
request: this,
});
}
@@ -131,7 +108,6 @@ export class MediaRequest {
.join(', '),
},
],
request: this,
});
}
}
@@ -144,11 +120,8 @@ export class MediaRequest {
* auto approved content
*/
@AfterUpdate()
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
if (
this.status === MediaRequestStatus.APPROVED ||
this.status === MediaRequestStatus.DECLINED
) {
private async _notifyApproved() {
if (this.status === MediaRequestStatus.APPROVED) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
@@ -157,72 +130,40 @@ export class MediaRequest {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
logger.warn(
'Media became available before request was approved. Approval notification will be skipped.',
{ label: 'Media Request' }
);
return;
}
const tmdb = new TheMovieDb();
if (this.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
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,
}
);
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
});
} else if (this.media.mediaType === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(
this.status === MediaRequestStatus.APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: this,
}
);
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
}
}
@AfterInsert()
public async autoapprovalNotification(): Promise<void> {
if (this.status === MediaRequestStatus.APPROVED) {
this.notifyApprovedOrDeclined(true);
}
}
@AfterUpdate()
@AfterInsert()
public async updateParentStatus(): Promise<void> {
private async _updateParentStatus() {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
@@ -233,30 +174,16 @@ export class MediaRequest {
return;
}
const seasonRequestRepository = getRepository(SeasonRequest);
if (
this.status === MediaRequestStatus.APPROVED &&
// Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
) {
if (this.is4k) {
media.status4k = MediaStatus.PROCESSING;
} else {
media.status = MediaStatus.PROCESSING;
}
if (this.status === MediaRequestStatus.APPROVED) {
media.status = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
if (
media.mediaType === MediaType.MOVIE &&
this.media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
if (this.is4k) {
media.status4k = MediaStatus.UNKNOWN;
} else {
media.status = MediaStatus.UNKNOWN;
}
media.status = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
@@ -291,37 +218,25 @@ export class MediaRequest {
}
@AfterRemove()
public async handleRemoveParentUpdate(): Promise<void> {
private async _handleRemoveParentUpdate() {
const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id },
relations: ['requests'],
});
if (
!fullMedia.requests.some((request) => !request.is4k) &&
fullMedia.status !== MediaStatus.AVAILABLE
) {
if (!fullMedia.requests || fullMedia.requests.length === 0) {
fullMedia.status = MediaStatus.UNKNOWN;
mediaRepository.save(fullMedia);
}
if (
!fullMedia.requests.some((request) => request.is4k) &&
fullMedia.status4k !== MediaStatus.AVAILABLE
) {
fullMedia.status4k = MediaStatus.UNKNOWN;
}
mediaRepository.save(fullMedia);
}
public async sendToRadarr(): Promise<void> {
@AfterUpdate()
@AfterInsert()
private async _sendToRadarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MOVIE
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
@@ -331,146 +246,51 @@ export class MediaRequest {
return;
}
let radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && radarr.is4k === this.is4k
const radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && !radarr.is4k
);
if (
this.serverId !== null &&
this.serverId >= 0 &&
radarrSettings?.id !== this.serverId
) {
radarrSettings = settings.radarr.find(
(radarr) => radarr.id === this.serverId
);
logger.info(
`Request has an override server: ${radarrSettings?.name}`,
{ label: 'Media Request' }
);
}
if (!radarrSettings) {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
}radarr configured. Did you set any of your Radarr servers as default?`,
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
{ label: 'Media Request' }
);
return;
}
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== radarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
});
}
if (
this.profileId &&
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile id: ${qualityProfile}`, {
label: 'Media Request',
});
}
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media not present');
return;
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
}
// Run this asynchronously so we don't wait for it on the UI side
radarr
.addMovie({
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
minimumAvailability: radarrSettings.minimumAvailability,
title: movie.title,
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: this,
});
});
radarr.addMovie({
profileId: radarrSettings.activeProfileId,
qualityProfileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory,
minimumAvailability: radarrSettings.minimumAvailability,
title: movie.title,
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
throw new Error(
`[MediaRequest] Request failed to send to radarr: ${e.message}`
);
}
}
}
public async sendToSonarr(): Promise<void> {
@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.TV
@@ -486,29 +306,13 @@ export class MediaRequest {
return;
}
let sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k
const sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && !sonarr.is4k
);
if (
this.serverId !== null &&
this.serverId >= 0 &&
sonarrSettings?.id !== this.serverId
) {
sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.id === this.serverId
);
logger.info(
`Request has an override server: ${sonarrSettings?.name}`,
{ label: 'Media Request' }
);
}
if (!sonarrSettings) {
logger.info(
`There is no default ${
this.is4k ? '4K ' : ''
}sonarr configured. Did you set any of your Sonarr servers as default?`,
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
{ label: 'Media Request' }
);
return;
@@ -523,24 +327,16 @@ export class MediaRequest {
throw new Error('Media data is missing');
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
}
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
if (!series.external_ids.tvdb_id) {
throw new Error('Series was missing tvdb id');
}
@@ -555,121 +351,29 @@ export class MediaRequest {
seriesType = 'anime';
}
let rootFolder =
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory;
let qualityProfile =
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId;
let languageProfile =
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== rootFolder
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
});
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
label: 'Media Request',
});
}
if (
this.languageProfileId &&
this.languageProfileId !== languageProfile
) {
languageProfile = this.languageProfileId;
logger.info(
`Request has an override Language Profile: ${languageProfile}`,
{
label: 'Media Request',
}
);
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
profileId: qualityProfile,
languageProfileId: languageProfile,
rootFolderPath: rootFolder,
title: series.name,
tvdbid: tvdbId,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})
.then(async (sonarrSeries) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: this,
});
});
sonarr.addSeries({
profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
throw new Error(
`[MediaRequest] Request failed to send to sonarr: ${e.message}`
);
}
}
}

View File

@@ -20,9 +20,6 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
public media: Promise<Media>;

View File

@@ -6,37 +6,17 @@ import {
UpdateDateColumn,
OneToMany,
RelationCount,
AfterLoad,
OneToOne,
} from 'typeorm';
import {
Permission,
hasPermission,
PermissionCheckOptions,
} from '../lib/permissions';
import { Permission, hasPermission } from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
import bcrypt from 'bcrypt';
import path from 'path';
import PreparedEmail from '../lib/email';
import logger from '../logger';
import { getSettings } from '../lib/settings';
import { default as generatePassword } from 'secure-random-password';
import { UserType } from '../constants/user';
import { v4 as uuid } from 'uuid';
import { UserSettings } from './UserSettings';
@Entity()
export class User {
public static filterMany(
users: User[],
showFiltered?: boolean
): Partial<User>[] {
return users.map((u) => u.filter(showFiltered));
public static filterMany(users: User[]): Partial<User>[] {
return users.map((u) => u.filter());
}
static readonly filteredFields: string[] = ['email'];
public displayName: string;
static readonly filteredFields: string[] = ['plexToken'];
@PrimaryGeneratedColumn()
public id: number;
@@ -44,26 +24,11 @@ export class User {
@Column({ unique: true })
public email: string;
@Column({ nullable: true })
public plexUsername: string;
@Column()
public username: string;
@Column({ nullable: true })
public username?: string;
@Column({ nullable: true, select: false })
public password?: string;
@Column({ nullable: true, select: false })
public resetPasswordGuid?: string;
@Column({ type: 'date', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true, select: false })
public plexId?: number;
@Column({ select: false })
public plexId: number;
@Column({ nullable: true, select: false })
public plexToken?: string;
@@ -80,13 +45,6 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[];
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
onDelete: 'CASCADE',
})
public settings?: UserSettings;
@CreateDateColumn()
public createdAt: Date;
@@ -97,106 +55,18 @@ export class User {
Object.assign(this, init);
}
public filter(showFiltered?: boolean): Partial<User> {
public filter(): Partial<User> {
const filtered: Partial<User> = Object.assign(
{},
...(Object.keys(this) as (keyof User)[])
.filter((k) => showFiltered || !User.filteredFields.includes(k))
.filter((k) => !User.filteredFields.includes(k))
.map((k) => ({ [k]: this[k] }))
);
return filtered;
}
public hasPermission(
permissions: Permission | Permission[],
options?: PermissionCheckOptions
): boolean {
return !!hasPermission(permissions, this.permissions, options);
}
public passwordMatch(password: string): Promise<boolean> {
return new Promise((resolve) => {
if (this.password) {
resolve(bcrypt.compare(password, this.password));
} else {
return resolve(false);
}
});
}
public async setPassword(password: string): Promise<void> {
const hashedPassword = await bcrypt.hash(password, 12);
this.password = hashedPassword;
}
public async generatePassword(): Promise<void> {
const password = generatePassword.randomPassword({ length: 16 });
this.setPassword(password);
const { applicationTitle, applicationUrl } = getSettings().main;
try {
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
to: this.email,
},
locals: {
password: password,
applicationUrl,
applicationTitle,
},
});
} catch (e) {
logger.error('Failed to send out generated password email', {
label: 'User Management',
message: e.message,
});
}
}
public async resetPassword(): Promise<void> {
const guid = uuid();
this.resetPasswordGuid = guid;
// 24 hours into the future
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
this.recoveryLinkExpirationDate = targetDate;
const { applicationTitle, applicationUrl } = getSettings().main;
const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`;
try {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {
to: this.email,
},
locals: {
resetPasswordLink,
applicationUrl: resetPasswordLink,
applicationTitle,
},
});
} catch (e) {
logger.error('Failed to send out reset password email', {
label: 'User Management',
message: e.message,
});
}
}
@AfterLoad()
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername;
public hasPermission(permissions: Permission | Permission[]): boolean {
return !!hasPermission(permissions, this.permissions);
}
}

View File

@@ -1,40 +0,0 @@
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './User';
@Entity()
export class UserSettings {
constructor(init?: Partial<UserSettings>) {
Object.assign(this, init);
}
@PrimaryGeneratedColumn()
public id: number;
@OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' })
@JoinColumn()
public user: User;
@Column({ default: true })
public enableNotifications: boolean;
@Column({ nullable: true })
public discordId?: string;
@Column({ nullable: true })
public telegramChatId?: string;
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
}

View File

@@ -5,7 +5,6 @@ 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';
@@ -18,13 +17,7 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import TelegramAgent from './lib/notifications/agents/telegram';
import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
import PushoverAgent from './lib/notifications/agents/pushover';
import WebhookAgent from './lib/notifications/agents/webhook';
import { getClientIp } from '@supercharge/request-ip';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -49,61 +42,17 @@ app
const settings = getSettings().load();
// Register Notification Agents
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
new PushbulletAgent(),
new PushoverAgent(),
new SlackAgent(),
new TelegramAgent(),
new WebhookAgent(),
]);
notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]);
// Start Jobs
startJobs();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
}
server.use(cookieParser());
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.use((req, res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {
req.ip = getClientIp(req) ?? '';
}
} catch (e) {
logger.error('Failed to attach the ip to the request', {
label: 'Middleware',
message: e.message,
});
} finally {
next();
}
});
if (settings.main.csrfProtection) {
server.use(
csurf({
cookie: {
httpOnly: true,
sameSite: true,
secure: !dev,
},
})
);
server.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
sameSite: true,
secure: !dev,
});
next();
});
}
// Set up sessions
// Setup sessions
const sessionRespository = getRepository(Session);
server.use(
'/api',

View File

@@ -1,45 +0,0 @@
import { PlexSettings } from '../../lib/settings';
export interface PlexStatus {
settings: PlexSettings;
status: number;
message: string;
}
export interface PlexConnection {
protocol: string;
address: string;
port: number;
uri: string;
local: boolean;
status?: number;
message?: string;
host?: string;
}
export interface PlexDevice {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: Date;
lastSeenAt: Date;
provides: string[];
owned: boolean;
accessToken?: string;
publicAddress?: string;
httpsRequired?: boolean;
synced?: boolean;
relay?: boolean;
dnsRebindingProtection?: boolean;
natLoopbackSupported?: boolean;
publicAddressMatches?: boolean;
presence?: boolean;
ownerID?: string;
home?: boolean;
sourceTitle?: string;
connection: PlexConnection[];
}

View File

@@ -1,22 +0,0 @@
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
import { LanguageProfile } from '../../api/sonarr';
export interface ServiceCommonServer {
id: number;
name: string;
is4k: boolean;
isDefault: boolean;
activeProfileId: number;
activeDirectory: string;
activeLanguageProfileId?: number;
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
}
export interface ServiceCommonServerWithDetails {
server: ServiceCommonServer;
profiles: RadarrProfile[];
rootFolders: Partial<RadarrRootFolder>[];
languageProfiles?: LanguageProfile[];
}

View File

@@ -4,26 +4,3 @@ export interface SettingsAboutResponse {
totalMediaItems: number;
tz?: string;
}
export interface PublicSettingsResponse {
initialized: boolean;
applicationTitle: string;
hideAvailable: boolean;
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
originalLanguage: string;
}
export interface CacheItem {
id: string;
name: string;
stats: {
hits: number;
misses: number;
keys: number;
ksize: number;
vsize: number;
};
}

View File

@@ -1,11 +0,0 @@
import type { User } from '../../entity/User';
import { MediaRequest } from '../../entity/MediaRequest';
import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
results: User[];
}
export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[];
}

View File

@@ -1,13 +0,0 @@
export interface UserSettingsGeneralResponse {
username?: string;
region?: string;
originalLanguage?: string;
}
export interface UserSettingsNotificationsResponse {
enableNotifications: boolean;
telegramBotUsername?: string;
discordId?: string;
telegramChatId?: string;
telegramSendSilently?: boolean;
}

View File

@@ -0,0 +1,426 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
import TheMovieDb, {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';
import { getSettings, Library } from '../../lib/settings';
import Season from '../../entity/Season';
import { uniqWith } from 'lodash';
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)|hama:\/\/tvdb-([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library;
libraries: Library[];
}
class JobPlexSync {
private tmdb: TheMovieDb;
private plexClient: PlexAPI;
private items: PlexLibraryItem[] = [];
private progress = 0;
private libraries: Library[];
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
private async getExisting(tmdbId: number) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId },
});
return existing;
}
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const newMedia = new Media();
if (!metadata.Guid) {
logger.debug('No Guid metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: plexitem.ratingKey,
});
return;
}
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
newMedia.tmdbId = Number(tmdbMatch);
}
});
const existing = await this.getExisting(newMedia.tmdbId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
'info'
);
} else {
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`);
}
} else {
let tmdbMovieId: number | undefined;
let tmdbMovie: TmdbMovieDetails | undefined;
const imdbMatch = plexitem.guid.match(imdbRegex);
const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
if (imdbMatch) {
tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: imdbMatch[1],
});
tmdbMovieId = tmdbMovie.id;
} else if (tmdbMatch) {
tmdbMovieId = Number(tmdbMatch[1]);
}
if (!tmdbMovieId) {
throw new Error('Unable to find TMDB ID');
}
const existing = await this.getExisting(tmdbMovieId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
await mediaRepository.save(existing);
this.log(
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
'info'
);
} else {
// If we have a tmdb movie guid but it didn't already exist, only then
// do we request the movie from tmdb (to reduce api requests)
if (!tmdbMovie) {
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
}
const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.id;
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
}
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
private async processShow(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null;
try {
const metadata = await this.plexClient.getMetadata(
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb?.[1]) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
}
} else if (metadata.guid.match(tmdbShowRegex)) {
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
if (matchedtmdb?.[1]) {
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
}
}
if (tvShow && metadata) {
// Lets get the available seasons from plex
const seasons = tvShow.seasons;
const media = await mediaRepository.findOne({
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
});
const newSeasons: Season[] = [];
const currentSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
seasons.forEach((season) => {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (
matchedPlexSeason &&
Number(matchedPlexSeason.leafCount) === season.episode_count
) {
if (existingSeason) {
existingSeason.status = MediaStatus.AVAILABLE;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.AVAILABLE,
})
);
}
} else if (matchedPlexSeason) {
if (existingSeason) {
existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.PARTIALLY_AVAILABLE,
})
);
}
}
});
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllSeasons =
newSeasons.length + (media?.seasons.length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newSeasonAvailable > currentSeasonAvailable) {
this.log(
`Detected ${
newSeasonAvailable - currentSeasonAvailable
} new season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
media.status = isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
status: isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
}
} else {
this.log(`failed show: ${plexitem.guid}`);
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
private async processItems(slicedItems: PlexLibraryItem[]) {
await Promise.all(
slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') {
await this.processMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processShow(plexitem);
}
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
}: {
start?: number;
end?: number;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (start < this.items.length && this.running) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve) =>
setTimeout(async () => {
await this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
});
resolve();
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Plex Sync', ...optional });
}
public async run(): Promise<void> {
const settings = getSettings();
if (!this.running) {
this.running = true;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
const libraryItems = await this.plexClient.getRecentlyAdded(
library.id
);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
return (
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
);
}
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
return mediaA.parentRatingKey === mediaB.parentRatingKey;
}
return mediaA.ratingKey === mediaB.ratingKey;
});
await this.loop();
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop();
}
}
this.running = false;
this.log('complete');
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public cancel(): void {
this.running = false;
}
}
export const jobPlexFullSync = new JobPlexSync();
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });

View File

@@ -1,99 +1,31 @@
import schedule from 'node-schedule';
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger';
import downloadTracker from '../lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
interface ScheduledJob {
id: string;
job: schedule.Job;
name: string;
type: 'process' | 'command';
running?: () => boolean;
cancelFn?: () => void;
}
export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex scan every 5 minutes
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
name: 'Plex Recently Added Sync',
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
plexRecentScanner.run();
jobPlexRecentSync.run();
}),
running: () => plexRecentScanner.status().running,
cancelFn: () => plexRecentScanner.cancel(),
});
// Run full plex scan every 24 hours
// Run full plex sync every 24 hours
scheduledJobs.push({
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
name: 'Plex Full Library Sync',
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
label: 'Jobs',
});
plexFullScanner.run();
}),
running: () => plexFullScanner.status().running,
cancelFn: () => plexFullScanner.cancel(),
});
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
radarrScanner.run();
}),
running: () => radarrScanner.status().running,
cancelFn: () => radarrScanner.cancel(),
});
// Run full sonarr scan every 24 hours
scheduledJobs.push({
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
sonarrScanner.run();
}),
running: () => sonarrScanner.status().running,
cancelFn: () => sonarrScanner.cancel(),
});
// Run download sync
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
job: schedule.scheduleJob('0 * * * * *', () => {
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
downloadTracker.updateDownloads();
}),
});
// Reset download sync
scheduledJobs.push({
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
job: schedule.scheduleJob('0 0 1 * * *', () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
});
downloadTracker.resetDownloadTracker();
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),
});

View File

@@ -1,60 +0,0 @@
import NodeCache from 'node-cache';
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
class Cache {
public id: AvailableCacheIds;
public data: NodeCache;
public name: string;
constructor(
id: AvailableCacheIds,
name: string,
options: { stdTtl?: number; checkPeriod?: number } = {}
) {
this.id = id;
this.name = name;
this.data = new NodeCache({
stdTTL: options.stdTtl ?? DEFAULT_TTL,
checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD,
});
}
public getStats() {
return this.data.getStats();
}
public flush(): void {
this.data.flushAll();
}
}
class CacheManager {
private availableCaches: Record<AvailableCacheIds, Cache> = {
tmdb: new Cache('tmdb', 'TMDb API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {
return this.availableCaches[id];
}
public getAllCaches(): Record<string, Cache> {
return this.availableCaches;
}
}
const cacheManager = new CacheManager();
export default cacheManager;

View File

@@ -1,195 +0,0 @@
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
size: number;
sizeLeft: number;
status: string;
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
}
class DownloadTracker {
private radarrServers: Record<number, DownloadingItem[]> = {};
private sonarrServers: Record<number, DownloadingItem[]> = {};
public getMovieProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.radarrServers[serverId]) {
return [];
}
return this.radarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public getSeriesProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.sonarrServers[serverId]) {
return [];
}
return this.sonarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public async resetDownloadTracker() {
this.radarrServers = {};
}
public updateDownloads() {
this.updateRadarrDownloads();
this.updateSonarrDownloads();
}
private async updateRadarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
externalId: item.movieId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.MOVIE,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.radarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Radarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.radarrServers[ms.id] = this.radarrServers[server.id];
}
});
}
})
);
}
private async updateSonarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.TV,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.sonarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.sonarrServers[ms.id] = this.sonarrServers[server.id];
}
});
}
})
);
}
}
const downloadTracker = new DownloadTracker();
export default downloadTracker;

View File

@@ -1,38 +0,0 @@
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import { getSettings } from '../settings';
class PreparedEmail extends Email {
public constructor() {
const settings = getSettings().notifications.agents.email;
const transport = nodemailer.createTransport({
host: settings.options.smtpHost,
port: settings.options.smtpPort,
secure: settings.options.secure,
tls: settings.options.allowSelfSigned
? {
rejectUnauthorized: false,
}
: undefined,
auth:
settings.options.authUser && settings.options.authPass
? {
user: settings.options.authUser,
pass: settings.options.authPass,
}
: undefined,
});
super({
message: {
from: {
name: settings.options.senderName,
address: settings.options.emailFrom,
},
},
send: true,
transport: transport,
});
}
}
export default PreparedEmail;

View File

@@ -1,6 +1,5 @@
import { Notification } from '..';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
@@ -11,7 +10,6 @@ export interface NotificationPayload {
image?: string;
message?: string;
extra?: { name: string; value: string }[];
request?: MediaRequest;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {
@@ -21,15 +19,9 @@ 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, payload: NotificationPayload): boolean;
shouldSend(type: Notification): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
}

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -71,15 +71,9 @@ interface DiscordRichEmbed {
interface DiscordWebhookPayload {
embeds: DiscordRichEmbed[];
username?: string;
username: string;
avatar_url?: string;
tts: boolean;
content?: string;
allowed_mentions?: {
parse?: ('users' | 'roles' | 'everyone')[];
roles?: string[];
users?: string[];
};
}
class DiscordAgent
@@ -104,63 +98,73 @@ class DiscordAgent
const fields: Field[] = [];
if (payload.request) {
fields.push({
name: 'Requested By',
value: payload.notifyUser.displayName ?? '',
inline: true,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
fields.push({
name: 'Status',
value: 'Pending Approval',
inline: true,
});
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Pending Approval',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
value: 'Processing',
inline: true,
});
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Processing Request',
inline: true,
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
fields.push({
name: 'Status',
value: 'Available',
inline: true,
});
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Declined',
inline: true,
});
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Failed',
inline: true,
});
break;
}
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Available',
inline: true,
}
);
if (settings.main.applicationUrl && payload.media) {
fields.push({
name: `Open in ${settings.main.applicationTitle}`,
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
}
return {
@@ -168,10 +172,7 @@ class DiscordAgent
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: {
name: settings.main.applicationTitle,
url: settings.main.applicationUrl,
},
author: { name: 'Overseerr', url: settings.main.applicationUrl },
fields: [
...fields,
// If we have extra data, map it to fields for discord notifications
@@ -186,12 +187,10 @@ class DiscordAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
return true;
}
@@ -202,38 +201,17 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Discord notification', { label: 'Notifications' });
logger.debug('Sending discord notification', { label: 'Notifications' });
try {
const {
botUsername,
botAvatarUrl,
webhookUrl,
} = this.getSettings().options;
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
const mentionedUsers: string[] = [];
let content = undefined;
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, {
username: botUsername,
avatar_url: botAvatarUrl,
username: 'Overseerr',
embeds: [this.buildEmbed(type, payload)],
content,
allowed_mentions: {
users: mentionedUsers,
},
} as DiscordWebhookPayload);
return true;
@@ -241,7 +219,6 @@ class DiscordAgent
logger.error('Error sending Discord notification', {
label: 'Notifications',
message: e.message,
response: e.response.data,
});
return false;
}

View File

@@ -1,13 +1,13 @@
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import path from 'path';
import { getSettings, NotificationAgentEmail } from '../../settings';
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import logger from '../../../logger';
import { getRepository } from 'typeorm';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
import PreparedEmail from '../../email';
import { MediaType } from '../../../constants/media';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
@@ -22,73 +22,54 @@ class EmailAgent
return settings.notifications.agents.email;
}
public shouldSend(type: Notification, payload: NotificationPayload): boolean {
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types) &&
(payload.notifyUser.settings?.enableNotifications ?? true)
) {
if (settings.enabled) {
return true;
}
return false;
}
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();
private getSmtpTransport() {
const emailSettings = this.getSettings().options;
// 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;
}
return nodemailer.createTransport({
host: emailSettings.smtpHost,
port: emailSettings.smtpPort,
secure: emailSettings.secure,
tls: emailSettings.allowSelfSigned
? {
rejectUnauthorized: false,
}
: undefined,
auth:
emailSettings.authUser && emailSettings.authPass
? {
user: emailSettings.authUser,
pass: emailSettings.authPass,
}
: undefined,
});
}
private async sendMediaFailedEmail(payload: NotificationPayload) {
private getNewEmail() {
const settings = this.getSettings();
return new Email({
message: {
from: settings.options.emailFrom,
},
send: true,
transport: this.getSmtpTransport(),
});
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
const applicationUrl = getSettings().main.applicationUrl;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -97,7 +78,7 @@ class EmailAgent
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = new PreparedEmail();
const email = this.getNewEmail();
email.send({
template: path.join(
@@ -108,29 +89,22 @@ class EmailAgent
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'
}`,
body: 'A user has requested new media!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
requestedBy: payload.notifyUser.username,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`,
requestType: 'New Request',
},
});
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -140,9 +114,9 @@ class EmailAgent
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = new PreparedEmail();
const email = this.getNewEmail();
await email.send({
template: path.join(
@@ -153,118 +127,21 @@ class EmailAgent
to: payload.notifyUser.email,
},
locals: {
body: `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`,
body: 'Your request for the following media has been approved:',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
requestedBy: payload.notifyUser.username,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`,
requestType: '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', {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -274,9 +151,9 @@ class EmailAgent
private async sendMediaAvailableEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = new PreparedEmail();
const email = this.getNewEmail();
await email.send({
template: path.join(
@@ -287,26 +164,21 @@ class EmailAgent
to: payload.notifyUser.email,
},
locals: {
body: `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`,
body: 'Your requested media is now available!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.displayName,
requestedBy: payload.notifyUser.username,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`,
requestType: 'Now Available',
},
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -316,9 +188,9 @@ class EmailAgent
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = new PreparedEmail();
const email = this.getNewEmail();
await email.send({
template: path.join(__dirname, '../../../templates/email/test-email'),
@@ -328,12 +200,11 @@ class EmailAgent
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
@@ -354,18 +225,9 @@ class EmailAgent
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;

View File

@@ -1,168 +0,0 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushbulletPayload {
title: string;
body: string;
}
class PushbulletAgent
extends BaseAgent<NotificationAgentPushbullet>
implements NotificationAgent {
protected getSettings(): NotificationAgentPushbullet {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.pushbullet;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private constructMessageDetails(
type: Notification,
payload: NotificationPayload
): {
title: string;
body: string;
} {
let messageTitle = '';
let message = '';
const title = payload.subject;
const plot = payload.message;
const username = payload.notifyUser.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Pending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Failed`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
}
return {
title: messageTitle,
body: message,
};
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const { accessToken } = this.getSettings().options;
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
endpoint,
{
type: 'note',
title: title,
body: body,
} as PushbulletPayload,
{
headers: {
'Access-Token': accessToken,
},
}
);
return true;
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default PushbulletAgent;

View File

@@ -1,195 +0,0 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushoverPayload {
token: string;
user: string;
title: string;
message: string;
url: string;
url_title: string;
priority: number;
html: number;
}
class PushoverAgent
extends BaseAgent<NotificationAgentPushover>
implements NotificationAgent {
protected getSettings(): NotificationAgentPushover {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.pushover;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
this.getSettings().options.userToken &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private constructMessageDetails(
type: Notification,
payload: NotificationPayload
): {
title: string;
message: string;
url: string | undefined;
url_title: string | undefined;
priority: number;
} {
const settings = getSettings();
let messageTitle = '';
let message = '';
let url: string | undefined;
let url_title: string | undefined;
let priority = 0;
const title = payload.subject;
const plot = payload.message;
const username = payload.notifyUser.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nDeclined`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nFailed`;
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
}
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
}
return {
title: messageTitle,
message,
url,
url_title,
priority,
};
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushover notification', { label: 'Notifications' });
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
const { accessToken, userToken } = this.getSettings().options;
const {
title,
message,
url,
url_title,
priority,
} = this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
token: accessToken,
user: userToken,
title: title,
message: message,
url: url,
url_title: url_title,
priority: priority,
html: 1,
} as PushoverPayload);
return true;
} catch (e) {
logger.error('Error sending Pushover notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default PushoverAgent;

View File

@@ -1,250 +0,0 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
text: string;
}
interface TextItem {
type: 'plain_text' | 'mrkdwn';
text: string;
emoji?: boolean;
}
interface Element {
type: 'button';
text?: TextItem;
value: string;
url: string;
action_id: 'button-action';
}
interface EmbedBlock {
type: 'header' | 'actions' | 'section' | 'context';
block_id?: 'section789';
text?: TextItem;
fields?: EmbedField[];
accessory?: {
type: 'image';
image_url: string;
alt_text: string;
};
elements?: Element[];
}
interface SlackBlockEmbed {
blocks: EmbedBlock[];
}
class SlackAgent
extends BaseAgent<NotificationAgentSlack>
implements NotificationAgent {
protected getSettings(): NotificationAgentSlack {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.slack;
}
public buildEmbed(
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = '';
let actionUrl: string | undefined;
const fields: EmbedField[] = [];
if (payload.request) {
fields.push({
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
header = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
break;
case Notification.MEDIA_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AUTO_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AVAILABLE:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
break;
case Notification.MEDIA_DECLINED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
break;
case Notification.MEDIA_FAILED:
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nFailed',
});
break;
case Notification.TEST_NOTIFICATION:
header = 'Test Notification';
break;
}
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
const blocks: EmbedBlock[] = [
{
type: 'header',
text: {
type: 'plain_text',
text: header,
},
},
];
if (type !== Notification.TEST_NOTIFICATION) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.subject}*`,
},
});
}
if (payload.message) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: payload.message,
},
accessory: payload.image
? {
type: 'image',
image_url: payload.image,
alt_text: payload.subject,
}
: undefined,
});
}
if (fields.length > 0) {
blocks.push({
type: 'section',
fields: [
...fields,
...(payload.extra ?? []).map(
(extra): EmbedField => ({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
})
),
],
});
}
if (actionUrl) {
blocks.push({
type: 'actions',
elements: [
{
action_id: 'button-action',
type: 'button',
url: actionUrl,
value: 'open_overseerr',
text: {
type: 'plain_text',
text: `Open in ${settings.main.applicationTitle}`,
},
},
],
});
}
return {
blocks,
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Slack notification', { label: 'Notifications' });
try {
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildEmbed(type, payload));
return true;
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default SlackAgent;

View File

@@ -1,213 +0,0 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { MediaType } from '../../../constants/media';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload {
text: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
interface TelegramPhotoPayload {
photo: string;
caption: string;
parse_mode: string;
chat_id: string;
disable_notification: boolean;
}
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
private baseUrl = 'https://api.telegram.org/';
protected getSettings(): NotificationAgentTelegram {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
private escapeText(text: string | undefined): string {
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
}
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nFailed`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*`;
message += `\n\n${plot}`;
break;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
}
/* eslint-enable */
return message;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Telegram notification', { label: 'Notifications' });
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
// Send system notification
await (payload.image
? axios.post(endpoint, {
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload));
// Send user notification
if (
this.userNotificationTypes.includes(type) &&
payload.notifyUser.settings?.enableNotifications &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
await (payload.image
? axios.post(endpoint, {
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload));
}
return true;
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default TelegramAgent;

View File

@@ -1,150 +0,0 @@
import axios from 'axios';
import { get } from 'lodash';
import { hasNotificationType, Notification } from '..';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
type KeyMapFunction = (
payload: NotificationPayload,
type: Notification
) => string;
const KeyMap: Record<string, string | KeyMapFunction> = {
notification_type: (_payload, type) => Notification[type],
subject: 'subject',
message: 'message',
image: 'image',
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
media_type: 'media.mediaType',
media_status: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status] : '',
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
request_id: 'request.id',
};
class WebhookAgent
extends BaseAgent<NotificationAgentWebhook>
implements NotificationAgent {
protected getSettings(): NotificationAgentWebhook {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.webhook;
}
private parseKeys(
finalPayload: Record<string, unknown>,
payload: NotificationPayload,
type: Notification
): Record<string, unknown> {
Object.keys(finalPayload).forEach((key) => {
if (key === '{{extra}}') {
finalPayload.extra = payload.extra ?? [];
delete finalPayload[key];
key = 'extra';
} else if (key === '{{media}}') {
if (payload.media) {
finalPayload.media = finalPayload[key];
} else {
finalPayload.media = null;
}
delete finalPayload[key];
key = 'media';
} else if (key === '{{request}}') {
if (payload.request) {
finalPayload.request = finalPayload[key];
} else {
finalPayload.request = null;
}
delete finalPayload[key];
key = 'request';
}
if (typeof finalPayload[key] === 'string') {
Object.keys(KeyMap).forEach((keymapKey) => {
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
finalPayload[key] = (finalPayload[key] as string).replace(
`{{${keymapKey}}}`,
typeof keymapValue === 'function'
? keymapValue(payload, type)
: get(payload, keymapValue) ?? ''
);
});
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
finalPayload[key] = this.parseKeys(
finalPayload[key] as Record<string, unknown>,
payload,
type
);
}
});
return finalPayload;
}
private buildPayload(type: Notification, payload: NotificationPayload) {
const payloadString = Buffer.from(
this.getSettings().options.jsonPayload,
'base64'
).toString('ascii');
const parsedJSON = JSON.parse(JSON.parse(payloadString));
return this.parseKeys(parsedJSON, payload, type);
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending webhook notification', { label: 'Notifications' });
try {
const { webhookUrl, authHeader } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: authHeader,
},
});
return true;
} catch (e) {
logger.error('Error sending webhook notification', {
label: 'Notifications',
errorMessage: e.message,
});
return false;
}
}
}
export default WebhookAgent;

View File

@@ -1,38 +1,13 @@
import logger from '../../logger';
import { getSettings } from '../settings';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
TEST_NOTIFICATION = 16,
}
export const hasNotificationType = (
types: Notification | Notification[],
value: number
): boolean => {
let total = 0;
// If we are not checking any notifications, bail out and return true
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
// Combine all notification values into one
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
return !!(value & total);
};
class NotificationManager {
private activeAgents: NotificationAgent[] = [];
@@ -45,12 +20,11 @@ class NotificationManager {
type: Notification,
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
logger.info(`Sending notification for ${Notification[type]}`, {
label: 'Notifications',
});
this.activeAgents.forEach((agent) => {
if (settings.enabled && agent.shouldSend(type, payload)) {
if (agent.shouldSend(type)) {
agent.send(type, payload);
}
});

View File

@@ -7,20 +7,6 @@ export enum Permission {
REQUEST = 32,
VOTE = 64,
AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512,
REQUEST_4K = 1024,
REQUEST_4K_MOVIE = 2048,
REQUEST_4K_TV = 4096,
REQUEST_ADVANCED = 8192,
REQUEST_VIEW = 16384,
AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072,
}
export interface PermissionCheckOptions {
type: 'and' | 'or';
}
/**
@@ -30,12 +16,10 @@ export interface PermissionCheckOptions {
*
* @param permissions Single permission or array of permissions
* @param value users current permission value
* @param options Extra options to control permission check behavior (mainly for arrays)
*/
export const hasPermission = (
permissions: Permission | Permission[],
value: number,
options: PermissionCheckOptions = { type: 'and' }
value: number
): boolean => {
let total = 0;
@@ -45,15 +29,8 @@ export const hasPermission = (
}
if (Array.isArray(permissions)) {
if (value & Permission.ADMIN) {
return true;
}
switch (options.type) {
case 'and':
return permissions.every((permission) => !!(value & permission));
case 'or':
return permissions.some((permission) => !!(value & permission));
}
// Combine all permission values into one
total = permissions.reduce((a, v) => a + v, 0);
} else {
total = permissions;
}

View File

@@ -1,616 +0,0 @@
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden)
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
export type StatusBase = {
running: boolean;
progress: number;
total: number;
};
export interface RunnableScanner<T> {
run: () => Promise<void>;
status: () => T & StatusBase;
}
export interface MediaIds {
tmdbId: number;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
}
interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
title?: string;
processing?: boolean;
}
export interface ProcessableSeason {
seasonNumber: number;
totalEpisodes: number;
episodes: number;
episodes4k: number;
is4kOverride?: boolean;
processing?: boolean;
}
class BaseScanner<T> {
private bundleSize;
private updateRate;
protected progress = 0;
protected items: T[] = [];
protected scannerName: string;
protected enable4kMovie = false;
protected enable4kShow = false;
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
readonly tmdb = new TheMovieDb();
protected constructor(
scannerName: string,
{
updateRate,
bundleSize,
}: {
updateRate?: number;
bundleSize?: number;
} = {}
) {
this.scannerName = scannerName;
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
protected async processMovie(
tmdbId: number,
{
is4k = false,
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
changedExisting = true;
}
if (
externalServiceSlug !== undefined &&
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changed were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = !is4k ? ratingKey : undefined;
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
* in one method.
*
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
* into the normal episodes properties and avoid using the 4k properties.
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
is4k = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const media = await this.getExisting(tmdbId, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
// force it to stay available (to avoid competing scanners)
existingSeason.status =
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
(this.enable4kShow &&
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
const isAllStandardSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes === season.totalEpisodes && season.episodes > 0
);
const isAll4kSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
);
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonsAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonsAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
this.log(
`Detected ${
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
} new standard season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
if (mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
}
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
this.log(
`Detected ${
new4kSeasonsAvailable - current4kSeasonsAvailable
} new 4K season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt && mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
.length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
.length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId,
tvdbId,
mediaAddedAt,
serviceId: !is4k ? serviceId : undefined,
serviceId4k: is4k ? serviceId : undefined,
externalServiceId: !is4k ? externalServiceId : undefined,
externalServiceId4k: is4k ? externalServiceId : undefined,
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
ratingKey: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
ratingKey4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set
*
* Returns the session ID which is requried for the cleanup method
*/
protected startRun(): string {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Scan starting', 'info', { sessionId });
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
this.running = true;
return sessionId;
}
/**
* Call at end of run loop to perform cleanup
*/
protected endRun(sessionId: string): void {
if (this.sessionId === sessionId) {
this.running = false;
}
}
public cancel(): void {
this.running = false;
}
protected async loop(
processFn: (item: T) => Promise<void>,
{
start = 0,
end = this.bundleSize,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}
): Promise<void> {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(processFn, slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop(processFn, {
start: start + this.bundleSize,
end: end + this.bundleSize,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, this.updateRate)
);
}
}
private async processItems(
processFn: (items: T) => Promise<void>,
items: T[]
) {
await Promise.all(
items.map(async (item) => {
await processFn(item);
})
);
}
protected log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: this.scannerName, ...optional });
}
}
export default BaseScanner;

View File

@@ -1,463 +0,0 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import { getSettings, Library } from '../../settings';
import BaseScanner, {
MediaIds,
RunnableScanner,
StatusBase,
ProcessableSeason,
} from '../baseScanner';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
const HAMA_AGENT = 'com.plexapp.agents.hama';
type SyncStatus = StatusBase & {
currentLibrary: Library;
libraries: Library[];
};
class PlexScanner
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
private plexClient: PlexAPI;
private libraries: Library[];
private currentLibrary: Library;
private isRecentOnly = false;
public constructor(isRecentOnly = false) {
super('Plex Scan');
this.isRecentOnly = isRecentOnly;
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Plex scan skipped.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();
}
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
const libraryItems = await this.plexClient.getRecentlyAdded(
library.id
);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
return (
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
);
}
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
return mediaA.parentRatingKey === mediaB.parentRatingKey;
}
return mediaA.ratingKey === mediaB.ratingKey;
});
await this.loop(this.processItem.bind(this), { sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop(this.processItem.bind(this), { sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processItem(plexitem: PlexLibraryItem) {
try {
if (plexitem.type === 'movie') {
await this.processPlexMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
errorMessage: e.message,
title: plexitem.title,
});
}
}
private async processPlexMovie(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexMovieByTmdbId(
plexitem: PlexMetadata,
tmdbId: number
) {
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexShow(plexitem: PlexLibraryItem) {
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
const mediaIds = await this.getMediaIds(metadata);
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
// as a special HAMA movie
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
this.processHamaMovie(metadata, mediaIds.tmdbId);
return;
}
// If the media is from HAMA and we have a TVDb ID, we will attempt
// to process any specials that may exist
if (mediaIds.tvdbId && mediaIds.isHama) {
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
if (matchedPlexSeason) {
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
!this.enable4kShow
? true
: episode.Media.some((media) => media.videoResolution !== '4k')
).length;
// Total episodes that are in 4k
const total4k = this.enable4kShow
? episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length
: 0;
processableSeasons.push({
seasonNumber: season.season_number,
episodes: totalStandard,
episodes4k: total4k,
totalEpisodes: season.episode_count,
});
} else {
processableSeasons.push({
seasonNumber: season.season_number,
episodes: 0,
episodes4k: 0,
totalEpisodes: season.episode_count,
});
}
}
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
const mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
// If there is no Guid field at all, then we bail
if (!metadata.Guid) {
throw new Error(
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
);
}
// Map all IDs to MediaId object
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
mediaIds.tmdbId = Number(tmdbMatch);
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
}
});
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is IMDb
} else if (plexitem.guid.match(imdbRegex)) {
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
mediaIds.tmdbId = Number(tmdbMatch[1]);
}
// Check if the agent is TVDb
} else if (plexitem.guid.match(tvdbRegex)) {
const matchedtvdb = plexitem.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDb
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
mediaIds.tmdbId = Number(matchedtmdb[1]);
}
// Check for HAMA (with TVDb guid)
} else if (plexitem.guid.match(hamaTvdbRegex)) {
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
}
// Check for HAMA (with anidb guid)
} else if (plexitem.guid.match(hamaAnidbRegex)) {
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
if (!animeList.isLoaded()) {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn',
{ title: plexitem.title }
);
} else if (matchedhama) {
const anidbId = Number(matchedhama[1]);
const result = animeList.getFromAnidbId(anidbId);
let tvShow: TmdbTvDetails | null = null;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
// First try to lookup the show by TVDb ID
if (result?.tvdbId) {
const extResponse = await this.tmdb.getByExternalId({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;
mediaIds.tmdbId = tvShow.id;
} else {
this.log(
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
);
}
}
if (!tvShow) {
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
// note - some tv shows have imdbid set too, that's why this need to go second
if (result?.tmdbId) {
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.imdbId = result.imdbId;
}
}
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// We check above if we have the TMDb ID, so we can safely assert the type below
return mediaIds as MediaIds;
}
// movies with hama agent actually are tv shows with at least one episode in it
// try to get first episode of any season - cannot hardcode season or episode number
// because sometimes user can have it in other season/ep than s01e01
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
const season = metadata.Children?.Metadata[0];
if (season) {
const episodes = await this.plexClient.getChildrenMetadata(
season.ratingKey
);
if (episodes) {
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
}
}
}
// this adds all movie episodes from specials season for Hama agent
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
const specials = metadata.Children?.Metadata.find(
(md) => Number(md.index) === 0
);
if (specials) {
const episodes = await this.plexClient.getChildrenMetadata(
specials.ratingKey
);
if (episodes) {
for (const episode of episodes) {
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
if (special) {
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
}
}
}
}
}
}
// checks if any of this.libraries has Hama agent set in Plex
private async hasHamaAgent() {
const plexLibraries = await this.plexClient.getLibraries();
return this.libraries.some((library) =>
plexLibraries.some(
(plexLibrary) =>
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
)
);
}
}
export const plexFullScanner = new PlexScanner();
export const plexRecentScanner = new PlexScanner(true);

Some files were not shown because too many files have changed in this diff Show More