Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2022-09-01 18:11:15 +12:00
473 changed files with 15548 additions and 8433 deletions

View File

@@ -665,6 +665,78 @@
"contributions": [ "contributions": [
"translation" "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>", "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", "projectOwner": "sct",
"repoType": "github", "repoType": "github",
"repoHost": "https://github.com", "repoHost": "https://github.com",
"skipCi": true "skipCi": false
} }

View File

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

View File

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

View File

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

30
.github/workflows/cypress.yml vendored Normal file
View 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}}

View File

@@ -3,7 +3,7 @@ name: Jellyseerr Preview
on: on:
push: push:
tags: tags:
- "preview-*" - 'preview-*'
jobs: jobs:
build_and_push: build_and_push:
@@ -16,16 +16,16 @@ jobs:
id: get_version id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -5,7 +5,7 @@ on: workflow_dispatch
jobs: jobs:
semantic-release: semantic-release:
name: Tag and release latest version name: Tag and release latest version
runs-on: self-hosted runs-on: ubuntu-20.04
env: env:
HUSKY: 0 HUSKY: 0
steps: steps:
@@ -18,16 +18,14 @@ jobs:
with: with:
node-version: 16 node-version: 16
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Install Yarn
run: npm install -g yarn
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Release - name: Release
@@ -37,6 +35,60 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release run: npx semantic-release
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
architecture:
- amd64
- arm64
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Pull latest changes
run: git pull
- name: Prepare
id: prepare
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
else
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
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: discord:
name: Send Discord Notification name: Send Discord Notification
needs: semantic-release needs: semantic-release
@@ -44,7 +96,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Get Build Job Status - name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2 uses: technote-space/workflow-conclusion-action@v3
- name: Combine Job Status - name: Combine Job Status
id: status id: status
run: | run: |

88
.github/workflows/snap.yaml vendored Normal file
View File

@@ -0,0 +1,88 @@
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@v3
- 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@v2
- name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build
with:
architecture: ${{ matrix.architecture }}
- name: Upload Snap Package
uses: actions/upload-artifact@v3
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@v3
- name: Combine Job Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true

11
.gitignore vendored
View File

@@ -54,5 +54,16 @@ config/db/db.sqlite3-journal
# VS Code # VS Code
.vscode/launch.json .vscode/launch.json
# Cypress
cypress.env.json
cypress/videos
cypress/screenshots
# ESLint
.eslintcache
# TS Build Info
tsconfig.tsbuildinfo
# Webstorm # Webstorm
.idea .idea

5
.prettierrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: [require('./merged-prettier-plugin.js')],
singleQuote: true,
trailingComma: 'es5',
};

View File

@@ -11,9 +11,6 @@
// https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
"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 // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest
"Orta.vscode-jest", "Orta.vscode-jest",

View File

@@ -15,8 +15,6 @@
"database": "./config/db/db.sqlite3" "database": "./config/db/db.sqlite3"
} }
], ],
"editor.codeActionsOnSave": { "editor.formatOnSave": true,
"source.organizeImports": true "typescript.preferences.importModuleSpecifier": "non-relative"
},
"editor.formatOnSave": true
} }

View File

@@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines:
1. Be concise and clear, and use as few words as possible to make your point. 1. Be concise and clear, and use as few words as possible to make your point.
2. Use the Oxford comma where appropriate. 2. Use the Oxford comma where appropriate.
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. 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). 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. 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. 7. Ensure that toast notification strings are complete sentences ending in punctuation.

View File

@@ -1,4 +1,4 @@
FROM node:16.14-alpine AS BUILD_IMAGE FROM node:16.17-alpine AS BUILD_IMAGE
WORKDIR /app WORKDIR /app
@@ -14,7 +14,7 @@ RUN \
esac esac
COPY package.json yarn.lock ./ 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 . ./ COPY . ./
@@ -33,7 +33,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:16.14-alpine FROM node:16.17-alpine
WORKDIR /app WORKDIR /app

View File

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

19
cypress.config.ts Normal file
View 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,
},
});

View 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 * * *"
}
}
}

210
cypress/e2e/discover.cy.ts Normal file
View File

@@ -0,0 +1,210 @@
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.loginAsAdmin();
});
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.json',
}).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', 'Your 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();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});

13
cypress/e2e/login.cy.ts Normal file
View File

@@ -0,0 +1,13 @@
describe('Login Page', () => {
it('succesfully logs in as an admin', () => {
cy.loginAsAdmin();
cy.visit('/');
cy.contains('Trending');
});
it('succesfully logs in as a local user', () => {
cy.loginAsUser();
cy.visit('/');
cy.contains('Trending');
});
});

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
describe('TV Details', () => {
it('loads a tv details page', () => {
cy.loginAsAdmin();
// Try to load stranger things
cy.visit('/tv/66732');
cy.get('[data-testid=media-title]').should(
'contain',
'Stranger Things (2016)'
);
});
it('shows seasons and expands episodes', () => {
cy.loginAsAdmin();
// Try to load stranger things
cy.visit('/tv/66732');
// intercept request for season info
cy.intercept('/api/v1/tv/66732/season/4').as('season4');
cy.contains('Season 4').should('be.visible').scrollIntoView().click();
cy.wait('@season4');
cy.contains('Chapter Nine').should('be.visible');
});
});

View 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.loginAsAdmin();
});
it('should not see watchlist sync settings on an account without permissions', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.contains('Auto-Request Movies').should('not.exist');
cy.contains('Auto-Request Series').should('not.exist');
});
it('should see watchlist sync settings on an admin account', () => {
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
});
it('should see auto-request settings after being given permission', () => {
visitUserEditPage(Cypress.env('USER_EMAIL'));
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
cy.contains('Save Changes').click();
cy.wait('@userPermissions');
cy.reload();
cy.get('#autorequest').should('be.checked');
cy.get('#autorequestmovies').should('be.checked');
cy.get('#autorequesttv').should('be.checked');
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
cy.contains('Auto-Request Movies').should('exist');
cy.contains('Auto-Request Series').should('exist');
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
cy.get('#watchlistSyncTv').should('not.be.checked').click();
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.reload();
cy.get('#watchlistSyncMovies').should('be.checked').click();
cy.get('#watchlistSyncTv').should('be.checked').click();
cy.contains('Save Changes').click();
cy.wait('@userMain');
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
cy.get('#autorequest').should('be.checked').click();
cy.contains('Save Changes').click();
});
});

View File

@@ -0,0 +1,50 @@
describe('User Profile', () => {
beforeEach(() => {
cy.loginAsAdmin();
});
it('opens user profile page from the home page', () => {
cy.visit('/');
cy.get('[data-testid=user-menu]').click();
cy.get('[data-testid=user-menu-profile]').click();
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
});
it('loads plex watchlist', () => {
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
fixture: 'watchlist.json',
}).as('getWatchlist');
// Wait for one of the watchlist movies to resolve
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
cy.visit('/profile');
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();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});

View File

@@ -0,0 +1,70 @@
const testUser = {
displayName: 'Test User',
emailAddress: 'test@seeerr.dev',
password: 'test1234',
};
describe('User List', () => {
beforeEach(() => {
cy.loginAsAdmin();
});
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 User`);
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
cy.wait('@user');
cy.wait(1000);
cy.get('[data-testid=user-list-row]')
.contains(testUser.emailAddress)
.should('not.exist');
});
});

View File

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

View File

@@ -0,0 +1,34 @@
/// <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);
},
}
);
});
Cypress.Commands.add('loginAsAdmin', () => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
Cypress.Commands.add('loginAsUser', () => {
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
});

7
cypress/support/e2e.ts Normal file
View File

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

14
cypress/support/index.ts Normal file
View File

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

10
cypress/tsconfig.json Normal file
View File

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

View File

@@ -9,7 +9,7 @@
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS - [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 - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot - [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 - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter

View File

@@ -45,7 +45,7 @@ Overseerr currently supports the following agents:
- New Plex TV - New Plex TV
- Legacy Plex TV - Legacy Plex TV
- TheTVDB - TheTVDB
- TMDb - TMDB
- [HAMA](https://github.com/ZeroQI/Hama.bundle) - [HAMA](https://github.com/ZeroQI/Hama.bundle)
Please verify that your library is using one of the agents previously listed. 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"**. 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: 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"/>` 2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`

View File

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

21
merged-prettier-plugin.js Normal file
View File

@@ -0,0 +1,21 @@
/* eslint-disable */
const tailwind = require('prettier-plugin-tailwindcss');
const organizeImports = require('prettier-plugin-organize-imports');
const combinedFormatter = {
...tailwind,
parsers: {
...tailwind.parsers,
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
acc[key] = {
...tailwind.parsers[key],
preprocess(code, options) {
return organizeImports.parsers[key].preprocess(code, options);
},
};
return acc;
}, {}),
},
};
module.exports = combinedFormatter;

View File

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

View File

@@ -1841,14 +1841,14 @@ components:
paths: paths:
/status: /status:
get: get:
summary: Get Overseerr version summary: Get Overseerr status
description: Returns the current Overseerr version in a JSON object. description: Returns the current Overseerr status in a JSON object.
security: [] security: []
tags: tags:
- public - public
responses: responses:
'200': '200':
description: Returned version description: Returned status
content: content:
application/json: application/json:
schema: schema:
@@ -1859,6 +1859,12 @@ paths:
example: 1.0.0 example: 1.0.0
commitTag: commitTag:
type: string type: string
updateAvailable:
type: boolean
commitsBehind:
type: number
restartRequired:
type: boolean
/status/appdata: /status/appdata:
get: get:
summary: Get application data volume status summary: Get application data volume status
@@ -3394,8 +3400,8 @@ paths:
name: guid name: guid
required: true required: true
schema: schema:
type: number type: string
example: 1 example: '9afef5a7-ec89-4d5f-9397-261e96970b50'
responses: responses:
'200': '200':
description: OK description: OK
@@ -3759,6 +3765,53 @@ paths:
restricted: restricted:
type: boolean type: boolean
example: false example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- 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
/user/{userId}/settings/main: /user/{userId}/settings/main:
get: get:
summary: Get general settings for a user summary: Get general settings for a user
@@ -4650,6 +4703,46 @@ paths:
name: name:
type: string type: string
example: Genre Name 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: /request:
get: get:
summary: Get all requests summary: Get all requests
@@ -4677,7 +4770,16 @@ paths:
schema: schema:
type: string type: string
nullable: true nullable: true
enum: [all, approved, available, pending, processing, unavailable] enum:
[
all,
approved,
available,
pending,
processing,
unavailable,
failed,
]
- in: query - in: query
name: sort name: sort
schema: schema:
@@ -5580,7 +5682,7 @@ paths:
$ref: '#/components/schemas/SonarrSeries' $ref: '#/components/schemas/SonarrSeries'
/regions: /regions:
get: get:
summary: Regions supported by TMDb summary: Regions supported by TMDB
description: Returns a list of regions in a JSON object. description: Returns a list of regions in a JSON object.
tags: tags:
- tmdb - tmdb
@@ -5602,7 +5704,7 @@ paths:
example: United States of America example: United States of America
/languages: /languages:
get: get:
summary: Languages supported by TMDb summary: Languages supported by TMDB
description: Returns a list of languages in a JSON object. description: Returns a list of languages in a JSON object.
tags: tags:
- tmdb - tmdb
@@ -5667,7 +5769,7 @@ paths:
$ref: '#/components/schemas/ProductionCompany' $ref: '#/components/schemas/ProductionCompany'
/genres/movie: /genres/movie:
get: 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. description: Returns a list of genres in a JSON array.
tags: tags:
- tmdb - tmdb
@@ -5695,7 +5797,7 @@ paths:
example: Family example: Family
/genres/tv: /genres/tv:
get: 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. description: Returns a list of genres in a JSON array.
tags: tags:
- tmdb - tmdb

View File

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

21
renovate.json Normal file
View 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"
}
]
}

View File

@@ -1,8 +1,8 @@
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import xml2js from 'xml2js';
import fs, { promises as fsp } from 'fs'; import fs, { promises as fsp } from 'fs';
import path from 'path'; import path from 'path';
import logger from '../logger'; import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
@@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); 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/ // https://github.com/Anime-Lists/anime-lists/
interface AnimeMapping { interface AnimeMapping {

View File

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

View File

@@ -1,5 +1,5 @@
import cacheManager from '../lib/cache'; import cacheManager from '@server/lib/cache';
import logger from '../logger'; import logger from '@server/logger';
import ExternalAPI from './externalapi'; import ExternalAPI from './externalapi';
interface GitHubRelease { interface GitHubRelease {

View File

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

View File

@@ -1,8 +1,9 @@
import axios, { AxiosInstance } from 'axios'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import { PlexDevice } from '../interfaces/api/plexInterfaces'; import ExternalAPI from './externalapi';
import { getSettings } from '../lib/settings';
import logger from '../logger';
interface PlexAccountResponse { interface PlexAccountResponse {
user: PlexUser; 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 authToken: string;
private axios: AxiosInstance;
constructor(authToken: string) { 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.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[]> { public async getDevices(): Promise<PlexDevice[]> {
@@ -252,6 +287,83 @@ class PlexTvAPI {
)) as UsersResponse; )) as UsersResponse;
return parsedXml; 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; export default PlexTvAPI;

View File

@@ -1,4 +1,4 @@
import cacheManager from '../lib/cache'; import cacheManager from '@server/lib/cache';
import ExternalAPI from './externalapi'; import ExternalAPI from './externalapi';
interface RTSearchResult { interface RTSearchResult {

View File

@@ -1,6 +1,7 @@
import cacheManager, { AvailableCacheIds } from '../../lib/cache'; import ExternalAPI from '@server/api/externalapi';
import { DVRSettings } from '../../lib/settings'; import type { AvailableCacheIds } from '@server/lib/cache';
import ExternalAPI from '../externalapi'; import cacheManager from '@server/lib/cache';
import type { DVRSettings } from '@server/lib/settings';
export interface SystemStatus { export interface SystemStatus {
version: string; version: string;

View File

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

View File

@@ -1,4 +1,4 @@
import logger from '../../logger'; import logger from '@server/logger';
import ServarrBase from './base'; import ServarrBase from './base';
interface SonarrSeason { interface SonarrSeason {

View File

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

View File

@@ -1,7 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import cacheManager from '../../lib/cache'; import type {
import ExternalAPI from '../externalapi';
import {
TmdbCollection, TmdbCollection,
TmdbExternalIdResponse, TmdbExternalIdResponse,
TmdbGenre, TmdbGenre,
@@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI {
}, },
{ {
nodeCache: cacheManager.getCache('tmdb').data, nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 50,
},
} }
); );
this.region = region; this.region = region;
@@ -192,7 +196,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
} }
}; };
@@ -214,7 +218,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[TMDb] Failed to fetch person combined credits: ${e.message}` `[TMDB] Failed to fetch person combined credits: ${e.message}`
); );
} }
}; };
@@ -241,7 +245,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
} }
}; };
@@ -267,7 +271,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } 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}`);
} }
}; };
@@ -293,7 +297,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } 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}`);
} }
}; };
@@ -319,7 +323,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
} }
} }
@@ -345,7 +349,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
} }
} }
@@ -371,7 +375,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } 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}`);
} }
} }
@@ -398,7 +402,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`[TMDb] Failed to fetch TV recommendations: ${e.message}` `[TMDB] Failed to fetch TV recommendations: ${e.message}`
); );
} }
} }
@@ -422,7 +426,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
} }
} }
@@ -455,7 +459,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
} }
}; };
@@ -488,7 +492,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
} }
}; };
@@ -514,7 +518,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
} }
}; };
@@ -541,7 +545,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
} }
}; };
@@ -564,7 +568,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
} }
}; };
@@ -587,7 +591,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
} }
}; };
@@ -619,7 +623,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } 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}`);
} }
} }
@@ -657,7 +661,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No movie or show returned from API for ID ${imdbId}`); throw new Error(`No movie or show returned from API for ID ${imdbId}`);
} catch (e) { } catch (e) {
throw new Error( 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}`
); );
} }
} }
@@ -687,7 +691,7 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`No show returned from API for ID ${tvdbId}`); throw new Error(`No show returned from API for ID ${tvdbId}`);
} catch (e) { } catch (e) {
throw new Error( 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}`
); );
} }
} }
@@ -711,7 +715,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
} }
} }
@@ -727,7 +731,7 @@ class TheMovieDb extends ExternalAPI {
return regions; return regions;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
} }
} }
@@ -743,7 +747,7 @@ class TheMovieDb extends ExternalAPI {
return languages; return languages;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
} }
} }
@@ -755,7 +759,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
} }
} }
@@ -765,7 +769,7 @@ class TheMovieDb extends ExternalAPI {
return data; return data;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
} }
} }
@@ -816,7 +820,7 @@ class TheMovieDb extends ExternalAPI {
return movieGenres; return movieGenres;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
} }
} }
@@ -867,7 +871,7 @@ class TheMovieDb extends ExternalAPI {
return tvGenres; return tvGenres;
} catch (e) { } catch (e) {
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
} }
} }
} }

View File

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

View File

@@ -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', type: 'sqlite',
database: process.env.CONFIG_DIRECTORY database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
@@ -10,31 +14,30 @@ const devConfig = {
entities: ['server/entity/**/*.ts'], entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'], migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'],
cli: {
entitiesDir: 'server/entity',
migrationsDir: 'server/migration',
},
}; };
const prodConfig = { const prodConfig: DataSourceOptions = {
type: 'sqlite', type: 'sqlite',
database: process.env.CONFIG_DIRECTORY database: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
: 'config/db/db.sqlite3', : 'config/db/db.sqlite3',
synchronize: false, synchronize: false,
migrationsRun: false,
logging: false, logging: false,
enableWAL: true, enableWAL: true,
entities: ['dist/entity/**/*.js'], entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'], migrations: ['dist/migration/**/*.js'],
migrationsRun: false,
subscribers: ['dist/subscriber/**/*.js'], subscribers: ['dist/subscriber/**/*.js'],
cli: {
entitiesDir: 'dist/entity',
migrationsDir: 'dist/migration',
},
}; };
const finalConfig = const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; 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;

View File

@@ -1,3 +1,5 @@
import type { IssueType } from '@server/constants/issue';
import { IssueStatus } from '@server/constants/issue';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
@@ -7,7 +9,6 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { IssueStatus, IssueType } from '../constants/issue';
import IssueComment from './IssueComment'; import IssueComment from './IssueComment';
import Media from './Media'; import Media from './Media';
import { User } from './User'; import { User } from './User';

View File

@@ -1,22 +1,23 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { import {
AfterLoad, AfterLoad,
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
getRepository,
In, In,
Index, Index,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import { MediaServerType } from '../constants/server';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue'; import Issue from './Issue';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
import Season from './Season'; import Season from './Season';
@@ -37,7 +38,7 @@ class Media {
} }
const media = await mediaRepository.find({ const media = await mediaRepository.find({
tmdbId: In(finalIds), where: { tmdbId: In(finalIds) },
}); });
return media; return media;
@@ -56,10 +57,10 @@ class Media {
try { try {
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType }, where: { tmdbId: id, mediaType },
relations: ['requests', 'issues'], relations: { requests: true, issues: true },
}); });
return media; return media ?? undefined;
} catch (e) { } catch (e) {
logger.error(e.message); logger.error(e.message);
return undefined; return undefined;
@@ -152,6 +153,9 @@ class Media {
public mediaUrl?: string; public mediaUrl?: string;
public mediaUrl4k?: string; public mediaUrl4k?: string;
public iOSPlexUrl?: string;
public iOSPlexUrl4k?: string;
public tautulliUrl?: string; public tautulliUrl?: string;
public tautulliUrl4k?: string; public tautulliUrl4k?: string;
@@ -172,36 +176,41 @@ class Media {
this.ratingKey this.ratingKey
}`; }`;
this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`;
if (tautulliUrl) { if (tautulliUrl) {
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`; this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
} }
}
if (this.ratingKey4k) { if (this.ratingKey4k) {
this.mediaUrl4k = `${ this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k this.ratingKey4k
}`; }`;
if (tautulliUrl) { this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
if (tautulliUrl) {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } =
getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
}
} }
} }
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
}
} }
} }

View File

@@ -1,3 +1,23 @@
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
AddSeriesOptions,
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isEqual, truncate } from 'lodash'; import { isEqual, truncate } from 'lodash';
import { import {
AfterInsert, AfterInsert,
@@ -6,30 +26,347 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
getRepository,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
RelationCount, RelationCount,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
import SonarrAPI, {
AddSeriesOptions,
SonarrSeries,
} from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import notificationManager, { Notification } from '../lib/notifications';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Media from './Media'; import Media from './Media';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
import { User } from './User'; 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() @Entity()
export class MediaRequest { 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() @PrimaryGeneratedColumn()
public id: number; public id: number;
@@ -120,6 +457,9 @@ export class MediaRequest {
}) })
public tags?: number[]; public tags?: number[];
@Column({ default: false })
public isAutoRequest: boolean;
constructor(init?: Partial<MediaRequest>) { constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init); Object.assign(this, init);
} }
@@ -147,6 +487,10 @@ export class MediaRequest {
} }
this.sendNotification(media, Notification.MEDIA_PENDING); this.sendNotification(media, Notification.MEDIA_PENDING);
if (this.isAutoRequest) {
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
}
} }
} }
@@ -191,6 +535,14 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED : Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED : Notification.MEDIA_DECLINED
); );
if (
this.status === MediaRequestStatus.APPROVED &&
autoApproved &&
this.isAutoRequest
) {
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
}
} }
} }
@@ -207,7 +559,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
relations: ['requests'], relations: { requests: true },
}); });
if (!media) { if (!media) {
logger.error('Media data not found', { logger.error('Media data not found', {
@@ -272,7 +624,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({ const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id }, where: { id: this.media.id },
relations: ['requests'], relations: { requests: true },
}); });
if ( if (
@@ -452,10 +804,13 @@ export class MediaRequest {
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; const requestRepository = getRepository(MediaRequest);
await mediaRepository.save(media);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
logger.warn( 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', label: 'Media Request',
requestId: this.id, requestId: this.id,
@@ -543,7 +898,7 @@ export class MediaRequest {
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
relations: ['requests'], relations: { requests: true },
}); });
if (!media) { if (!media) {
@@ -670,7 +1025,7 @@ export class MediaRequest {
// We grab media again here to make sure we have the latest version of it // We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
relations: ['requests'], relations: { requests: true },
}); });
if (!media) { if (!media) {
@@ -685,10 +1040,13 @@ export class MediaRequest {
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; const requestRepository = getRepository(MediaRequest);
await mediaRepository.save(media);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
logger.warn( 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', label: 'Media Request',
requestId: this.id, requestId: this.id,
@@ -723,6 +1081,7 @@ export class MediaRequest {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined; let event: string | undefined;
let notifyAdmin = true; let notifyAdmin = true;
let notifySystem = true;
switch (type) { switch (type) {
case Notification.MEDIA_APPROVED: case Notification.MEDIA_APPROVED:
@@ -736,6 +1095,13 @@ export class MediaRequest {
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
break; break;
case Notification.MEDIA_AUTO_REQUESTED:
event = `${
this.is4k ? '4K ' : ''
}${mediaType} Request Automatically Submitted`;
notifyAdmin = false;
notifySystem = false;
break;
case Notification.MEDIA_AUTO_APPROVED: case Notification.MEDIA_AUTO_APPROVED:
event = `${ event = `${
this.is4k ? '4K ' : '' this.is4k ? '4K ' : ''
@@ -752,6 +1118,7 @@ export class MediaRequest {
media, media,
request: this, request: this,
notifyAdmin, notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy, notifyUser: notifyAdmin ? undefined : this.requestedBy,
event, event,
subject: `${movie.title}${ subject: `${movie.title}${
@@ -770,6 +1137,7 @@ export class MediaRequest {
media, media,
request: this, request: this,
notifyAdmin, notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy, notifyUser: notifyAdmin ? undefined : this.requestedBy,
event, event,
subject: `${tv.name}${ subject: `${tv.name}${

View File

@@ -1,12 +1,12 @@
import { MediaStatus } from '@server/constants/media';
import { import {
Entity,
PrimaryGeneratedColumn,
Column, Column,
ManyToOne,
CreateDateColumn, CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { MediaStatus } from '../constants/media';
import Media from './Media'; import Media from './Media';
@Entity() @Entity()

View File

@@ -1,12 +1,12 @@
import { MediaRequestStatus } from '@server/constants/media';
import { import {
Entity,
PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, Entity,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { MediaRequestStatus } from '../constants/media';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
@Entity() @Entity()

View File

@@ -1,5 +1,5 @@
import { ISession } from 'connect-typeorm'; import type { ISession } from 'connect-typeorm';
import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity() @Entity()
export class Session implements ISession { export class Session implements ISession {

View File

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

View File

@@ -1,3 +1,6 @@
import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces';
import { hasNotificationType, Notification } from '@server/lib/notifications';
import { NotificationAgentKey } from '@server/lib/settings';
import { import {
Column, Column,
Entity, Entity,
@@ -5,9 +8,6 @@ import {
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } from 'typeorm';
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
import { hasNotificationType, Notification } from '../lib/notifications';
import { NotificationAgentKey } from '../lib/settings';
import { User } from './User'; import { User } from './User';
export const ALL_NOTIFICATIONS = Object.values(Notification) export const ALL_NOTIFICATIONS = Object.values(Notification)
@@ -57,6 +57,12 @@ export class UserSettings {
@Column({ nullable: true }) @Column({ nullable: true })
public telegramSendSilently?: boolean; public telegramSendSilently?: boolean;
@Column({ nullable: true })
public watchlistSyncMovies?: boolean;
@Column({ nullable: true })
public watchlistSyncTv?: boolean;
@Column({ @Column({
type: 'text', type: 'text',
nullable: true, nullable: true,

View File

@@ -1,34 +1,37 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import notificationManager from '@server/lib/notifications';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
import SlackAgent from '@server/lib/notifications/agents/slack';
import TelegramAgent from '@server/lib/notifications/agents/telegram';
import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import routes from '@server/routes';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csurf from 'csurf'; 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 * 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 next from 'next';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import { createConnection, getRepository } from 'typeorm';
import YAML from 'yamljs'; import YAML from 'yamljs';
import PlexAPI from './api/plexapi';
import { Session } from './entity/Session';
import { User } from './entity/User';
import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import GotifyAgent from './lib/notifications/agents/gotify';
import LunaSeaAgent from './lib/notifications/agents/lunasea';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
import PushoverAgent from './lib/notifications/agents/pushover';
import SlackAgent from './lib/notifications/agents/slack';
import TelegramAgent from './lib/notifications/agents/telegram';
import WebhookAgent from './lib/notifications/agents/webhook';
import WebPushAgent from './lib/notifications/agents/webpush';
import { getSettings } from './lib/settings';
import logger from './logger';
import routes from './routes';
import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@@ -40,7 +43,7 @@ const handle = app.getRequestHandler();
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
const dbConnection = await createConnection(); const dbConnection = await dataSource.initialize();
// Run migrations in production // Run migrations in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
@@ -51,6 +54,7 @@ app
// Load Settings // Load Settings
const settings = getSettings().load(); const settings = getSettings().load();
restartFlag.initializeSettings(settings.main);
// Migrate library types // Migrate library types
if ( if (
@@ -59,8 +63,8 @@ app
) { ) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
select: ['id', 'plexToken'], select: { id: true, plexToken: true },
order: { id: 'ASC' }, where: { id: 1 },
}); });
if (admin) { if (admin) {

View File

@@ -3,3 +3,17 @@ export interface GenreSliderItem {
name: string; name: string;
backdrops: string[]; backdrops: string[];
} }
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
}
export interface WatchlistResponse {
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import Media from '../../entity/Media'; import type Media from '@server/entity/Media';
import { MediaRequest } from '../../entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { User } from '../../entity/User'; import type { User } from '@server/entity/User';
import { PaginatedResponse } from './common'; import type { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse { export interface UserResultsResponse extends PaginatedResponse {
results: User[]; results: User[];
@@ -23,6 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus; movie: QuotaStatus;
tv: QuotaStatus; tv: QuotaStatus;
} }
export interface UserWatchDataResponse { export interface UserWatchDataResponse {
recentlyWatched: Media[]; recentlyWatched: Media[];
playCount: number; playCount: number;

View File

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

View File

@@ -1,17 +1,19 @@
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { randomUUID as uuid } from 'crypto'; import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin';
import TheMovieDb from '../../api/themoviedb';
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '../../constants/media';
import { MediaServerType } from '../../constants/server';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { User } from '../../entity/User';
import { getSettings, Library } from '../../lib/settings';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
const BUNDLE_SIZE = 20; const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000; const UPDATE_RATE = 4 * 1000;
@@ -552,6 +554,7 @@ class JobJellyfinSync {
this.running = true; this.running = true;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 },
select: [ select: [
'id', 'id',
'jellyfinAuthToken', 'jellyfinAuthToken',

View File

@@ -1,11 +1,13 @@
import { MediaServerType } from '@server/constants/server';
import downloadTracker from '@server/lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import schedule from 'node-schedule'; import schedule from 'node-schedule';
import { MediaServerType } from '../constants/server';
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 logger from '../logger';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync'; import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob { interface ScheduledJob {
@@ -99,6 +101,20 @@ export const startJobs = (): void => {
}); });
} }
// 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 // Run full radarr scan every 24 hours
scheduledJobs.push({ scheduledJobs.push({
id: 'radarr-scan', id: 'radarr-scan',

View File

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

View File

@@ -1,9 +1,9 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaType } from '@server/constants/media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
export interface DownloadingItem { export interface DownloadingItem {
mediaType: MediaType; mediaType: MediaType;

View File

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

View File

@@ -1,7 +1,8 @@
import logger from '@server/logger';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import * as openpgp from 'openpgp'; import * as openpgp from 'openpgp';
import { Transform, TransformCallback } from 'stream'; import type { TransformCallback } from 'stream';
import logger from '../../logger'; import { Transform } from 'stream';
interface EncryptorOptions { interface EncryptorOptions {
signingKey?: string; signingKey?: string;
@@ -26,7 +27,7 @@ class PGPEncryptor extends Transform {
// just save the whole message // just save the whole message
_transform = ( _transform = (
chunk: any, chunk: Uint8Array,
_encoding: BufferEncoding, _encoding: BufferEncoding,
callback: TransformCallback callback: TransformCallback
): void => { ): void => {
@@ -184,6 +185,9 @@ class PGPEncryptor extends Transform {
} }
export const openpgpEncrypt = (options: EncryptorOptions) => { 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 { return function (mail: any, callback: () => unknown): void {
if (!options.encryptionKeys.length) { if (!options.encryptionKeys.length) {
setImmediate(callback); setImmediate(callback);

View File

@@ -1,14 +1,15 @@
import { Notification } from '..'; import type Issue from '@server/entity/Issue';
import type Issue from '../../../entity/Issue'; import type IssueComment from '@server/entity/IssueComment';
import IssueComment from '../../../entity/IssueComment'; import type Media from '@server/entity/Media';
import Media from '../../../entity/Media'; import type { MediaRequest } from '@server/entity/MediaRequest';
import { MediaRequest } from '../../../entity/MediaRequest'; import type { User } from '@server/entity/User';
import { User } from '../../../entity/User'; import type { NotificationAgentConfig } from '@server/lib/settings';
import { NotificationAgentConfig } from '../../settings'; import type { Notification } from '..';
export interface NotificationPayload { export interface NotificationPayload {
event?: string; event?: string;
subject: string; subject: string;
notifySystem: boolean;
notifyAdmin: boolean; notifyAdmin: boolean;
notifyUser?: User; notifyUser?: User;
media?: Media; media?: Media;

View File

@@ -1,19 +1,17 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
shouldSendAdminNotification, shouldSendAdminNotification,
} from '..'; } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { User } from '../../../entity/User'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentDiscord,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors { enum EmbedColors {
DEFAULT = 0, DEFAULT = 0,
@@ -245,7 +243,10 @@ class DiscordAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) { if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true; return true;
} }

View File

@@ -1,19 +1,17 @@
import { EmailOptions } from 'email-templates'; import { IssueType, IssueTypeName } from '@server/constants/issue';
import path from 'path'; import { MediaType } from '@server/constants/media';
import { getRepository } from 'typeorm'; import { getRepository } from '@server/datasource';
import { Notification, shouldSendAdminNotification } from '..'; import { User } from '@server/entity/User';
import { IssueType, IssueTypeName } from '../../../constants/issue'; import PreparedEmail from '@server/lib/email';
import { MediaType } from '../../../constants/media'; import type { NotificationAgentEmail } from '@server/lib/settings';
import { User } from '../../../entity/User'; import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '../../../logger'; import logger from '@server/logger';
import PreparedEmail from '../../email'; import type { EmailOptions } from 'email-templates';
import {
getSettings,
NotificationAgentEmail,
NotificationAgentKey,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import path from 'path';
import { Notification, shouldSendAdminNotification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
class EmailAgent class EmailAgent
extends BaseAgent<NotificationAgentEmail> extends BaseAgent<NotificationAgentEmail>
@@ -84,6 +82,11 @@ class EmailAgent
is4k ? 'in 4K ' : '' is4k ? 'in 4K ' : ''
}is pending approval:`; }is pending approval:`;
break; break;
case Notification.MEDIA_AUTO_REQUESTED:
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}was automatically submitted:`;
break;
case Notification.MEDIA_APPROVED: case Notification.MEDIA_APPROVED:
body = `Your request for the following ${mediaType} ${ body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : '' is4k ? 'in 4K ' : ''

View File

@@ -1,15 +1,17 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import logger from '../../../logger'; import { BaseAgent } from './agent';
import { getSettings, NotificationAgentGotify } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface GotifyPayload { interface GotifyPayload {
title: string; title: string;
message: string; message: string;
priority: number; priority: number;
extras: any; extras: Record<string, unknown>;
} }
class GotifyAgent class GotifyAgent
@@ -115,7 +117,10 @@ class GotifyAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) { if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true; return true;
} }

View File

@@ -1,10 +1,12 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { MediaStatus } from '../../../constants/media'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class LunaSeaAgent class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea> extends BaseAgent<NotificationAgentLunaSea>
@@ -85,7 +87,10 @@ class LunaSeaAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) { if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true; return true;
} }

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
shouldSendAdminNotification, shouldSendAdminNotification,
} from '..'; } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { User } from '../../../entity/User'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushbullet,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushbulletPayload { interface PushbulletPayload {
type: string; type: string;
@@ -54,6 +53,12 @@ class PushbulletAgent
let status = ''; let status = '';
switch (type) { switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
status = 'Pending Approval'; status = 'Pending Approval';
break; break;
@@ -106,6 +111,7 @@ class PushbulletAgent
// Send system notification // Send system notification
if ( if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) && hasNotificationType(type, settings.types ?? 0) &&
settings.enabled && settings.enabled &&
settings.options.accessToken settings.options.accessToken

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
shouldSendAdminNotification, shouldSendAdminNotification,
} from '..'; } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { User } from '../../../entity/User'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushover,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushoverPayload { interface PushoverPayload {
token: string; token: string;
@@ -63,6 +62,12 @@ class PushoverAgent
let status = ''; let status = '';
switch (type) { switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
status = 'Pending Approval'; status = 'Pending Approval';
break; break;
@@ -137,6 +142,7 @@ class PushoverAgent
// Send system notification // Send system notification
if ( if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) && hasNotificationType(type, settings.types ?? 0) &&
settings.enabled && settings.enabled &&
settings.options.accessToken && settings.options.accessToken &&

View File

@@ -1,9 +1,11 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import logger from '../../../logger'; import { BaseAgent } from './agent';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface EmbedField { interface EmbedField {
type: 'plain_text' | 'mrkdwn'; type: 'plain_text' | 'mrkdwn';
@@ -223,7 +225,10 @@ class SlackAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) { if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true; return true;
} }

View File

@@ -1,19 +1,18 @@
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm';
import { import {
hasNotificationType, hasNotificationType,
Notification, Notification,
shouldSendAdminNotification, shouldSendAdminNotification,
} from '..'; } from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { User } from '../../../entity/User'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import {
getSettings,
NotificationAgentKey,
NotificationAgentTelegram,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload { interface TelegramMessagePayload {
text: string; text: string;
@@ -81,6 +80,12 @@ class TelegramAgent
let status = ''; let status = '';
switch (type) { switch (type) {
case Notification.MEDIA_AUTO_REQUESTED:
status =
payload.media?.status === MediaStatus.PENDING
? 'Pending Approval'
: 'Processing';
break;
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
status = 'Pending Approval'; status = 'Pending Approval';
break; break;
@@ -159,6 +164,7 @@ class TelegramAgent
// Send system notification // Send system notification
if ( if (
payload.notifySystem &&
hasNotificationType(type, settings.types ?? 0) && hasNotificationType(type, settings.types ?? 0) &&
settings.options.chatId settings.options.chatId
) { ) {

View File

@@ -1,11 +1,13 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios'; import axios from 'axios';
import { get } from 'lodash'; import { get } from 'lodash';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { MediaStatus } from '../../../constants/media'; import { BaseAgent } from './agent';
import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
type KeyMapFunction = ( type KeyMapFunction = (
payload: NotificationPayload, payload: NotificationPayload,
@@ -162,7 +164,10 @@ class WebhookAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) { if (
!payload.notifySystem ||
!hasNotificationType(type, settings.types ?? 0)
) {
return true; return true;
} }

View File

@@ -1,17 +1,15 @@
import { getRepository } from 'typeorm'; import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { NotificationAgentConfig } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import webpush from 'web-push'; import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..'; import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue'; import type { NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media'; import { BaseAgent } from './agent';
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';
interface PushNotificationPayload { interface PushNotificationPayload {
notificationType: string; notificationType: string;
@@ -59,6 +57,11 @@ class WebPushAgent
case Notification.TEST_NOTIFICATION: case Notification.TEST_NOTIFICATION:
message = payload.message; message = payload.message;
break; break;
case Notification.MEDIA_AUTO_REQUESTED:
message = `Automatically submitted a new ${
is4k ? '4K ' : ''
}${mediaType} request.`;
break;
case Notification.MEDIA_APPROVED: case Notification.MEDIA_APPROVED:
message = `Your ${ message = `Your ${
is4k ? '4K ' : '' is4k ? '4K ' : ''
@@ -160,7 +163,7 @@ class WebPushAgent
true) true)
) { ) {
const notifySubs = await userPushSubRepository.find({ const notifySubs = await userPushSubRepository.find({
where: { user: payload.notifyUser.id }, where: { user: { id: payload.notifyUser.id } },
}); });
pushSubs.push(...notifySubs); pushSubs.push(...notifySubs);

View File

@@ -1,6 +1,6 @@
import { User } from '../../entity/User'; import type { User } from '@server/entity/User';
import logger from '../../logger'; import { Permission } from '@server/lib/permissions';
import { Permission } from '../permissions'; import logger from '@server/logger';
import type { NotificationAgent, NotificationPayload } from './agents/agent'; import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification { export enum Notification {
@@ -16,6 +16,7 @@ export enum Notification {
ISSUE_COMMENT = 512, ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024, ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048, ISSUE_REOPENED = 2048,
MEDIA_AUTO_REQUESTED = 4096,
} }
export const hasNotificationType = ( export const hasNotificationType = (

View File

@@ -22,6 +22,11 @@ export enum Permission {
MANAGE_ISSUES = 1048576, MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152, VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304, CREATE_ISSUES = 4194304,
AUTO_REQUEST = 8388608,
AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

View File

@@ -1,12 +1,12 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { getRepository } from 'typeorm';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden) // Default scan rates (can be overidden)
const BUNDLE_SIZE = 20; const BUNDLE_SIZE = 20;
@@ -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 * should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method * 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 * does not take an `is4k` option. We handle both the 4k _and_ non 4k status

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { import type {
TmdbMovieDetails, TmdbMovieDetails,
TmdbMovieResult, TmdbMovieResult,
TmdbPersonDetails, TmdbPersonDetails,
@@ -9,13 +9,17 @@ import {
TmdbSearchTvResponse, TmdbSearchTvResponse,
TmdbTvDetails, TmdbTvDetails,
TmdbTvResult, TmdbTvResult,
} from '../api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import { import {
mapMovieDetailsToResult, mapMovieDetailsToResult,
mapPersonDetailsToResult, mapPersonDetailsToResult,
mapTvDetailsToResult, mapTvDetailsToResult,
} from '../models/Search'; } from '@server/models/Search';
import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers'; import {
isMovie,
isMovieDetails,
isTvDetails,
} from '@server/utils/typeHelpers';
interface SearchProvider { interface SearchProvider {
pattern: RegExp; pattern: RegExp;

View File

@@ -1,9 +1,9 @@
import { MediaServerType } from '@server/constants/server';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
import { MediaServerType } from '../constants/server';
import { Permission } from './permissions'; import { Permission } from './permissions';
export interface Library { export interface Library {
@@ -257,6 +257,7 @@ interface JobSettings {
export type JobId = export type JobId =
| 'plex-recently-added-scan' | 'plex-recently-added-scan'
| 'plex-full-scan' | 'plex-full-scan'
| 'plex-watchlist-sync'
| 'radarr-scan' | 'radarr-scan'
| 'sonarr-scan' | 'sonarr-scan'
| 'download-sync' | 'download-sync'
@@ -424,6 +425,9 @@ class Settings {
'plex-full-scan': { 'plex-full-scan': {
schedule: '0 0 3 * * *', schedule: '0 0 3 * * *',
}, },
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
},
'radarr-scan': { 'radarr-scan': {
schedule: '0 0 4 * * *', schedule: '0 0 4 * * *',
}, },

163
server/lib/watchlistsync.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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