Compare commits
79 Commits
v1.29.0
...
feature/wa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb9e78ccb | ||
|
|
1b98fd0aac | ||
|
|
e72f05b311 | ||
|
|
7804030f2d | ||
|
|
46a605c9af | ||
|
|
dcba13eeb9 | ||
|
|
a505bd7d4d | ||
|
|
8e66516650 | ||
|
|
11991c4131 | ||
|
|
9efa91563b | ||
|
|
fa50e3ccb7 | ||
|
|
0bb6c65c28 | ||
|
|
c4e47ae763 | ||
|
|
eada54e7be | ||
|
|
7490bbfc9e | ||
|
|
f53a3bc10b | ||
|
|
74e928e6e9 | ||
|
|
b11fc4b9c0 | ||
|
|
6e96f4ce52 | ||
|
|
4e6c9ec545 | ||
|
|
d435418d3b | ||
|
|
928cd5b999 | ||
|
|
7943e0c339 | ||
|
|
6ce0aa5b10 | ||
|
|
a0301e2d83 | ||
|
|
9021696cf0 | ||
|
|
9bc1f89777 | ||
|
|
a12697b061 | ||
|
|
5247f14968 | ||
|
|
fd0ff4bd5f | ||
|
|
16545eec22 | ||
|
|
36d17fed6e | ||
|
|
ac34328074 | ||
|
|
91e0928aa0 | ||
|
|
f836cadd23 | ||
|
|
f4910a1483 | ||
|
|
103c4ca49c | ||
|
|
c143c0b8d2 | ||
|
|
e5d8c93ab8 | ||
|
|
72d7a3477f | ||
|
|
808fabba9a | ||
|
|
7a5fab35ff | ||
|
|
17ac5069e5 | ||
|
|
cfab63c0ca | ||
|
|
0fa84eae8d | ||
|
|
821bb79d83 | ||
|
|
233035dbd7 | ||
|
|
114943ae2c | ||
|
|
a6f7b19693 | ||
|
|
3db3044210 | ||
|
|
1fcfe93b58 | ||
|
|
6cb456cb69 | ||
|
|
e939dc678e | ||
|
|
f3e56da3b7 | ||
|
|
70dc4c4b3b | ||
|
|
6428b8d419 | ||
|
|
004e1bb17e | ||
|
|
ebd22ffcea | ||
|
|
22ec058431 | ||
|
|
db898db9f2 | ||
|
|
b33956e6b8 | ||
|
|
f5864b49de | ||
|
|
25eb765f9b | ||
|
|
9da8461225 | ||
|
|
aed1409f29 | ||
|
|
575da306b0 | ||
|
|
6cd0c9b2c8 | ||
|
|
a6c1f3f7ce | ||
|
|
eb5248d8d1 | ||
|
|
db05172d8b | ||
|
|
90095bb185 | ||
|
|
14519ef555 | ||
|
|
1054b4e2d7 | ||
|
|
e4039d09c0 | ||
|
|
29be659512 | ||
|
|
475314c87b | ||
|
|
1d00229a48 | ||
|
|
b2878390b4 | ||
|
|
b75fc7b238 |
@@ -665,6 +665,78 @@
|
||||
"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>",
|
||||
@@ -673,5 +745,5 @@
|
||||
"projectOwner": "sct",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
"skipCi": false
|
||||
}
|
||||
|
||||
@@ -26,3 +26,4 @@ public/os_logo_filled.png
|
||||
public/preview.jpg
|
||||
snap
|
||||
stylelint.config.js
|
||||
cypress
|
||||
|
||||
11
.eslintrc.js
11
.eslintrc.js
@@ -7,6 +7,7 @@ module.exports = {
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'prettier',
|
||||
],
|
||||
parserOptions: {
|
||||
@@ -26,11 +27,17 @@ 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: [
|
||||
{
|
||||
@@ -40,7 +47,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'],
|
||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs'],
|
||||
settings: {
|
||||
react: {
|
||||
pragma: 'React',
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -1,14 +0,0 @@
|
||||
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
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -13,16 +13,18 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:16.14-alpine
|
||||
container: node:16.17-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
HUSKY: 0
|
||||
run: yarn
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
- name: Formatting
|
||||
run: yarn format:check
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
@@ -34,29 +36,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -86,7 +88,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
|
||||
30
.github/workflows/cypress.yml
vendored
Normal file
30
.github/workflows/cypress.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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}}
|
||||
10
.github/workflows/preview.yml
vendored
10
.github/workflows/preview.yml
vendored
@@ -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@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -9,8 +9,6 @@ jobs:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
runs-on: ubuntu-20.04
|
||||
env:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -21,16 +19,16 @@ jobs:
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -74,7 +72,7 @@ jobs:
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
- name: Build Snap Package
|
||||
@@ -83,7 +81,7 @@ jobs:
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
@@ -105,7 +103,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
|
||||
90
.github/workflows/snap.yaml
vendored
Normal file
90
.github/workflows/snap.yaml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
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
11
.gitignore
vendored
@@ -53,3 +53,14 @@ 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
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -11,9 +11,6 @@
|
||||
// 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",
|
||||
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,8 +15,5 @@
|
||||
"database": "./config/db/db.sqlite3"
|
||||
}
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
||||
1265
CHANGELOG.md
1265
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has 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., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.14-alpine AS BUILD_IMAGE
|
||||
FROM node:16.17-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,7 +14,7 @@ RUN \
|
||||
esac
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 1000000
|
||||
RUN CYPRESS_INSTALL_BINARY=0 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.14-alpine
|
||||
FROM node:16.17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.14-alpine
|
||||
FROM node:16.17-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
12
README.md
12
README.md
@@ -12,7 +12,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-72-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-80-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -167,6 +167,16 @@ 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>
|
||||
|
||||
|
||||
19
cypress.config.ts
Normal file
19
cypress.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
149
cypress/config/settings.cypress.json
Normal file
149
cypress/config/settings.cypress.json
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"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 * * *"
|
||||
}
|
||||
}
|
||||
}
|
||||
211
cypress/e2e/discover.cy.ts
Normal file
211
cypress/e2e/discover.cy.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
cypress/e2e/login.cy.ts
Normal file
13
cypress/e2e/login.cy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
12
cypress/e2e/movie-details.cy.ts
Normal file
12
cypress/e2e/movie-details.cy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
12
cypress/e2e/tv-details.cy.ts
Normal file
12
cypress/e2e/tv-details.cy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
73
cypress/e2e/user/user-list.cy.ts
Normal file
73
cypress/e2e/user/user-list.cy.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
25
cypress/fixtures/watchlist.json
Normal file
25
cypress/fixtures/watchlist.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
26
cypress/support/commands.ts
Normal file
26
cypress/support/commands.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/// <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);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
7
cypress/support/e2e.ts
Normal file
7
cypress/support/e2e.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import './commands';
|
||||
|
||||
before(() => {
|
||||
if (Cypress.env('SEED_DATABASE')) {
|
||||
cy.exec('yarn cypress:prepare');
|
||||
}
|
||||
});
|
||||
12
cypress/support/index.ts
Normal file
12
cypress/support/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
login(email?: string, password?: string): Chainable<Element>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
10
cypress/tsconfig.json
Normal file
10
cypress/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
|
||||
|
||||
```
|
||||
[Definition]
|
||||
failregex = .*\[info\]\[Auth\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
||||
failregex = .*\[warn\]\[API\]\: 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
env: {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
|
||||
@@ -1793,14 +1793,14 @@ components:
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
summary: Get Overseerr version
|
||||
description: Returns the current Overseerr version in a JSON object.
|
||||
summary: Get Overseerr status
|
||||
description: Returns the current Overseerr status in a JSON object.
|
||||
security: []
|
||||
tags:
|
||||
- public
|
||||
responses:
|
||||
'200':
|
||||
description: Returned version
|
||||
description: Returned status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1811,6 +1811,12 @@ 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
|
||||
@@ -3170,8 +3176,8 @@ paths:
|
||||
name: guid
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
type: string
|
||||
example: '9afef5a7-ec89-4d5f-9397-261e96970b50'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -4397,6 +4403,46 @@ 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
|
||||
@@ -4424,7 +4470,16 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [all, approved, available, pending, processing, unavailable]
|
||||
enum:
|
||||
[
|
||||
all,
|
||||
approved,
|
||||
available,
|
||||
pending,
|
||||
processing,
|
||||
unavailable,
|
||||
failed,
|
||||
]
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
@@ -5327,7 +5382,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
|
||||
@@ -5349,7 +5404,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
|
||||
@@ -5414,7 +5469,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
|
||||
@@ -5442,7 +5497,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
|
||||
@@ -5562,6 +5617,36 @@ 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
|
||||
|
||||
265
package.json
265
package.json
@@ -1,20 +1,27 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.29.0",
|
||||
"version": "0.1.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}\"",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"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",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,127 +29,137 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^8.4.1"
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
21
renovate.json
Normal file
21
renovate.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import NodeCache from 'node-cache';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'axios-rate-limit';
|
||||
import type NodeCache from 'node-cache';
|
||||
|
||||
// 5 minute default TTL (in seconds)
|
||||
const DEFAULT_TTL = 300;
|
||||
@@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
};
|
||||
}
|
||||
|
||||
class ExternalAPI {
|
||||
@@ -31,6 +37,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NodePlexAPI from 'plex-api';
|
||||
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
||||
import type { Library, PlexSettings } from '../lib/settings';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
@@ -130,7 +131,6 @@ class PlexAPI {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import { PlexDevice } from '../interfaces/api/plexInterfaces';
|
||||
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
|
||||
import cacheManager from '../lib/cache';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface PlexAccountResponse {
|
||||
user: PlexUser;
|
||||
@@ -111,20 +112,54 @@ interface UsersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class PlexTvAPI {
|
||||
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 {
|
||||
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[]> {
|
||||
@@ -252,6 +287,83 @@ class PlexTvAPI {
|
||||
)) 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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||
import { DVRSettings } from '../../lib/settings';
|
||||
import type { AvailableCacheIds } from '../../lib/cache';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import type { DVRSettings } from '../../lib/settings';
|
||||
import ExternalAPI from '../externalapi';
|
||||
|
||||
export interface SystemStatus {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { User } from '../entity/User';
|
||||
import { TautulliSettings } from '../lib/settings';
|
||||
import type { User } from '../entity/User';
|
||||
import type { TautulliSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface TautulliHistoryRecord {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import {
|
||||
import type {
|
||||
TmdbCollection,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
@@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.region = region;
|
||||
@@ -129,7 +133,13 @@ class TheMovieDb extends ExternalAPI {
|
||||
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||
params: { query, page, include_adult: includeAdult, language, year },
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
primary_release_year: year,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -186,7 +196,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,7 +218,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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -235,7 +245,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,7 +271,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -287,7 +297,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,7 +323,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +349,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +375,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +402,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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -416,7 +426,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +459,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -482,7 +492,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -508,7 +518,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,7 +545,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -558,7 +568,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -581,7 +591,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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -613,7 +623,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,7 +661,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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -681,7 +691,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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -705,7 +715,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,7 +731,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,7 +747,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,7 +759,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +769,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +820,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,7 +871,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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum MediaRequestStatus {
|
||||
PENDING = 1,
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const devConfig = {
|
||||
import 'reflect-metadata';
|
||||
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const devConfig: DataSourceOptions = {
|
||||
type: 'sqlite',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||
@@ -10,31 +14,30 @@ const devConfig = {
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
},
|
||||
};
|
||||
|
||||
const prodConfig = {
|
||||
const prodConfig: DataSourceOptions = {
|
||||
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 finalConfig =
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig;
|
||||
const dataSource = new DataSource(
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||
);
|
||||
|
||||
module.exports = finalConfig;
|
||||
export const getRepository = <Entity>(
|
||||
target: EntityTarget<Entity>
|
||||
): Repository<Entity> => {
|
||||
return dataSource.getRepository(target);
|
||||
};
|
||||
|
||||
export default dataSource;
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { IssueStatus, IssueType } from '../constants/issue';
|
||||
import type { IssueType } from '../constants/issue';
|
||||
import { IssueStatus } from '../constants/issue';
|
||||
import IssueComment from './IssueComment';
|
||||
import Media from './Media';
|
||||
import { User } from './User';
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
In,
|
||||
Index,
|
||||
OneToMany,
|
||||
@@ -13,7 +12,9 @@ import {
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import type { DownloadingItem } from '../lib/downloadtracker';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { getRepository } from '../datasource';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
@@ -36,7 +37,7 @@ class Media {
|
||||
}
|
||||
|
||||
const media = await mediaRepository.find({
|
||||
tmdbId: In(finalIds),
|
||||
where: { tmdbId: In(finalIds) },
|
||||
});
|
||||
|
||||
return media;
|
||||
@@ -55,10 +56,10 @@ class Media {
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: ['requests', 'issues'],
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
return media;
|
||||
return media ?? undefined;
|
||||
} catch (e) {
|
||||
logger.error(e.message);
|
||||
return undefined;
|
||||
@@ -145,6 +146,9 @@ class Media {
|
||||
public plexUrl?: string;
|
||||
public plexUrl4k?: string;
|
||||
|
||||
public iOSPlexUrl?: string;
|
||||
public iOSPlexUrl4k?: string;
|
||||
|
||||
public tautulliUrl?: string;
|
||||
public tautulliUrl4k?: string;
|
||||
|
||||
@@ -164,6 +168,8 @@ 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}`;
|
||||
}
|
||||
@@ -176,6 +182,8 @@ 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}`;
|
||||
}
|
||||
|
||||
@@ -6,30 +6,360 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
|
||||
import SonarrAPI, {
|
||||
AddSeriesOptions,
|
||||
SonarrSeries,
|
||||
} from '../api/servarr/sonarr';
|
||||
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 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;
|
||||
|
||||
@@ -120,6 +450,9 @@ export class MediaRequest {
|
||||
})
|
||||
public tags?: number[];
|
||||
|
||||
@Column({ default: false })
|
||||
public isAutoRequest: boolean;
|
||||
|
||||
constructor(init?: Partial<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -207,7 +540,7 @@ export class MediaRequest {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
@@ -272,7 +605,7 @@ export class MediaRequest {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const fullMedia = await mediaRepository.findOneOrFail({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -452,10 +785,13 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
@@ -543,7 +879,7 @@ export class MediaRequest {
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -670,7 +1006,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'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -685,10 +1021,13 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
|
||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ISession } from 'connect-typeorm';
|
||||
import type { ISession } from 'connect-typeorm';
|
||||
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
MoreThan,
|
||||
Not,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
@@ -18,15 +16,14 @@ import {
|
||||
} from 'typeorm';
|
||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { UserType } from '../constants/user';
|
||||
import { QuotaResponse } from '../interfaces/api/userInterfaces';
|
||||
import { getRepository } from '../datasource';
|
||||
import type { QuotaResponse } from '../interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
PermissionCheckOptions,
|
||||
} from '../lib/permissions';
|
||||
import type { PermissionCheckOptions } from '../lib/permissions';
|
||||
import { hasPermission, Permission } 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';
|
||||
@@ -255,13 +252,14 @@ export class User {
|
||||
if (movieQuotaDays) {
|
||||
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
|
||||
}
|
||||
const movieQuotaStartDate = movieDate.toJSON();
|
||||
|
||||
const movieQuotaUsed = movieQuotaLimit
|
||||
? await requestRepository.count({
|
||||
where: {
|
||||
requestedBy: this,
|
||||
createdAt: MoreThan(movieQuotaStartDate),
|
||||
requestedBy: {
|
||||
id: this.id,
|
||||
},
|
||||
createdAt: AfterDate(movieDate),
|
||||
type: MediaType.MOVIE,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
||||
import type { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
||||
import { hasNotificationType, Notification } from '../lib/notifications';
|
||||
import { NotificationAgentKey } from '../lib/settings';
|
||||
import { User } from './User';
|
||||
@@ -57,6 +57,12 @@ 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,
|
||||
|
||||
@@ -2,15 +2,17 @@ import { getClientIp } from '@supercharge/request-ip';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csurf from 'csurf';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import session, { Store } from 'express-session';
|
||||
import type { Store } from 'express-session';
|
||||
import session 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';
|
||||
@@ -29,6 +31,7 @@ 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');
|
||||
|
||||
@@ -40,7 +43,7 @@ const handle = app.getRequestHandler();
|
||||
app
|
||||
.prepare()
|
||||
.then(async () => {
|
||||
const dbConnection = await createConnection();
|
||||
const dbConnection = await dataSource.initialize();
|
||||
|
||||
// Run migrations in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -51,6 +54,7 @@ app
|
||||
|
||||
// Load Settings
|
||||
const settings = getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
|
||||
// Migrate library types
|
||||
if (
|
||||
@@ -59,8 +63,8 @@ app
|
||||
) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
|
||||
@@ -3,3 +3,10 @@ export interface GenreSliderItem {
|
||||
name: string;
|
||||
backdrops: string[];
|
||||
}
|
||||
|
||||
export interface WatchlistItem {
|
||||
ratingKey: string;
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Issue from '../../entity/Issue';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type Issue from '../../entity/Issue';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
results: Issue[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Media from '../../entity/Media';
|
||||
import { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type { User } from '../../entity/User';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface MediaResultsResponse extends PaginatedResponse {
|
||||
results: Media[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
import type { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlexSettings } from '../../lib/settings';
|
||||
import type { PlexSettings } from '../../lib/settings';
|
||||
|
||||
export interface PlexStatus {
|
||||
settings: PlexSettings;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
||||
import { LanguageProfile } from '../../api/servarr/sonarr';
|
||||
import type { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
||||
import type { LanguageProfile } from '../../api/servarr/sonarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
|
||||
@@ -56,4 +56,5 @@ export interface StatusResponse {
|
||||
commitTag: string;
|
||||
updateAvailable: boolean;
|
||||
commitsBehind: number;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type Media from '../../entity/Media';
|
||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
results: User[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationAgentKey } from '../../lib/settings';
|
||||
import type { NotificationAgentKey } from '../../lib/settings';
|
||||
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
@@ -14,6 +14,8 @@ export interface UserSettingsGeneralResponse {
|
||||
globalMovieQuotaLimit?: number;
|
||||
globalTvQuotaLimit?: number;
|
||||
globalTvQuotaDays?: number;
|
||||
watchlistSyncMovies?: boolean;
|
||||
watchlistSyncTv?: boolean;
|
||||
}
|
||||
|
||||
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
||||
|
||||
@@ -3,7 +3,9 @@ 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 { getSettings, JobId } from '../lib/settings';
|
||||
import type { JobId } from '../lib/settings';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import watchlistSync from '../lib/watchlistsync';
|
||||
import logger from '../logger';
|
||||
|
||||
interface ScheduledJob {
|
||||
@@ -53,6 +55,20 @@ 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',
|
||||
|
||||
@@ -6,7 +6,8 @@ export type AvailableCacheIds =
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'github'
|
||||
| 'plexguid';
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -58,6 +59,10 @@ 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 {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Email from 'email-templates';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { URL } from 'url';
|
||||
import { getSettings, NotificationAgentEmail } from '../settings';
|
||||
import type { NotificationAgentEmail } from '../settings';
|
||||
import { getSettings } from '../settings';
|
||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||
|
||||
class PreparedEmail extends Email {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as openpgp from 'openpgp';
|
||||
import { Transform, TransformCallback } from 'stream';
|
||||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
import logger from '../../logger';
|
||||
|
||||
interface EncryptorOptions {
|
||||
@@ -26,7 +27,7 @@ class PGPEncryptor extends Transform {
|
||||
|
||||
// just save the whole message
|
||||
_transform = (
|
||||
chunk: any,
|
||||
chunk: Uint8Array,
|
||||
_encoding: BufferEncoding,
|
||||
callback: TransformCallback
|
||||
): void => {
|
||||
@@ -184,6 +185,9 @@ 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);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Notification } from '..';
|
||||
import type { Notification } from '..';
|
||||
import type Issue from '../../../entity/Issue';
|
||||
import IssueComment from '../../../entity/IssueComment';
|
||||
import Media from '../../../entity/Media';
|
||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
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';
|
||||
|
||||
export interface NotificationPayload {
|
||||
event?: string;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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 {
|
||||
getSettings,
|
||||
NotificationAgentDiscord,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentDiscord } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
enum EmbedColors {
|
||||
DEFAULT = 0,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { EmailOptions } from 'email-templates';
|
||||
import type { 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 {
|
||||
getSettings,
|
||||
NotificationAgentEmail,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentEmail } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
|
||||
@@ -2,14 +2,16 @@ import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentGotify } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentGotify } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface GotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
priority: number;
|
||||
extras: any;
|
||||
extras: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class GotifyAgent
|
||||
|
||||
@@ -3,8 +3,10 @@ import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentLunaSea } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
class LunaSeaAgent
|
||||
extends BaseAgent<NotificationAgentLunaSea>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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 {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushbullet,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentPushbullet } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface PushbulletPayload {
|
||||
type: string;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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 {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushover,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentPushover } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface PushoverPayload {
|
||||
token: string;
|
||||
|
||||
@@ -2,8 +2,10 @@ import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentSlack } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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 {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentTelegram,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentTelegram } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface TelegramMessagePayload {
|
||||
text: string;
|
||||
|
||||
@@ -4,8 +4,10 @@ import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentWebhook } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
type KeyMapFunction = (
|
||||
payload: NotificationPayload,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
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 {
|
||||
getSettings,
|
||||
NotificationAgentConfig,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgentConfig } from '../../settings';
|
||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface PushNotificationPayload {
|
||||
notificationType: string;
|
||||
@@ -160,7 +158,7 @@ class WebPushAgent
|
||||
true)
|
||||
) {
|
||||
const notifySubs = await userPushSubRepository.find({
|
||||
where: { user: payload.notifyUser.id },
|
||||
where: { user: { id: payload.notifyUser.id } },
|
||||
});
|
||||
|
||||
pushSubs.push(...notifySubs);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from '../../entity/User';
|
||||
import type { User } from '../../entity/User';
|
||||
import logger from '../../logger';
|
||||
import { Permission } from '../permissions';
|
||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export enum Permission {
|
||||
NONE = 0,
|
||||
ADMIN = 2,
|
||||
MANAGE_SETTINGS = 4,
|
||||
MANAGE_USERS = 8,
|
||||
MANAGE_REQUESTS = 16,
|
||||
REQUEST = 32,
|
||||
@@ -22,6 +21,10 @@ 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import animeList from '../../../api/animelist';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import PlexAPI from '../../../api/plexapi';
|
||||
import type { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { getRepository } from '../../../datasource';
|
||||
import { User } from '../../../entity/User';
|
||||
import cacheManager from '../../cache';
|
||||
import { getSettings, Library } from '../../settings';
|
||||
import BaseScanner, {
|
||||
import type { Library } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type {
|
||||
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]+)/);
|
||||
@@ -59,8 +62,8 @@ class PlexScanner
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
@@ -141,7 +144,9 @@ 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);
|
||||
}
|
||||
@@ -369,7 +374,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,
|
||||
@@ -390,7 +395,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) {
|
||||
@@ -409,7 +414,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) {
|
||||
@@ -484,10 +489,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
|
||||
import { getSettings, RadarrSettings } from '../../settings';
|
||||
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
|
||||
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';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: RadarrSettings;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
|
||||
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
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 Media from '../../../entity/Media';
|
||||
import { getSettings, SonarrSettings } from '../../settings';
|
||||
import BaseScanner, {
|
||||
import type { SonarrSettings } from '../../settings';
|
||||
import { getSettings } from '../../settings';
|
||||
import type {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '../baseScanner';
|
||||
import BaseScanner from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: SonarrSettings;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import {
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
|
||||
@@ -243,6 +243,7 @@ interface JobSettings {
|
||||
export type JobId =
|
||||
| 'plex-recently-added-scan'
|
||||
| 'plex-full-scan'
|
||||
| 'plex-watchlist-sync'
|
||||
| 'radarr-scan'
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
@@ -398,6 +399,9 @@ class Settings {
|
||||
'plex-full-scan': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'plex-watchlist-sync': {
|
||||
schedule: '0 */10 * * * *',
|
||||
},
|
||||
'radarr-scan': {
|
||||
schedule: '0 0 4 * * *',
|
||||
},
|
||||
|
||||
165
server/lib/watchlistsync.ts
Normal file
165
server/lib/watchlistsync.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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;
|
||||
@@ -26,7 +26,7 @@ const hformat = winston.format.printf(
|
||||
);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'debug',
|
||||
level: process.env.LOG_LEVEL?.toLowerCase() || 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { getRepository } from '../datasource';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission, PermissionCheckOptions } from '../lib/permissions';
|
||||
import type { 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;
|
||||
let user: User | undefined | null;
|
||||
|
||||
if (req.header('X-API-Key') === settings.main.apiKey) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitialMigration1603944374840 implements MigrationInterface {
|
||||
name = 'InitialMigration1603944374840';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SeasonStatus1605085519544 implements MigrationInterface {
|
||||
name = 'SeasonStatus1605085519544';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CascadeMigration1606730060700 implements MigrationInterface {
|
||||
name = 'CascadeMigration1606730060700';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { TableUnique } from 'typeorm';
|
||||
|
||||
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserRequestDeleteCascades1608219049304
|
||||
implements MigrationInterface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLastSeasonChangeMedia1608477467935
|
||||
implements MigrationInterface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ForceDropImdbUniqueConstraint1608477467935
|
||||
implements MigrationInterface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import type { 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
Reference in New Issue
Block a user