Compare commits

..

92 Commits

Author SHA1 Message Date
semantic-release-bot
512f6e51e4 chore(release): 1.29.0 2022-04-01 09:03:58 +00:00
Ryan Cohen
1b36dded3f Merge branch 'develop' 2022-04-01 18:02:32 +09:00
Ryan Cohen
77511bf199 ci: disable husky for release ci 2022-04-01 18:01:22 +09:00
Ryan Cohen
26d77af3a4 Merge branch 'develop' 2022-04-01 17:45:51 +09:00
semantic-release-bot
2b111f1c34 chore(release): 1.28.0 2022-01-01 04:20:46 +00:00
Ryan Cohen
a6392cb2db Merge branch 'develop' 2022-01-01 13:11:57 +09:00
semantic-release-bot
6d55b50e5e chore(release): 1.27.0 2021-10-19 14:01:17 +00:00
sct
f7dc6256ba Merge branch 'develop' 2021-10-19 22:54:07 +09:00
semantic-release-bot
f1df1caea5 chore(release): 1.26.1 2021-09-20 00:22:06 +00:00
sct
d23c120dc0 Merge branch 'develop' 2021-09-20 09:14:29 +09:00
semantic-release-bot
353efa39d0 chore(release): 1.26.0 2021-09-19 12:15:52 +00:00
sct
21cdf7d113 Merge branch 'develop' 2021-09-19 21:08:16 +09:00
semantic-release-bot
f858f025fe chore(release): 1.25.0 2021-06-10 10:25:49 +00:00
sct
c92bb134d4 Merge branch 'develop' 2021-06-10 19:18:12 +09:00
semantic-release-bot
7e0e2a92e3 chore(release): 1.24.0 2021-05-05 08:57:48 +00:00
sct
e840489e57 Merge branch 'develop' 2021-05-05 17:50:27 +09:00
sct
800f4668ec chore: fix gitattributes for images [skip ci] 2021-05-05 17:49:55 +09:00
semantic-release-bot
f2c37babeb chore(release): 1.23.2 2021-04-21 12:28:28 +00:00
sct
24095eac03 Merge branch 'develop' 2021-04-21 21:20:07 +09:00
semantic-release-bot
8c782e0c93 chore(release): 1.23.1 2021-04-16 14:19:53 +00:00
sct
08ce7f24b8 Merge branch 'develop' 2021-04-16 23:11:54 +09:00
semantic-release-bot
67e794aca4 chore(release): 1.23.0 2021-04-16 12:38:05 +00:00
sct
c9c429d027 Merge branch 'develop' 2021-04-16 21:30:30 +09:00
semantic-release-bot
31ac11f4eb chore(release): 1.22.0 2021-04-01 06:33:50 +00:00
sct
f072a4e11d Merge branch 'develop' 2021-04-01 15:27:10 +09:00
semantic-release-bot
b24c23e549 chore(release): 1.21.1 2021-03-15 08:04:26 +00:00
sct
e602e9f758 Merge branch 'develop' 2021-03-15 07:56:33 +00:00
semantic-release-bot
afd46ebee8 chore(release): 1.21.0 2021-03-15 02:00:59 +00:00
sct
be47d1e56c Merge branch 'develop' 2021-03-15 01:52:10 +00:00
semantic-release-bot
0e612f01f1 chore(release): 1.20.1 2021-02-28 04:10:48 +00:00
sct
cd9d9ae8d2 Merge branch 'develop' 2021-02-28 04:03:43 +00:00
semantic-release-bot
ea2765f8c7 chore(release): 1.20.0 2021-02-23 12:33:56 +00:00
sct
448e7c3a7d Merge branch 'develop' 2021-02-23 12:26:58 +00:00
sct
6ab3cd77a7 Merge branch 'develop' 2021-02-23 12:09:08 +00:00
semantic-release-bot
3dfbb0e0a5 chore(release): 1.19.1 2021-02-06 05:14:36 +00:00
TheCatLady
726f62b9b6 fix(ui): Fix webhook URL validation regex (#864) 2021-02-06 14:08:24 +09:00
sct
81c30347f6 Merge branch 'develop' 2021-02-05 14:15:02 +00:00
semantic-release-bot
a397893217 chore(release): 1.19.0 2021-02-05 10:32:05 +00:00
sct
12c5bbd778 Merge branch 'develop' 2021-02-05 10:25:43 +00:00
sct
06261c1118 ci(snapcraft): remove --unshallow argument from fetch in Prepare step 2021-01-30 06:31:34 +00:00
semantic-release-bot
686fe6fdf8 chore(release): 1.18.0 2021-01-30 06:13:16 +00:00
sct
c80a1fa261 Merge branch 'develop' 2021-01-30 06:06:49 +00:00
semantic-release-bot
6167011ee5 chore(release): 1.17.2 2021-01-20 07:27:33 +00:00
sct
bc399d95a0 Merge branch 'develop' 2021-01-20 07:21:48 +00:00
semantic-release-bot
519dd17d37 chore(release): 1.17.1 2021-01-19 10:24:57 +00:00
sct
0761593411 Merge branch 'develop' 2021-01-19 10:18:51 +00:00
semantic-release-bot
caa45415b1 chore(release): 1.17.0 2021-01-19 07:58:37 +00:00
sct
ed297d9d6c Merge branch 'develop' 2021-01-19 07:52:47 +00:00
semantic-release-bot
c6486c3643 chore(release): 1.16.0 2021-01-07 09:58:09 +00:00
sct
f9695068a8 Merge branch 'develop' 2021-01-07 09:53:04 +00:00
semantic-release-bot
fd90adba4d chore(release): 1.15.0 2021-01-04 12:01:25 +00:00
sct
970aa612fe Merge pull request #570 from sct/release/release-20210104
Release
2021-01-04 20:56:34 +09:00
sct
71169944dc Merge branch 'develop' into release/release-20210104 2021-01-04 11:54:45 +00:00
semantic-release-bot
d77f9eb44e chore(release): 1.14.1 2021-01-02 08:57:24 +00:00
sct
22f2037ea6 fix(holiday): remove special holiday slider 2021-01-02 08:33:24 +00:00
semantic-release-bot
2cdf755c20 chore(release): 1.14.0 2020-12-25 11:52:44 +00:00
sct
f99ab47c01 Merge branch 'develop' 2020-12-25 20:47:19 +09:00
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
438 changed files with 7307 additions and 12234 deletions

View File

@@ -665,78 +665,6 @@
"contributions": [
"translation"
]
},
{
"login": "sambartik",
"name": "Samuel Bartík",
"avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4",
"profile": "https://github.com/sambartik",
"contributions": [
"code"
]
},
{
"login": "frank-cywong",
"name": "Chun Yeung Wong",
"avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4",
"profile": "https://github.com/frank-cywong",
"contributions": [
"code"
]
},
{
"login": "TheMeanCanEHdian",
"name": "TheMeanCanEHdian",
"avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4",
"profile": "https://github.com/TheMeanCanEHdian",
"contributions": [
"code"
]
},
{
"login": "Gylesie",
"name": "Gylesie",
"avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4",
"profile": "https://github.com/Gylesie",
"contributions": [
"code"
]
},
{
"login": "Fhd-pro",
"name": "Fhd-pro",
"avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4",
"profile": "https://github.com/Fhd-pro",
"contributions": [
"translation"
]
},
{
"login": "PovilasID",
"name": "PovilasID",
"avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4",
"profile": "https://github.com/PovilasID",
"contributions": [
"translation"
]
},
{
"login": "byakurau",
"name": "byakurau",
"avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4",
"profile": "https://github.com/byakurau",
"contributions": [
"translation"
]
},
{
"login": "miknii",
"name": "miknii",
"avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4",
"profile": "https://github.com/miknii",
"contributions": [
"translation"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -745,5 +673,5 @@
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false
"skipCi": true
}

View File

@@ -26,4 +26,3 @@ public/os_logo_filled.png
public/preview.jpg
snap
stylelint.config.js
cypress

View File

@@ -7,7 +7,6 @@ module.exports = {
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:react/jsx-runtime',
'prettier',
],
parserOptions: {
@@ -27,17 +26,11 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'jsx-a11y/no-onchange': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
},
],
},
overrides: [
{
@@ -47,7 +40,7 @@ module.exports = {
},
},
],
plugins: ['jsx-a11y', 'react-hooks', 'formatjs'],
plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'],
settings: {
react: {
pragma: 'React',

14
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: npm
directory: '/'
schedule:
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

View File

@@ -13,18 +13,16 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-20.04
container: node:16.17-alpine
container: node:16.14-alpine
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install dependencies
env:
HUSKY: 0
HUSKY_SKIP_INSTALL: 1
run: yarn
- name: Lint
run: yarn lint
- name: Formatting
run: yarn format:check
- name: Build
run: yarn build
@@ -36,29 +34,29 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
@@ -88,7 +86,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
uses: technote-space/workflow-conclusion-action@v2
- name: Combine Job Status
id: status
run: |

View File

@@ -1,30 +0,0 @@
name: Cypress Tests
on:
pull_request:
branches:
- '*'
push:
branches:
- develop
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Cypress run
uses: cypress-io/github-action@v4
with:
build: yarn cypress:build
start: yarn start
wait-on: 'http://localhost:5055'
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_MIGRATIONS: true
# Fix test titles in cypress dashboard
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}

View File

@@ -16,22 +16,22 @@ jobs:
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile

View File

@@ -9,6 +9,8 @@ jobs:
semantic-release:
name: Tag and release latest version
runs-on: ubuntu-20.04
env:
HUSKY: 0
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -19,16 +21,16 @@ jobs:
with:
node-version: 16
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -72,7 +74,7 @@ jobs:
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
@@ -81,7 +83,7 @@ jobs:
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
@@ -103,7 +105,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
uses: technote-space/workflow-conclusion-action@v2
- name: Combine Job Status
id: status
run: |

View File

@@ -1,90 +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.10.0
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: jobs
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
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
- 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

11
.gitignore vendored
View File

@@ -53,14 +53,3 @@ config/db/db.sqlite3-journal
# VS Code
.vscode/launch.json
# Cypress
cypress.env.json
cypress/videos
cypress/screenshots
# ESLint
.eslintcache
# TS Build Info
tsconfig.tsbuildinfo

View File

@@ -11,6 +11,9 @@
// https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
"esbenp.prettier-vscode",
// https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script
"eg2.vscode-npm-script",
// https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest
"Orta.vscode-jest",

View File

@@ -15,5 +15,8 @@
"database": "./config/db/db.sqlite3"
}
],
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"editor.formatOnSave": true
}

1265
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -86,7 +86,7 @@ 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., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'.
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.

View File

@@ -1,4 +1,4 @@
FROM node:16.17-alpine AS BUILD_IMAGE
FROM node:16.14-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -14,7 +14,7 @@ RUN \
esac
COPY package.json yarn.lock ./
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
RUN yarn install --frozen-lockfile --network-timeout 1000000
COPY . ./
@@ -33,7 +33,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:16.17-alpine
FROM node:16.14-alpine
WORKDIR /app

View File

@@ -1,4 +1,4 @@
FROM node:16.17-alpine
FROM node:16.14-alpine
COPY . /app
WORKDIR /app

View File

@@ -12,7 +12,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-80-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-72-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@@ -167,16 +167,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr>
<td align="center"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
<td align="center"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt=""/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt=""/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
</tr>
</table>

View File

@@ -1,19 +0,0 @@
import { defineConfig } from 'cypress';
export default defineConfig({
projectId: 'onnqy3',
e2e: {
baseUrl: 'http://localhost:5055',
experimentalSessionAndOrigin: true,
},
env: {
ADMIN_EMAIL: 'admin@seerr.dev',
ADMIN_PASSWORD: 'test1234',
USER_EMAIL: 'friend@seerr.dev',
USER_PASSWORD: 'test1234',
},
retries: {
runMode: 2,
openMode: 0,
},
});

View File

@@ -1,149 +0,0 @@
{
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {
"apiKey": "testkey",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
"movie": {},
"tv": {}
},
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"originalLanguage": "",
"trustProxy": false,
"partialRequestsEnabled": true,
"locale": "en"
},
"plex": {
"name": "Seerr",
"ip": "192.168.1.1",
"port": 32400,
"useSsl": false,
"libraries": [
{
"id": "1",
"name": "Movies",
"enabled": true,
"type": "movie"
}
],
"machineId": "test"
},
"tautulli": {},
"radarr": [],
"sonarr": [],
"public": {
"initialized": true
},
"notifications": {
"agents": {
"email": {
"enabled": false,
"options": {
"emailFrom": "",
"smtpHost": "",
"smtpPort": 587,
"secure": false,
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Overseerr"
}
},
"discord": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"telegram": {
"enabled": false,
"types": 0,
"options": {
"botAPI": "",
"chatId": "",
"sendSilently": false
}
},
"pushbullet": {
"enabled": false,
"types": 0,
"options": {
"accessToken": ""
}
},
"pushover": {
"enabled": false,
"types": 0,
"options": {
"accessToken": "",
"userToken": ""
}
},
"webhook": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
}
},
"webpush": {
"enabled": false,
"options": {}
},
"gotify": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"token": ""
}
}
}
},
"jobs": {
"plex-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"plex-full-scan": {
"schedule": "0 0 3 * * *"
},
"radarr-scan": {
"schedule": "0 0 4 * * *"
},
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
}
}
}

View File

@@ -1,211 +0,0 @@
const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.trigger('mouseover')
.find('[data-testid=title-card-title]')
.invoke('text')
.then((text) => {
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.click();
cy.get('[data-testid=media-title]').should('contain', text);
});
};
describe('Discover', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('loads a trending item', () => {
cy.intercept('/api/v1/discover/trending*').as('getTrending');
cy.visit('/');
cy.wait('@getTrending');
clickFirstTitleCardInSlider('Trending');
});
it('loads popular movies', () => {
cy.intercept('/api/v1/discover/movies*').as('getPopularMovies');
cy.visit('/');
cy.wait('@getPopularMovies');
clickFirstTitleCardInSlider('Popular Movies');
});
it('loads upcoming movies', () => {
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
cy.visit('/');
cy.wait('@getUpcomingMovies');
clickFirstTitleCardInSlider('Upcoming Movies');
});
it('loads popular series', () => {
cy.intercept('/api/v1/discover/tv*').as('getPopularTv');
cy.visit('/');
cy.wait('@getPopularTv');
clickFirstTitleCardInSlider('Popular Series');
});
it('loads upcoming series', () => {
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
cy.visit('/');
cy.wait('@getUpcomingSeries');
clickFirstTitleCardInSlider('Upcoming Series');
});
it('displays error for media with invalid TMDB ID', () => {
cy.intercept('GET', '/api/v1/media?*', {
pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 },
results: [
{
downloadStatus: [],
downloadStatus4k: [],
id: 1922,
mediaType: 'movie',
tmdbId: 998814,
tvdbId: null,
imdbId: null,
status: 5,
status4k: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T19:56:41.000Z',
lastSeasonChange: '2022-08-18T19:56:41.000Z',
mediaAddedAt: '2022-08-18T19:56:41.000Z',
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
seasons: [],
},
],
}).as('getMedia');
cy.visit('/');
cy.wait('@getMedia');
cy.contains('.slider-header', 'Recently Added')
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.find('[data-testid=title-card-title]')
.contains('Movie Not Found');
});
it('displays error for request with invalid TMDB ID', () => {
cy.intercept('GET', '/api/v1/request?*', {
pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 },
results: [
{
id: 582,
status: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T18:11:13.000Z',
type: 'movie',
is4k: false,
serverId: null,
profileId: null,
rootFolder: null,
languageProfileId: null,
tags: null,
media: {
downloadStatus: [],
downloadStatus4k: [],
id: 1922,
mediaType: 'movie',
tmdbId: 998814,
tvdbId: null,
imdbId: null,
status: 2,
status4k: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T18:11:13.000Z',
lastSeasonChange: '2022-08-18T18:11:13.000Z',
mediaAddedAt: null,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
},
seasons: [],
modifiedBy: null,
requestedBy: {
permissions: 4194336,
id: 18,
email: 'friend@seerr.dev',
plexUsername: null,
username: '',
recoveryLinkExpirationDate: null,
userType: 2,
avatar:
'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200',
movieQuotaLimit: null,
movieQuotaDays: null,
tvQuotaLimit: null,
tvQuotaDays: null,
createdAt: '2022-08-17T04:55:28.000Z',
updatedAt: '2022-08-17T04:55:28.000Z',
requestCount: 1,
displayName: 'friend@seerr.dev',
},
seasonCount: 0,
},
],
}).as('getRequests');
cy.visit('/');
cy.wait('@getRequests');
cy.contains('.slider-header', 'Recent Requests')
.next('[data-testid=media-slider]')
.find('[data-testid=request-card]')
.first()
.find('[data-testid=request-card-title]')
.contains('Movie Not Found');
});
it('loads plex watchlist', () => {
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as(
'getWatchlist'
);
// Wait for one of the watchlist movies to resolve
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
cy.visit('/');
cy.wait('@getWatchlist');
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
sliderHeader.scrollIntoView();
cy.wait('@getTmdbMovie');
// Wait a little longer to make sure the movie component reloaded
cy.wait(500);
sliderHeader
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.trigger('mouseover')
.find('[data-testid=title-card-title]')
.invoke('text')
.then((text) => {
cy.contains('.slider-header', 'Plex Watchlist')
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.click()
.click();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});

View File

@@ -1,13 +0,0 @@
describe('Login Page', () => {
it('succesfully logs in as an admin', () => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
cy.visit('/');
cy.contains('Trending');
});
it('succesfully logs in as a local user', () => {
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
cy.visit('/');
cy.contains('Trending');
});
});

View File

@@ -1,12 +0,0 @@
describe('Movie Details', () => {
it('loads a movie page', () => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Try to load minions: rise of gru
cy.visit('/movie/438148');
cy.get('[data-testid=media-title]').should(
'contain',
'Minions: The Rise of Gru (2022)'
);
});
});

View File

@@ -1,32 +0,0 @@
describe('General Settings', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('opens the settings page from the home page', () => {
cy.visit('/');
cy.get('[data-testid=sidebar-toggle]').click();
cy.get('[data-testid=sidebar-menu-settings-mobile]').click();
cy.get('.heading').should('contain', 'General Settings');
});
it('modifies setting that requires restart', () => {
cy.visit('/settings');
cy.get('#trustProxy').click();
cy.get('form').submit();
cy.get('[data-testid=modal-title]').should(
'contain',
'Server Restart Required'
);
cy.get('[data-testid=modal-ok-button]').click();
cy.get('[data-testid=modal-title]').should('not.exist');
cy.get('[type=checkbox]#trustProxy').click();
cy.get('form').submit();
cy.get('[data-testid=modal-title]').should('not.exist');
});
});

View File

@@ -1,12 +0,0 @@
describe('TV Details', () => {
it('loads a movie page', () => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Try to load stranger things
cy.visit('/tv/66732');
cy.get('[data-testid=media-title]').should(
'contain',
'Stranger Things (2016)'
);
});
});

View File

@@ -1,74 +0,0 @@
const visitUserEditPage = (email: string): void => {
cy.visit('/users');
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
};
describe('Auto Request Settings', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('should not see watchlist sync settings on an account without permissions', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.contains('Auto-Request Movies').should('not.exist');
cy.contains('Auto-Request Series').should('not.exist');
});
it('should see watchlist sync settings on an admin account', () => {
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
});
it('should see auto-request settings after being given permission', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
cy.contains('Save Changes').click();
cy.wait('@userPermissions');
cy.reload();
cy.get('#autorequest').should('be.checked');
cy.get('#autorequestmovies').should('be.checked');
cy.get('#autorequesttv').should('be.checked');
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
cy.get('#watchlistSyncTv').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.reload();
cy.get('#watchlistSyncMovies').should('be.checked').click();
cy.get('#watchlistSyncTv').should('be.checked').click();
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('be.checked').click();
cy.contains('Save Changes').click();
});
});

View File

@@ -1,73 +0,0 @@
const testUser = {
displayName: 'Test User',
emailAddress: 'test@seeerr.dev',
password: 'test1234',
};
describe('User List', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('opens the user list from the home page', () => {
cy.visit('/');
cy.get('[data-testid=sidebar-toggle]').click();
cy.get('[data-testid=sidebar-menu-users-mobile]').click();
cy.get('[data-testid=page-header]').should('contain', 'User List');
});
it('can find the admin user and friend user in the user list', () => {
cy.visit('/users');
cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL'));
cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL'));
});
it('can create a local user', () => {
cy.visit('/users');
cy.contains('Create Local User').click();
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
cy.get('#displayName').type(testUser.displayName);
cy.get('#email').type(testUser.emailAddress);
cy.get('#password').type(testUser.password);
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.get('[data-testid=modal-ok-button]').click();
cy.wait('@user');
// Wait a little longer for the user list to fully re-render
cy.wait(1000);
cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress);
});
it('can delete the created local test user', () => {
cy.visit('/users');
cy.contains('[data-testid=user-list-row]', testUser.emailAddress)
.contains('Delete')
.click();
cy.get('[data-testid=modal-title]').should(
'contain',
`Delete ${testUser.displayName}`
);
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
cy.wait('@user');
cy.wait(1000);
cy.get('[data-testid=user-list-row]')
.contains(testUser.emailAddress)
.should('not.exist');
});
});

View File

@@ -1,25 +0,0 @@
{
"page": 1,
"totalPages": 1,
"totalResults": 20,
"results": [
{
"ratingKey": "5d776be17a53e9001e732ab9",
"title": "Top Gun: Maverick",
"mediaType": "movie",
"tmdbId": 361743
},
{
"ratingKey": "5e16338fbc1372003ea68ab3",
"title": "Nope",
"mediaType": "movie",
"tmdbId": 762504
},
{
"ratingKey": "5f409b8452f200004161e126",
"title": "Hocus Pocus 2",
"mediaType": "movie",
"tmdbId": 642885
}
]
}

View File

@@ -1,26 +0,0 @@
/// <reference types="cypress" />
Cypress.Commands.add('login', (email, password) => {
cy.session(
[email, password],
() => {
cy.visit('/login');
cy.contains('Use your Overseerr account').click();
cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password);
cy.intercept('/api/v1/auth/local').as('localLogin');
cy.get('[data-testid=local-signin-button]').click();
cy.wait('@localLogin');
cy.url().should('contain', '/');
},
{
validate() {
cy.request('/api/v1/auth/me').its('status').should('eq', 200);
},
}
);
});

View File

@@ -1,7 +0,0 @@
import './commands';
before(() => {
if (Cypress.env('SEED_DATABASE')) {
cy.exec('yarn cypress:prepare');
}
});

View File

@@ -1,12 +0,0 @@
/* eslint-disable @typescript-eslint/no-namespace */
/// <reference types="cypress" />
declare global {
namespace Cypress {
interface Chainable {
login(email?: string, password?: string): Chainable<Element>;
}
}
}
export {};

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress", "node"],
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["**/*.ts"]
}

View File

@@ -8,7 +8,7 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
```
[Definition]
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
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

@@ -9,7 +9,7 @@
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter

View File

@@ -45,7 +45,7 @@ Overseerr currently supports the following agents:
- New Plex TV
- Legacy Plex TV
- TheTVDB
- TMDB
- TMDb
- [HAMA](https://github.com/ZeroQI/Hama.bundle)
Please verify that your library is using one of the agents previously listed.
@@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr
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"`
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"`

View File

@@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDB ID |
| `{{media_tmdbid}}` | The media's TMDb ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |

View File

@@ -1,6 +1,3 @@
/**
* @type {import('next').NextConfig}
*/
module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',

View File

@@ -1,8 +1,4 @@
import 'reflect-metadata';
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
import { DataSource } from 'typeorm';
const devConfig: DataSourceOptions = {
const devConfig = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
@@ -14,30 +10,31 @@ const devConfig: DataSourceOptions = {
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
cli: {
entitiesDir: 'server/entity',
migrationsDir: 'server/migration',
},
};
const prodConfig: DataSourceOptions = {
const prodConfig = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3',
synchronize: false,
migrationsRun: false,
logging: false,
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrationsRun: false,
subscribers: ['dist/subscriber/**/*.js'],
cli: {
entitiesDir: 'dist/entity',
migrationsDir: 'dist/migration',
},
};
const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
const finalConfig =
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig;
export const getRepository = <Entity>(
target: EntityTarget<Entity>
): Repository<Entity> => {
return dataSource.getRepository(target);
};
export default dataSource;
module.exports = finalConfig;

View File

@@ -1793,14 +1793,14 @@ components:
paths:
/status:
get:
summary: Get Overseerr status
description: Returns the current Overseerr status in a JSON object.
summary: Get Overseerr version
description: Returns the current Overseerr version in a JSON object.
security: []
tags:
- public
responses:
'200':
description: Returned status
description: Returned version
content:
application/json:
schema:
@@ -1811,12 +1811,6 @@ paths:
example: 1.0.0
commitTag:
type: string
updateAvailable:
type: boolean
commitsBehind:
type: number
restartRequired:
type: boolean
/status/appdata:
get:
summary: Get application data volume status
@@ -3176,8 +3170,8 @@ paths:
name: guid
required: true
schema:
type: string
example: '9afef5a7-ec89-4d5f-9397-261e96970b50'
type: number
example: 1
responses:
'200':
description: OK
@@ -4403,46 +4397,6 @@ paths:
name:
type: string
example: Genre Name
/discover/watchlist:
get:
summary: Get the Plex watchlist.
tags:
- search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Watchlist data returned
content:
application/json:
schema:
type: object
properties:
page:
type: number
totalPages:
type: number
totalResults:
type: number
results:
type: array
items:
type: object
properties:
tmdbId:
type: number
example: 1
ratingKey:
type: string
type:
type: string
title:
type: string
/request:
get:
summary: Get all requests
@@ -4470,16 +4424,7 @@ paths:
schema:
type: string
nullable: true
enum:
[
all,
approved,
available,
pending,
processing,
unavailable,
failed,
]
enum: [all, approved, available, pending, processing, unavailable]
- in: query
name: sort
schema:
@@ -5382,7 +5327,7 @@ paths:
$ref: '#/components/schemas/SonarrSeries'
/regions:
get:
summary: Regions supported by TMDB
summary: Regions supported by TMDb
description: Returns a list of regions in a JSON object.
tags:
- tmdb
@@ -5404,7 +5349,7 @@ paths:
example: United States of America
/languages:
get:
summary: Languages supported by TMDB
summary: Languages supported by TMDb
description: Returns a list of languages in a JSON object.
tags:
- tmdb
@@ -5469,7 +5414,7 @@ paths:
$ref: '#/components/schemas/ProductionCompany'
/genres/movie:
get:
summary: Get list of official TMDB movie genres
summary: Get list of official TMDb movie genres
description: Returns a list of genres in a JSON array.
tags:
- tmdb
@@ -5497,7 +5442,7 @@ paths:
example: Family
/genres/tv:
get:
summary: Get list of official TMDB movie genres
summary: Get list of official TMDb movie genres
description: Returns a list of genres in a JSON array.
tags:
- tmdb
@@ -5617,36 +5562,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/issue/count:
get:
summary: Gets issue counts
description: |
Returns the number of open and closed issues, as well as the number of issues of each type.
tags:
- issue
responses:
'200':
description: Issue counts returned
content:
application/json:
schema:
type: object
properties:
total:
type: number
video:
type: number
audio:
type: number
subtitles:
type: number
others:
type: number
open:
type: number
closed:
type: number
/issue/{issueId}:
get:
summary: Get issue

View File

@@ -1,27 +1,20 @@
{
"name": "overseerr",
"version": "0.1.0",
"version": "1.29.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",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates",
"build:next": "next build",
"build": "yarn build:next && yarn build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .",
"typecheck": "yarn typecheck:server && yarn typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
"typecheck:client": "tsc --noEmit",
"prepare": "husky install",
"cypress:open": "cypress open",
"cypress:prepare": "ts-node --files server/scripts/prepareTestDb.ts",
"cypress:build": "yarn build && yarn cypress:prepare"
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
"format": "prettier --write .",
"prepare": "husky install"
},
"repository": {
"type": "git",
@@ -29,137 +22,127 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.0.3",
"@formatjs/intl-locale": "3.0.3",
"@formatjs/intl-pluralrules": "5.0.3",
"@headlessui/react": "1.6.6",
"@heroicons/react": "1.0.6",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.3.1",
"@tanem/react-nprogress": "5.0.11",
"ace-builds": "1.9.6",
"axios": "0.27.2",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.0.1",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.2",
"country-flag-icons": "1.5.5",
"csurf": "1.11.0",
"date-fns": "2.29.1",
"email-templates": "9.0.0",
"express": "4.18.1",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.5.1",
"express-session": "1.17.3",
"formik": "2.2.9",
"gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21",
"next": "12.2.5",
"node-cache": "5.1.2",
"node-gyp": "9.1.0",
"node-schedule": "2.1.0",
"nodemailer": "6.7.8",
"openpgp": "5.4.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.0",
"react-intl": "6.0.5",
"react-markdown": "8.0.3",
"react-popper-tooltip": "4.4.2",
"react-select": "5.4.0",
"react-spring": "9.5.2",
"react-toast-notifications": "2.5.1",
"react-transition-group": "4.4.5",
"react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.8",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.3.7",
"sqlite3": "5.0.11",
"swagger-ui-express": "4.5.0",
"swr": "1.3.0",
"typeorm": "0.3.7",
"web-push": "3.5.0",
"winston": "3.8.1",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11"
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.6",
"@supercharge/request-ip": "^1.2.0",
"@svgr/webpack": "^6.2.1",
"@tanem/react-nprogress": "^4.0.10",
"ace-builds": "^1.4.14",
"axios": "^0.26.1",
"bcrypt": "^5.0.1",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.1",
"country-flag-icons": "^1.4.21",
"csurf": "^1.11.0",
"email-templates": "^8.0.10",
"express": "^4.17.3",
"express-openapi-validator": "^4.13.6",
"express-rate-limit": "^6.3.0",
"express-session": "^1.17.2",
"formik": "^2.2.9",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
"next": "12.1.0",
"node-cache": "^5.1.2",
"node-gyp": "^9.0.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.2",
"openpgp": "^5.2.0",
"plex-api": "^5.3.2",
"pug": "^3.0.2",
"react": "17.0.2",
"react-ace": "^9.5.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.2",
"react-intersection-observer": "^8.33.1",
"react-intl": "5.24.7",
"react-markdown": "^8.0.0",
"react-select": "^5.2.2",
"react-spring": "^9.4.4",
"react-toast-notifications": "^2.5.1",
"react-transition-group": "^4.4.2",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"reflect-metadata": "^0.1.13",
"secure-random-password": "^0.2.3",
"semver": "^7.3.5",
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.3.0",
"swr": "^1.2.2",
"typeorm": "0.2.45",
"web-push": "^3.4.5",
"winston": "^3.6.0",
"winston-daily-rotate-file": "^4.6.1",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/cli": "7.18.10",
"@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "17.0.3",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.0",
"@tailwindcss/forms": "0.5.2",
"@tailwindcss/typography": "0.5.4",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.13",
"@types/express-session": "1.17.4",
"@types/lodash": "4.14.183",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.5",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.12",
"@types/swagger-ui-express": "4.1.3",
"@types/web-push": "3.3.2",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.33.1",
"@typescript-eslint/parser": "5.33.1",
"autoprefixer": "10.4.8",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.2.5",
"copyfiles": "2.4.1",
"cypress": "10.6.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-formatjs": "4.1.0",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.30.1",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.1",
"lint-staged": "12.4.3",
"nodemon": "2.0.19",
"postcss": "8.4.16",
"prettier": "2.7.1",
"prettier-plugin-organize-imports": "3.1.0",
"prettier-plugin-tailwindcss": "0.1.13",
"semantic-release": "19.0.3",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.1.8",
"ts-node": "10.9.1",
"typescript": "4.7.4"
"@babel/cli": "^7.17.6",
"@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1",
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/typography": "^0.5.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.2",
"@types/email-templates": "^8.0.4",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/lodash": "^4.14.179",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/react": "^17.0.40",
"@types/react-dom": "^17.0.13",
"@types/react-transition-group": "^4.4.4",
"@types/secure-random-password": "^0.2.1",
"@types/semver": "^7.3.9",
"@types/swagger-ui-express": "^4.1.3",
"@types/web-push": "^3.3.2",
"@types/xml2js": "^0.4.9",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.13",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0",
"autoprefixer": "^10.4.2",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.4",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.11.0",
"eslint-config-next": "^12.1.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-formatjs": "^3.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-react-hooks": "^4.3.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "^7.0.4",
"lint-staged": "^12.3.5",
"nodemon": "^2.0.15",
"postcss": "^8.4.8",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.8",
"semantic-release": "^19.0.2",
"semantic-release-docker-buildx": "^1.0.1",
"tailwindcss": "^3.0.23",
"ts-node": "^10.7.0",
"typescript": "^4.6.2"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6"
"sqlite3/node-gyp": "^8.4.1"
},
"config": {
"commitizen": {

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:js-app",
"group:allNonMajor",
"docker:disableMajor",
"helpers:disableTypesNodeMajor"
],
"packageRules": [
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"groupSlug": "github-actions"
},
{
"matchPackageNames": ["node"],
"groupName": "Node.js",
"groupSlug": "node"
}
]
}

View File

@@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY
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
// 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 {

View File

@@ -1,7 +1,5 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
@@ -12,10 +10,6 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
}
class ExternalAPI {
@@ -37,14 +31,6 @@ class ExternalAPI {
...options.headers,
},
});
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {
maxRequests: options.rateLimit.maxRequests,
maxRPS: options.rateLimit.maxRPS,
});
}
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}

View File

@@ -1,6 +1,5 @@
import NodePlexAPI from 'plex-api';
import type { Library, PlexSettings } from '../lib/settings';
import { getSettings } from '../lib/settings';
import { getSettings, Library, PlexSettings } from '../lib/settings';
import logger from '../logger';
export interface PlexLibraryItem {
@@ -131,6 +130,7 @@ class PlexAPI {
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public async getStatus() {
return await this.plexClient.query('/');
}

View File

@@ -1,9 +1,8 @@
import axios, { AxiosInstance } from 'axios';
import xml2js from 'xml2js';
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
import cacheManager from '../lib/cache';
import { PlexDevice } from '../interfaces/api/plexInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@@ -112,54 +111,20 @@ interface UsersResponse {
};
}
interface WatchlistResponse {
MediaContainer: {
totalSize: number;
Metadata?: {
ratingKey: string;
}[];
};
}
interface MetadataResponse {
MediaContainer: {
Metadata: {
ratingKey: string;
type: 'movie' | 'show';
title: string;
Guid: {
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
}[];
}[];
};
}
export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'show';
title: string;
}
class PlexTvAPI extends ExternalAPI {
class PlexTvAPI {
private authToken: string;
private axios: AxiosInstance;
constructor(authToken: string) {
super(
'https://plex.tv',
{},
{
headers: {
'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('plextv').data,
}
);
this.authToken = authToken;
this.axios = axios.create({
baseURL: 'https://plex.tv',
headers: {
'X-Plex-Token': this.authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public async getDevices(): Promise<PlexDevice[]> {
@@ -287,83 +252,6 @@ class PlexTvAPI extends ExternalAPI {
)) as UsersResponse;
return parsedXml;
}
public async getWatchlist({
offset = 0,
size = 20,
}: { offset?: number; size?: number } = {}): Promise<{
offset: number;
size: number;
totalSize: number;
items: PlexWatchlistItem[];
}> {
try {
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
{
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
}
);
const watchlistDetails = await Promise.all(
(response.data.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
}
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
return {
offset,
size,
totalSize: response.data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {
logger.error('Failed to retrieve watchlist items', {
label: 'Plex.TV Metadata API',
errorMessage: e.message,
});
return {
offset,
size,
totalSize: 0,
items: [],
};
}
}
}
export default PlexTvAPI;

View File

@@ -1,6 +1,5 @@
import type { AvailableCacheIds } from '../../lib/cache';
import cacheManager from '../../lib/cache';
import type { DVRSettings } from '../../lib/settings';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { DVRSettings } from '../../lib/settings';
import ExternalAPI from '../externalapi';
export interface SystemStatus {

View File

@@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDB ID', {
logger.error('Error retrieving movie by TMDb ID', {
label: 'Radarr API',
errorMessage: e.message,
tmdbId: id,

View File

@@ -1,8 +1,7 @@
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import axios, { AxiosInstance } from 'axios';
import { uniqWith } from 'lodash';
import type { User } from '../entity/User';
import type { TautulliSettings } from '../lib/settings';
import { User } from '../entity/User';
import { TautulliSettings } from '../lib/settings';
import logger from '../logger';
export interface TautulliHistoryRecord {

View File

@@ -1,7 +1,7 @@
import { sortBy } from 'lodash';
import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi';
import type {
import {
TmdbCollection,
TmdbExternalIdResponse,
TmdbGenre,
@@ -92,10 +92,6 @@ class TheMovieDb extends ExternalAPI {
},
{
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 50,
},
}
);
this.region = region;
@@ -133,13 +129,7 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: {
query,
page,
include_adult: includeAdult,
language,
primary_release_year: year,
},
params: { query, page, include_adult: includeAdult, language, year },
});
return data;
@@ -196,7 +186,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
}
};
@@ -218,7 +208,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch person combined credits: ${e.message}`
`[TMDb] Failed to fetch person combined credits: ${e.message}`
);
}
};
@@ -245,7 +235,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
}
};
@@ -271,7 +261,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -297,7 +287,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
}
};
@@ -323,7 +313,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -349,7 +339,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
}
@@ -375,7 +365,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
}
}
@@ -402,7 +392,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV recommendations: ${e.message}`
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
);
}
}
@@ -426,7 +416,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
}
}
@@ -459,7 +449,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
}
};
@@ -492,7 +482,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
}
};
@@ -518,7 +508,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
}
};
@@ -545,7 +535,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -568,7 +558,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -591,7 +581,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
}
};
@@ -623,7 +613,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
}
}
@@ -661,7 +651,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
} catch (e) {
throw new Error(
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`
`[TMDb] Failed to find media using external IMDb ID: ${e.message}`
);
}
}
@@ -691,7 +681,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No show returned from API for ID ${tvdbId}`);
} catch (e) {
throw new Error(
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}
@@ -715,7 +705,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
}
}
@@ -731,7 +721,7 @@ class TheMovieDb extends ExternalAPI {
return regions;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
}
}
@@ -747,7 +737,7 @@ class TheMovieDb extends ExternalAPI {
return languages;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
}
}
@@ -759,7 +749,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
}
}
@@ -769,7 +759,7 @@ class TheMovieDb extends ExternalAPI {
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
}
}
@@ -820,7 +810,7 @@ class TheMovieDb extends ExternalAPI {
return movieGenres;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
}
}
@@ -871,7 +861,7 @@ class TheMovieDb extends ExternalAPI {
return tvGenres;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
}
}
}

View File

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

View File

@@ -7,8 +7,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type { IssueType } from '../constants/issue';
import { IssueStatus } from '../constants/issue';
import { IssueStatus, IssueType } from '../constants/issue';
import IssueComment from './IssueComment';
import Media from './Media';
import { User } from './User';

View File

@@ -3,6 +3,7 @@ import {
Column,
CreateDateColumn,
Entity,
getRepository,
In,
Index,
OneToMany,
@@ -12,9 +13,7 @@ import {
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import type { DownloadingItem } from '../lib/downloadtracker';
import downloadTracker from '../lib/downloadtracker';
import { getRepository } from '../datasource';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
@@ -37,7 +36,7 @@ class Media {
}
const media = await mediaRepository.find({
where: { tmdbId: In(finalIds) },
tmdbId: In(finalIds),
});
return media;
@@ -56,10 +55,10 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
relations: { requests: true, issues: true },
relations: ['requests', 'issues'],
});
return media ?? undefined;
return media;
} catch (e) {
logger.error(e.message);
return undefined;
@@ -146,9 +145,6 @@ class Media {
public plexUrl?: string;
public plexUrl4k?: string;
public iOSPlexUrl?: string;
public iOSPlexUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
@@ -168,8 +164,6 @@ class Media {
this.ratingKey
}`;
this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`;
if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
}
@@ -182,8 +176,6 @@ class Media {
this.ratingKey4k
}`;
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}

View File

@@ -6,360 +6,30 @@ import {
Column,
CreateDateColumn,
Entity,
getRepository,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import type { RadarrMovieOptions } from '../api/servarr/radarr';
import RadarrAPI from '../api/servarr/radarr';
import type { AddSeriesOptions, SonarrSeries } from '../api/servarr/sonarr';
import SonarrAPI from '../api/servarr/sonarr';
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
import SonarrAPI, {
AddSeriesOptions,
SonarrSeries,
} from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import { getRepository } from '../datasource';
import type { MediaRequestBody } from '../interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Media from './Media';
import SeasonRequest from './SeasonRequest';
import { User } from './User';
export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
};
@Entity()
export class MediaRequest {
public static async request(
requestBody: MediaRequestBody,
user: User,
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
let requestUser = user;
if (
requestBody.userId &&
!requestUser.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
throw new RequestPermissionError(
'You do not have permission to modify the request user.'
);
} else if (requestBody.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: requestBody.userId },
});
}
if (!requestUser) {
throw new Error('User missing from request context.');
}
if (
requestBody.mediaType === MediaType.MOVIE &&
!requestUser.hasPermission(
requestBody.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make ${
requestBody.is4k ? '4K ' : ''
}movie requests.`
);
} else if (
requestBody.mediaType === MediaType.TV &&
!requestUser.hasPermission(
requestBody.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
: [Permission.REQUEST, Permission.REQUEST_TV],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make ${
requestBody.is4k ? '4K ' : ''
}series requests.`
);
}
const quotas = await requestUser.getQuota();
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
const tmdbMedia =
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({
where: {
tmdbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING;
}
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
}
}
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany();
if (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one.
if (
requestBody.mediaType === MediaType.MOVIE &&
existing[0].status !== MediaRequestStatus.DECLINED
) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request',
});
throw new DuplicateMediaRequestError(
'Request for this media already exists.'
);
}
// If an existing auto-request for this media exists from the same user,
// don't allow a new one.
if (
existing.find(
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
)
) {
throw new DuplicateMediaRequestError(
'Auto-request for this media and user already exists.'
);
}
}
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
} else {
const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]);
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
// (Unless there are no seasons, in which case we abort)
if (media.requests) {
existingSeasons = media.requests
.filter(
(request) =>
request.is4k === requestBody.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
}
// We should also check seasons that are available/partially available but don't have existing requests
if (media.seasons) {
existingSeasons = [
...existingSeasons,
...media.seasons
.filter(
(season) =>
season[requestBody.is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
)
.map((season) => season.seasonNumber),
];
}
const finalSeasons = requestedSeasons.filter(
(rs) => !existingSeasons.includes(rs)
);
if (finalSeasons.length === 0) {
throw new NoSeasonsAvailableError('No seasons available to request');
} else if (
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.TV,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
languageProfileId: requestBody.languageProfileId,
tags: requestBody.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: user.hasPermission(
[
requestBody.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
requestBody.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
}
}
@PrimaryGeneratedColumn()
public id: number;
@@ -450,9 +120,6 @@ export class MediaRequest {
})
public tags?: number[];
@Column({ default: false })
public isAutoRequest: boolean;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@@ -540,7 +207,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: { requests: true },
relations: ['requests'],
});
if (!media) {
logger.error('Media data not found', {
@@ -605,7 +272,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id },
relations: { requests: true },
relations: ['requests'],
});
if (
@@ -785,13 +452,10 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED',
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
{
label: 'Media Request',
requestId: this.id,
@@ -879,7 +543,7 @@ export class MediaRequest {
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: { requests: true },
relations: ['requests'],
});
if (!media) {
@@ -1006,7 +670,7 @@ export class MediaRequest {
// 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: true },
relations: ['requests'],
});
if (!media) {
@@ -1021,13 +685,10 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED',
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
{
label: 'Media Request',
requestId: this.id,

View File

@@ -1,4 +1,4 @@
import type { ISession } from 'connect-typeorm';
import { ISession } from 'connect-typeorm';
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
@Entity()

View File

@@ -7,6 +7,8 @@ import {
Column,
CreateDateColumn,
Entity,
getRepository,
MoreThan,
Not,
OneToMany,
OneToOne,
@@ -16,14 +18,15 @@ import {
} from 'typeorm';
import { MediaRequestStatus, MediaType } from '../constants/media';
import { UserType } from '../constants/user';
import { getRepository } from '../datasource';
import type { QuotaResponse } from '../interfaces/api/userInterfaces';
import { QuotaResponse } from '../interfaces/api/userInterfaces';
import PreparedEmail from '../lib/email';
import type { PermissionCheckOptions } from '../lib/permissions';
import { hasPermission, Permission } from '../lib/permissions';
import {
hasPermission,
Permission,
PermissionCheckOptions,
} from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { AfterDate } from '../utils/dateHelpers';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
@@ -252,14 +255,13 @@ export class User {
if (movieQuotaDays) {
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
}
const movieQuotaStartDate = movieDate.toJSON();
const movieQuotaUsed = movieQuotaLimit
? await requestRepository.count({
where: {
requestedBy: {
id: this.id,
},
createdAt: AfterDate(movieDate),
requestedBy: this,
createdAt: MoreThan(movieQuotaStartDate),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
},

View File

@@ -5,7 +5,7 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import type { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
import { hasNotificationType, Notification } from '../lib/notifications';
import { NotificationAgentKey } from '../lib/settings';
import { User } from './User';
@@ -57,12 +57,6 @@ export class UserSettings {
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({ nullable: true })
public watchlistSyncMovies?: boolean;
@Column({ nullable: true })
public watchlistSyncTv?: boolean;
@Column({
type: 'text',
nullable: true,

View File

@@ -2,17 +2,15 @@ import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import express, { NextFunction, Request, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import session, { Store } from 'express-session';
import next from 'next';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import { createConnection, getRepository } from 'typeorm';
import YAML from 'yamljs';
import PlexAPI from './api/plexapi';
import dataSource, { getRepository } from './datasource';
import { Session } from './entity/Session';
import { User } from './entity/User';
import { startJobs } from './job/schedule';
@@ -31,7 +29,6 @@ import { getSettings } from './lib/settings';
import logger from './logger';
import routes from './routes';
import { getAppVersion } from './utils/appVersion';
import restartFlag from './utils/restartFlag';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -43,7 +40,7 @@ const handle = app.getRequestHandler();
app
.prepare()
.then(async () => {
const dbConnection = await dataSource.initialize();
const dbConnection = await createConnection();
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
@@ -54,7 +51,6 @@ app
// Load Settings
const settings = getSettings().load();
restartFlag.initializeSettings(settings.main);
// Migrate library types
if (
@@ -63,8 +59,8 @@ app
) {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (admin) {

View File

@@ -3,10 +3,3 @@ export interface GenreSliderItem {
name: string;
backdrops: string[];
}
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
}

View File

@@ -1,5 +1,5 @@
import type Issue from '../../entity/Issue';
import type { PaginatedResponse } from './common';
import Issue from '../../entity/Issue';
import { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];

View File

@@ -1,6 +1,6 @@
import type Media from '../../entity/Media';
import type { User } from '../../entity/User';
import type { PaginatedResponse } from './common';
import { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse {
results: Media[];

View File

@@ -1,4 +1,4 @@
import type { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
export interface PersonCombinedCreditsResponse {
id: number;

View File

@@ -1,4 +1,4 @@
import type { PlexSettings } from '../../lib/settings';
import { PlexSettings } from '../../lib/settings';
export interface PlexStatus {
settings: PlexSettings;

View File

@@ -1,21 +1,6 @@
import type { PaginatedResponse } from './common';
import type { MediaRequest } from '../../entity/MediaRequest';
import type { MediaType } from '../../constants/media';
export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[];
}
export type MediaRequestBody = {
mediaType: MediaType;
mediaId: number;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
serverId?: number;
profileId?: number;
rootFolder?: string;
languageProfileId?: number;
userId?: number;
tags?: number[];
};

View File

@@ -1,5 +1,5 @@
import type { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
import type { LanguageProfile } from '../../api/servarr/sonarr';
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
import { LanguageProfile } from '../../api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;

View File

@@ -56,5 +56,4 @@ export interface StatusResponse {
commitTag: string;
updateAvailable: boolean;
commitsBehind: number;
restartRequired: boolean;
}

View File

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

View File

@@ -1,4 +1,4 @@
import type { NotificationAgentKey } from '../../lib/settings';
import { NotificationAgentKey } from '../../lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
@@ -14,8 +14,6 @@ export interface UserSettingsGeneralResponse {
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;
}
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;

View File

@@ -3,9 +3,7 @@ import downloadTracker from '../lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
import type { JobId } from '../lib/settings';
import { getSettings } from '../lib/settings';
import watchlistSync from '../lib/watchlistsync';
import { getSettings, JobId } from '../lib/settings';
import logger from '../logger';
interface ScheduledJob {
@@ -55,20 +53,6 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// Run watchlist sync every 5 minutes
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'long',
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
});
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',

View File

@@ -6,8 +6,7 @@ export type AvailableCacheIds =
| 'sonarr'
| 'rt'
| 'github'
| 'plexguid'
| 'plextv';
| 'plexguid';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -59,10 +58,6 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60 * 30,
}),
plextv: new Cache('plextv', 'Plex TV', {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -1,8 +1,7 @@
import Email from 'email-templates';
import nodemailer from 'nodemailer';
import { URL } from 'url';
import type { NotificationAgentEmail } from '../settings';
import { getSettings } from '../settings';
import { getSettings, NotificationAgentEmail } from '../settings';
import { openpgpEncrypt } from './openpgpEncrypt';
class PreparedEmail extends Email {

View File

@@ -1,7 +1,6 @@
import { randomBytes } from 'crypto';
import * as openpgp from 'openpgp';
import type { TransformCallback } from 'stream';
import { Transform } from 'stream';
import { Transform, TransformCallback } from 'stream';
import logger from '../../logger';
interface EncryptorOptions {
@@ -27,7 +26,7 @@ class PGPEncryptor extends Transform {
// just save the whole message
_transform = (
chunk: Uint8Array,
chunk: any,
_encoding: BufferEncoding,
callback: TransformCallback
): void => {
@@ -185,9 +184,6 @@ class PGPEncryptor extends Transform {
}
export const openpgpEncrypt = (options: EncryptorOptions) => {
// Disabling this line because I don't want to fix it but I am tired
// of seeing the lint warning
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (mail: any, callback: () => unknown): void {
if (!options.encryptionKeys.length) {
setImmediate(callback);

View File

@@ -1,10 +1,10 @@
import type { Notification } from '..';
import { Notification } from '..';
import type Issue from '../../../entity/Issue';
import type IssueComment from '../../../entity/IssueComment';
import type Media from '../../../entity/Media';
import type { MediaRequest } from '../../../entity/MediaRequest';
import type { User } from '../../../entity/User';
import type { NotificationAgentConfig } from '../../settings';
import IssueComment from '../../../entity/IssueComment';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
event?: string;

View File

@@ -1,17 +1,19 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import type { NotificationAgentDiscord } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentDiscord,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
DEFAULT = 0,

View File

@@ -1,16 +1,18 @@
import type { EmailOptions } from 'email-templates';
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import type { NotificationAgentEmail } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentEmail,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>

View File

@@ -2,16 +2,14 @@ import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import type { NotificationAgentGotify } from '../../settings';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import { getSettings, NotificationAgentGotify } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface GotifyPayload {
title: string;
message: string;
priority: number;
extras: Record<string, unknown>;
extras: any;
}
class GotifyAgent

View File

@@ -3,10 +3,8 @@ import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import type { NotificationAgentLunaSea } from '../../settings';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>

View File

@@ -1,17 +1,19 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import type { NotificationAgentPushbullet } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushbullet,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushbulletPayload {
type: string;

View File

@@ -1,17 +1,19 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import type { NotificationAgentPushover } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushover,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushoverPayload {
token: string;

View File

@@ -2,10 +2,8 @@ import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import type { NotificationAgentSlack } from '../../settings';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';

View File

@@ -1,17 +1,19 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import type { NotificationAgentTelegram } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentKey,
NotificationAgentTelegram,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload {
text: string;

View File

@@ -4,10 +4,8 @@ import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import type { NotificationAgentWebhook } from '../../settings';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import { getSettings, NotificationAgentWebhook } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
type KeyMapFunction = (
payload: NotificationPayload,

View File

@@ -1,15 +1,17 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { getRepository } from '../../../datasource';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import type { NotificationAgentConfig } from '../../settings';
import { getSettings, NotificationAgentKey } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
import {
getSettings,
NotificationAgentConfig,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload {
notificationType: string;
@@ -158,7 +160,7 @@ class WebPushAgent
true)
) {
const notifySubs = await userPushSubRepository.find({
where: { user: { id: payload.notifyUser.id } },
where: { user: payload.notifyUser.id },
});
pushSubs.push(...notifySubs);

View File

@@ -1,4 +1,4 @@
import type { User } from '../../entity/User';
import { User } from '../../entity/User';
import logger from '../../logger';
import { Permission } from '../permissions';
import type { NotificationAgent, NotificationPayload } from './agents/agent';

View File

@@ -1,6 +1,7 @@
export enum Permission {
NONE = 0,
ADMIN = 2,
MANAGE_SETTINGS = 4,
MANAGE_USERS = 8,
MANAGE_REQUESTS = 16,
REQUEST = 32,
@@ -21,10 +22,6 @@ export enum Permission {
MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304,
AUTO_REQUEST = 8388608,
AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
}
export interface PermissionCheckOptions {

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { getRepository } from 'typeorm';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import { getRepository } from '../../datasource';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
@@ -210,7 +210,7 @@ class BaseScanner<T> {
}
/**
* processShow takes a TMDB ID and an array of ProcessableSeasons, which
* 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

View File

@@ -1,20 +1,17 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import type { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import PlexAPI from '../../../api/plexapi';
import type { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { getRepository } from '../../../datasource';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import cacheManager from '../../cache';
import type { Library } from '../../settings';
import { getSettings } from '../../settings';
import type {
import { getSettings, Library } from '../../settings';
import BaseScanner, {
MediaIds,
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
import BaseScanner from '../baseScanner';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
@@ -62,8 +59,8 @@ class PlexScanner
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
@@ -144,9 +141,7 @@ class PlexScanner
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', {
errorMessage: e.message,
});
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
@@ -374,7 +369,7 @@ class PlexScanner
}
});
// If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
@@ -395,7 +390,7 @@ class PlexScanner
});
mediaIds.tmdbId = tmdbMedia.id;
}
// Check if the agent is TMDB
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
@@ -414,7 +409,7 @@ class PlexScanner
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDB
// Check if the agent (for shows) is TMDb
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
@@ -489,10 +484,10 @@ class PlexScanner
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDB ID');
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
// We check above if we have the TMDb ID, so we can safely assert the type below
return mediaIds as MediaIds;
}

View File

@@ -1,10 +1,7 @@
import { uniqWith } from 'lodash';
import type { RadarrMovie } from '../../../api/servarr/radarr';
import RadarrAPI from '../../../api/servarr/radarr';
import type { RadarrSettings } from '../../settings';
import { getSettings } from '../../settings';
import type { RunnableScanner, StatusBase } from '../baseScanner';
import BaseScanner from '../baseScanner';
import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: RadarrSettings;

View File

@@ -1,17 +1,14 @@
import { uniqWith } from 'lodash';
import type { SonarrSeries } from '../../../api/servarr/sonarr';
import SonarrAPI from '../../../api/servarr/sonarr';
import type { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { getRepository } from '../../../datasource';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import Media from '../../../entity/Media';
import type { SonarrSettings } from '../../settings';
import { getSettings } from '../../settings';
import type {
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
import BaseScanner from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '../api/themoviedb';
import type {
import {
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,

View File

@@ -243,7 +243,6 @@ interface JobSettings {
export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@@ -399,9 +398,6 @@ class Settings {
'plex-full-scan': {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',
},

View File

@@ -1,165 +0,0 @@
import { Not } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { User } from '../entity/User';
import Media from '../entity/Media';
import logger from '../logger';
import { MediaType } from '../constants/media';
import { MediaStatus } from '../constants/media';
import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QuotaRestrictedError,
RequestPermissionError,
} from '../entity/MediaRequest';
import { Permission } from './permissions';
import { getRepository } from '../datasource';
class WatchlistSync {
public async syncWatchlist() {
const userRepository = getRepository(User);
// Get users who actually have plex tokens
const users = await userRepository.find({
select: { id: true, plexToken: true, permissions: true },
where: {
plexToken: Not(''),
},
});
for (const user of users) {
await this.syncUserWatchlist(user);
}
}
private async syncUserWatchlist(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user watchlist sync for user without plex token', {
label: 'Plex Watchlist Sync',
userId: user.id,
});
return;
}
if (
!user.hasPermission(
[
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_APPROVE_TV,
],
{ type: 'or' }
)
) {
return;
}
if (
!user.settings?.watchlistSyncMovies &&
!user.settings?.watchlistSyncTv
) {
// Skip sync if user settings have it disabled
return;
}
const plexTvApi = new PlexTvAPI(user.plexToken);
const response = await plexTvApi.getWatchlist({ size: 200 });
const mediaItems = await Media.getRelatedMedia(
response.items.map((i) => i.tmdbId)
);
const unavailableItems = response.items.filter(
// If we can find watchlist items in our database that are also available, we should exclude them
(i) =>
!mediaItems.find(
(m) =>
m.tmdbId === i.tmdbId &&
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
)
);
await Promise.all(
unavailableItems.map(async (mediaItem) => {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
return;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
return;
}
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. Since we are auto syncing these constantly, it's
// possible they are unexpectedly at their quota limit, for example. So we'll
// instead log these as debug messages.
case RequestPermissionError:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
}
}
})
);
}
}
const watchlistSync = new WatchlistSync();
export default watchlistSync;

View File

@@ -26,7 +26,7 @@ const hformat = winston.format.printf(
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL?.toLowerCase() || 'debug',
level: process.env.LOG_LEVEL || 'debug',
format: winston.format.combine(
winston.format.splat(),
winston.format.timestamp(),

View File

@@ -1,11 +1,11 @@
import { getRepository } from '../datasource';
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import type { Permission, PermissionCheckOptions } from '../lib/permissions';
import { Permission, PermissionCheckOptions } from '../lib/permissions';
import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
const settings = getSettings();
let user: User | undefined | null;
let user: User | undefined;
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialMigration1603944374840 implements MigrationInterface {
name = 'InitialMigration1603944374840';

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeasonStatus1605085519544 implements MigrationInterface {
name = 'SeasonStatus1605085519544';

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CascadeMigration1606730060700 implements MigrationInterface {
name = 'CascadeMigration1606730060700';

View File

@@ -1,5 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { TableUnique } from 'typeorm';
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface

View File

@@ -1,4 +1,4 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface

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